From 1ca60fbc3a3f85c7072a9029346b5d31402ac0e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 15:55:16 -0700 Subject: [PATCH] refactor(agents): make multi-agent ownership explicit (H2-1 core) (#114388) * refactor(agents): make roster ownership explicit * feat(config): materialize legacy agent roles * fix(cron): migrate legacy owners at startup * feat(gateway): expose agent selection contracts * fix(gateway): enforce agent-scoped authorization * docs(config): document explicit agent ownership * fix(config): pin retained owner workspace * fix(gateway): target hook wakes at effective agent * fix(sessions): preserve fixed-store ownership * fix: preserve retained agent ownership * fix: preserve legacy agent ownership across runtime surfaces * fix: fail closed on ambiguous session ownership * fix: preserve compatibility owners across dispatch and writes * fix: preserve retained agent projections * fix: preserve agent ownership compatibility * fix: preserve per-agent heartbeat guidance * fix: preserve compatibility owners in generic paths * fix: enforce configured ownership in session paths * fix: defer remote roster selection * fix: preserve ownership across session and config writes * fix: fail closed on ambiguous restored ownership * fix: preserve explicit ACP and legacy ownership * fix: honor durable fixed-store ownership * fix: enforce fixed-store owner authority * fix: preserve ownership evidence boundaries * fix: honor resolved session ownership * fix: align compatibility ownership paths * fix: persist legacy main store ownership * fix: close ownership fallback gaps * fix(agents): close retained owner compatibility gaps * fix(agents): enforce session owner resolution * fix(agents): complete session owner resolution sweep * fix(agents): preserve durable session ownership * fix: complete persisted session owner routing * fix: thread prepared session owners * fix: preserve stable session ownership * fix: enforce session ownership boundaries * fix: close session ownership delta gaps * fix: reconcile session ownership after rebase * fix: reconcile ownership with current main * fix: align session store path imports * fix: align session store config path import * fix: reconcile explicit ownership CI * fix: reconcile ownership rebase checks * fix: align ownership ci contracts * fix: align ownership rebase checks * fix: preserve compatibility owner during setup * fix(doctor): migrate ownerless heartbeat monitors * fix(gateway): preserve explicit session ownership * test: align ownership fixtures after rebase * test: complete plugin manifest fixture * test: align runtime context mocks * fix(gateway): preserve alias routing for existing sessions * style: format agent routing update * fix(gateway): preserve selected owner during alias routing * style: normalize rebased ownership files * fix(gateway): preserve owner through global alias routing * fix(gateway): preserve explicit ownership at HTTP boundaries * fix(gateway): validate compatibility model ownership * fix(agents): reconcile strict session ownership * fix(agents): contain media yield callback failures * fix(agents): avoid eager bare-key owner resolution * chore: refresh rebased ownership baselines * chore: align hosted plugin SDK baseline * chore: refresh ownership baselines after main sync * chore: refresh ownership baselines after main sync * test: align routed event owner fixtures * chore: retrigger CI after runner startup failure * chore: refresh ownership SDK budgets after main sync * fix(tasks): require agent identity for bare owners * chore: align Linux plugin SDK baseline * chore: remove release-owned changelog entry --- .../OpenClawProtocol/GatewayModels.swift | 94 +++- docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- .../plugin-sdk-api-baseline/account-core.json | 2 +- .../account-helpers.json | 2 +- .../account-resolution.json | 2 +- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../agent-media-payload.json | 2 +- .../agent-runtime.json | 2 +- .../agent-scope-runtime.json | 2 +- .../allowlist-config-edit.json | 2 +- .../approval-auth-runtime.json | 2 +- .../approval-client-runtime.json | 2 +- .../approval-delivery-runtime.json | 2 +- .../approval-gateway-runtime.json | 2 +- .../approval-handler-adapter-runtime.json | 2 +- .../approval-handler-runtime.json | 2 +- .../approval-native-runtime.json | 2 +- .../approval-runtime.json | 2 +- .../channel-config-helpers.json | 2 +- .../channel-contract.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-dm-policy.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-feedback.json | 2 +- .../channel-inbound-debounce.json | 2 +- .../channel-inbound.json | 2 +- .../channel-ingress-runtime.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-pairing.json | 2 +- .../channel-plugin-common.json | 2 +- .../channel-policy.json | 2 +- .../channel-reply-pipeline.json | 2 +- .../channel-secret-basic-runtime.json | 2 +- .../channel-send-result.json | 2 +- .../channel-setup.json | 2 +- .../command-auth-native.json | 2 +- .../plugin-sdk-api-baseline/command-auth.json | 2 +- .../command-detection.json | 2 +- .../command-status.json | 2 +- .../config-contracts.json | 2 +- .../config-mutation.json | 2 +- .../config-runtime.json | 2 +- .../conversation-runtime.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../diagnostic-runtime.json | 2 +- .../directory-runtime.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../extension-shared.json | 2 +- .../gateway-runtime.json | 2 +- .../plugin-sdk-api-baseline/health.json | 2 +- .../plugin-sdk-api-baseline/hook-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../infra-runtime.json | 2 +- .../plugin-sdk-api-baseline/logging-core.json | 2 +- .../media-local-roots.json | 2 +- .../media-runtime.json | 2 +- .../media-understanding-runtime.json | 2 +- .../media-understanding.json | 2 +- .../meeting-runtime.json | 2 +- .../memory-core-host-engine-foundation.json | 2 +- .../memory-host-core.json | 2 +- .../model-session-runtime.json | 2 +- .../models-provider-runtime.json | 2 +- .../native-command-config-runtime.json | 2 +- .../native-command-registry.json | 2 +- .../plugin-command-runtime.json | 2 +- .../plugin-config-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-auth.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../question-gateway-runtime.json | 2 +- .../reply-chunking.json | 2 +- .../reply-dispatch-runtime.json | 2 +- .../reply-payload.json | 2 +- .../reply-runtime.json | 2 +- .../plugin-sdk-api-baseline/routing.json | 2 +- .../runtime-config-snapshot.json | 2 +- .../runtime-store.json | 2 +- .../plugin-sdk-api-baseline/runtime.json | 2 +- .../secret-input-runtime.json | 2 +- .../secret-ref-runtime.json | 2 +- .../security-runtime.json | 2 +- .../session-catalog.json | 2 +- .../session-discussion.json | 2 +- .../session-store-runtime.json | 2 +- .../setup-runtime.json | 2 +- .../plugin-sdk-api-baseline/setup.json | 2 +- .../skill-commands-runtime.json | 2 +- .../speech-settings.json | 2 +- .../plugin-sdk-api-baseline/ssrf-policy.json | 2 +- .../plugin-sdk-api-baseline/ssrf-runtime.json | 2 +- .../status-helpers.json | 2 +- .../telegram-account.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- .../webhook-request-guards.json | 2 +- docs/automation/cron-jobs.md | 22 +- docs/channels/discord.md | 1 - docs/gateway/config-agents.md | 32 +- docs/help/faq.md | 2 +- docs/plugins/codex-harness.md | 9 +- docs/tools/acp-agents.md | 2 +- packages/gateway-protocol/CHANGELOG.md | 1 + .../src/schema/agents-models-skills.test.ts | 23 +- .../src/schema/agents-models-skills.ts | 11 +- packages/gateway-protocol/src/schema/board.ts | 10 +- .../gateway-protocol/src/schema/hooks.test.ts | 2 + packages/gateway-protocol/src/schema/hooks.ts | 8 +- .../gateway-protocol/src/schema/plugins.ts | 1 + .../src/schema/session-discussion.ts | 2 + .../src/schema/sessions-viewer-presence.ts | 3 +- .../gateway-protocol/src/schema/sessions.ts | 4 + .../gateway-protocol/src/schema/snapshot.ts | 3 + .../gateway-protocol/src/schema/ui-command.ts | 1 + scripts/plugin-sdk-surface-report.mts | 11 +- src/acp/runtime/session-meta-keys.ts | 205 +++++++++ .../runtime/session-meta-legacy-cleanup.ts | 26 ++ src/acp/runtime/session-meta-store.test.ts | 116 +++++ src/acp/runtime/session-meta-store.ts | 64 ++- src/acp/runtime/session-meta.test.ts | 272 ++++++++++- src/acp/runtime/session-meta.ts | 366 +++++++-------- src/agents/acp-spawn-heartbeat.test.ts | 68 +++ src/agents/agent-bundle-mcp-harness.test.ts | 9 + src/agents/agent-bundle-mcp-harness.ts | 3 + src/agents/agent-bundle-mcp-materialize.ts | 2 + src/agents/agent-create.integration.test.ts | 14 +- src/agents/agent-create.test.ts | 63 ++- src/agents/agent-create.ts | 37 +- src/agents/agent-scope-config.test.ts | 43 +- src/agents/agent-scope-config.ts | 133 ++++-- src/agents/agent-scope.ts | 57 ++- src/agents/agent-tools.policy.test.ts | 43 ++ src/agents/agent-tools.policy.ts | 19 +- .../bash-tools.exec-approval-followup.test.ts | 15 +- .../bash-tools.exec-approval-followup.ts | 15 +- src/agents/bash-tools.exec-host-gateway.ts | 1 + src/agents/bash-tools.exec-host-node.ts | 1 + .../bash-tools.exec-host-shared.test.ts | 18 + src/agents/bash-tools.exec-host-shared.ts | 3 + src/agents/cli-runner.reliability.test.ts | 64 +++ .../claude-live-session-policy.test.ts | 27 ++ src/agents/cli-runner/cli-run-transcript.ts | 26 +- src/agents/cli-runner/mcp-grant-context.ts | 9 +- src/agents/cli-runner/prepare.test.ts | 8 +- src/agents/cli-runner/prepare.ts | 21 +- src/agents/code-mode-bridge.ts | 10 +- src/agents/code-mode-swarm.test.ts | 4 + src/agents/command/explicit-session-key.ts | 36 ++ .../command/prepare.session-key.test.ts | 52 +++ src/agents/command/prepare.ts | 26 +- .../session.resolve-session-key.test.ts | 425 +++++++++++++++++- src/agents/command/session.ts | 375 +++++++++++++--- ...gent-runner.resolvesessionagentids.test.ts | 141 +++++- .../compaction-checkpoint.ts | 1 + .../context-engine-maintenance.ts | 10 + src/agents/embedded-agent-runner/model.ts | 5 +- .../run.overflow-compaction.loop.test.ts | 1 + .../run/attempt-bundle-tools.ts | 1 + .../run/attempt-execution-settle.test.ts | 2 + .../run/attempt-history.ts | 11 +- .../run/attempt-normalization.test.ts | 114 ++++- .../run/attempt-prompt-build.ts | 1 + .../run/attempt-prompt-helpers.ts | 7 +- .../run/attempt-settle.ts | 1 + .../run/attempt-transcript-helpers.ts | 3 + .../run/attempt.prompt-helpers.test.ts | 6 +- .../embedded-agent-runner/run/backend.ts | 1 + .../run/session-bootstrap.ts | 57 ++- .../runs.force-clear-terminal.test.ts | 36 ++ src/agents/embedded-agent-runner/runs.ts | 15 +- ...ed-agent-subscribe.handlers.tools.start.ts | 17 +- src/agents/harness/compaction.ts | 2 +- .../harness/context-engine-lifecycle.test.ts | 5 +- .../harness/context-engine-lifecycle.ts | 3 +- src/agents/harness/selection.test.ts | 14 +- src/agents/harness/support.ts | 11 +- src/agents/heartbeat-system-prompt.test.ts | 40 +- src/agents/heartbeat-system-prompt.ts | 64 ++- src/agents/identity-avatar.test.ts | 60 ++- src/agents/identity-avatar.ts | 11 +- src/agents/legacy-inherited-auth-dir.test.ts | 13 + src/agents/legacy-inherited-auth-dir.ts | 70 +++ src/agents/local-model-lean.test.ts | 37 ++ src/agents/local-model-lean.ts | 18 +- .../main-session-restart-dispatch.ts | 2 + .../main-session-restart-recovery-failure.ts | 4 +- .../main-session-restart-recovery-store.ts | 31 +- .../main-session-restart-recovery.test.ts | 48 ++ src/agents/mcp-ui-resource.test.ts | 36 +- src/agents/mcp-ui-resource.ts | 15 +- ...edia-generation-task-status-shared.test.ts | 30 ++ .../media-generation-task-status-shared.ts | 60 ++- src/agents/model-runtime-policy.test.ts | 48 ++ src/agents/model-runtime-policy.ts | 20 +- src/agents/openai-routing.test.ts | 34 ++ src/agents/openai-routing.ts | 11 +- src/agents/openclaw-tools.agents.test.ts | 2 +- src/agents/openclaw-tools.media-yield.test.ts | 35 ++ src/agents/openclaw-tools.media-yield.ts | 23 + .../openclaw-tools.registration.test.ts | 15 +- src/agents/openclaw-tools.requester-yield.ts | 17 + .../openclaw-tools.session-status.test.ts | 117 ++++- src/agents/openclaw-tools.sessions.test.ts | 1 + ...subagents.sessions-spawn.allowlist.test.ts | 2 +- src/agents/openclaw-tools.ts | 51 +-- src/agents/openclaw-tools.tts-config.test.ts | 74 +-- src/agents/prepared-model-catalog.test.ts | 1 + src/agents/prepared-model-catalog.ts | 22 +- src/agents/prepared-model-registry.test.ts | 5 + src/agents/prepared-model-registry.ts | 10 +- ...red-model-runtime.inbound-registry.test.ts | 3 +- .../prepared-model-runtime.lifecycle.test.ts | 10 +- ...ared-model-runtime.owner-selection.test.ts | 8 +- src/agents/prepared-model-runtime.owner.ts | 12 +- ...pared-model-runtime.startup-static.test.ts | 6 + .../prepared-model-runtime.test-harness.ts | 27 ++ src/agents/run-session-target.test.ts | 95 ++++ src/agents/run-session-target.ts | 84 +++- src/agents/run-wait.ts | 11 +- src/agents/sandbox/context.ts | 1 + src/agents/sandbox/runtime-status.ts | 27 +- src/agents/sandbox/shared.test.ts | 15 + src/agents/sandbox/shared.ts | 9 +- src/agents/sandbox/tool-policy.test.ts | 98 ++++ src/agents/session-agent-binding.test.ts | 18 + src/agents/session-agent-binding.ts | 37 +- src/agents/spawn-plan.ts | 7 +- src/agents/subagent-requester-owner.test.ts | 78 ++++ src/agents/subagent-requester-owner.ts | 44 ++ .../subagent-requester-store-key.test.ts | 31 ++ .../subagent-announce-delivery.runtime.ts | 5 +- ...subagent-announce-delivery.test-support.ts | 5 +- .../subagent-announce-delivery.test.ts | 332 +++++++++++++- .../announce/subagent-announce-delivery.ts | 163 +++++-- .../announce/subagent-announce-output.ts | 7 +- ...ent-announce.requester-settle-wake.test.ts | 41 +- ...subagent-announce.requester-settle-wake.ts | 28 +- .../subagents/announce/subagent-announce.ts | 19 +- .../announce/subagent-requester-store-key.ts | 23 +- .../registry/subagent-active-context.ts | 7 +- .../registry/subagent-control.test.ts | 28 ++ .../subagents/registry/subagent-control.ts | 131 +++++- .../registry/subagent-registry-lifecycle.ts | 1 + .../registry/subagent-registry-public-api.ts | 11 +- .../registry/subagent-registry-queries.ts | 27 +- .../registry/subagent-registry-read.ts | 12 +- .../subagent-registry-requester-yield.ts | 4 + .../registry/subagent-registry-restore.ts | 30 +- .../registry/subagent-registry-state.ts | 1 + .../subagent-registry.store.sqlite.ts | 3 + .../subagents/registry/subagent-registry.ts | 4 +- .../registry/subagent-registry.types.ts | 1 + .../subagents/spawn/acp-spawn-heartbeat.ts | 23 +- .../subagents/spawn/acp-spawn-requester.ts | 1 + src/agents/subagents/spawn/acp-spawn.ts | 3 +- .../subagents/spawn/subagent-capabilities.ts | 5 + .../subagents/spawn/subagent-depth.test.ts | 63 +++ src/agents/subagents/spawn/subagent-depth.ts | 53 ++- .../subagents/spawn/subagent-spawn-context.ts | 2 + .../subagents/spawn/subagent-spawn-request.ts | 19 +- .../spawn/subagent-spawn-requester-prefs.ts | 2 + .../subagents/spawn/subagent-spawn.test.ts | 20 +- src/agents/tools/agent-step.ts | 4 + src/agents/tools/agents-list-tool.test.ts | 18 + src/agents/tools/agents-list-tool.ts | 20 +- src/agents/tools/agents-wait-tool.test.ts | 24 + src/agents/tools/agents-wait-tool.ts | 71 ++- .../tools/ask-user-tool-normalization.ts | 108 +++++ src/agents/tools/ask-user-tool.test.ts | 20 + src/agents/tools/ask-user-tool.ts | 148 ++---- src/agents/tools/cron-tool-caller-scope.ts | 2 +- src/agents/tools/cron-tool-context.ts | 2 + src/agents/tools/cron-tool.ts | 7 +- src/agents/tools/cron-tool.types.ts | 1 + src/agents/tools/dashboard-tool.ts | 29 +- .../tools/embedded-gateway-stub.test.ts | 20 + src/agents/tools/embedded-gateway-stub.ts | 10 +- .../tools/image-generate-tool.actions.ts | 11 +- src/agents/tools/image-generate-tool.ts | 15 +- .../tools/media-generate-background-shared.ts | 15 +- .../media-generate-tool-actions-shared.ts | 18 +- src/agents/tools/message-tool-execution.ts | 6 +- .../tools/music-generate-tool.actions.ts | 3 +- src/agents/tools/music-generate-tool.ts | 15 +- src/agents/tools/nodes-tool.ts | 2 + src/agents/tools/openclaw-delegate-tool.ts | 12 +- src/agents/tools/scoped-session-access.ts | 37 +- src/agents/tools/screen-tool.ts | 4 +- src/agents/tools/session-status-tool.ts | 103 +++-- src/agents/tools/sessions-announce-target.ts | 12 +- .../tools/sessions-history-tool.test.ts | 54 +++ src/agents/tools/sessions-history-tool.ts | 56 ++- src/agents/tools/sessions-list-tool.test.ts | 86 +++- src/agents/tools/sessions-list-tool.ts | 55 ++- .../tools/sessions-resolution.strict.test.ts | 111 +++++ src/agents/tools/sessions-resolution.test.ts | 346 ++------------ src/agents/tools/sessions-resolution.ts | 116 +++-- src/agents/tools/sessions-search-tool.test.ts | 27 ++ src/agents/tools/sessions-search-tool.ts | 48 +- src/agents/tools/sessions-send-tool.a2a.ts | 52 ++- src/agents/tools/sessions-send-tool.ts | 169 +++++-- src/agents/tools/sessions-spawn-visible.ts | 21 +- .../tools/sessions-tool.test-helpers.ts | 63 +++ src/agents/tools/sessions-tool.test.ts | 174 ++++--- src/agents/tools/sessions-tool.ts | 95 ++-- src/agents/tools/sessions.test.ts | 195 +++++++- src/agents/tools/subagents-tool.ts | 61 ++- src/agents/tools/terminal-tool.test.ts | 3 +- src/agents/tools/terminal-tool.ts | 27 +- .../tools/video-generate-tool.actions.ts | 3 +- src/agents/tools/video-generate-tool.ts | 15 +- src/agents/workspace-dirs.ts | 19 +- src/agents/workspace-run.test.ts | 24 + src/agents/workspace-run.ts | 31 +- ...-runner-execution-context-failures.test.ts | 6 + .../reply/bash-command.stop.test.ts | 4 +- src/auto-reply/reply/bash-command.ts | 1 + .../reply/commands-acp/diagnostics.ts | 6 +- .../reply/commands-acp/runtime-options.ts | 2 + src/auto-reply/reply/commands-learn.ts | 1 + .../reply/commands-plugins.install.test.ts | 4 +- .../reply/commands-subagents/action-focus.ts | 1 + .../reply/commands-subagents/action-info.ts | 2 + .../reply/commands-system-prompt.test.ts | 1 + .../reply/commands-system-prompt.ts | 1 + .../reply/directive-handling.impl.ts | 20 +- .../reply/directive-runtime-context.ts | 25 ++ src/auto-reply/reply/dispatch-acp.ts | 1 + src/auto-reply/reply/get-reply-directives.ts | 9 +- src/auto-reply/reply/get-reply-run-context.ts | 2 +- .../reply/get-reply-run.media-only.test.ts | 4 + .../reply/runtime-policy-session-key.test.ts | 45 ++ .../reply/runtime-policy-session-key.ts | 23 +- src/auto-reply/reply/session.ts | 7 +- src/canvas/widget-tool.test.ts | 15 +- .../acp-configured-binding-consumer.ts | 8 +- .../read-only.legacy-workspace.test.ts | 97 ++++ src/channels/plugins/read-only.ts | 25 +- ...hannels-list-catalog-row-discovery.test.ts | 1 + .../channels-list-route-cold-imports.test.ts | 1 + src/cli/config-model-validation.test.ts | 15 +- src/cli/config-model-validation.ts | 44 +- src/commands/agent-via-gateway.test.ts | 328 +++++++++++++- src/commands/agent-via-gateway.ts | 330 ++++++++++++-- src/commands/agent.session.test.ts | 26 ++ src/commands/agents.commands.delete.ts | 23 +- src/commands/agents.config.ts | 98 +++- src/commands/agents.delete.test.ts | 87 +++- src/commands/agents.test.ts | 64 ++- src/commands/cleanup-utils.test.ts | 6 +- src/commands/daemon-install-helpers.test.ts | 19 + src/commands/daemon-install-helpers.ts | 8 +- .../doctor-auth-flat-profiles.test.ts | 47 ++ src/commands/doctor-auth-flat-profiles.ts | 9 +- .../doctor-auth-oauth-sidecar.test.ts | 101 +++++ src/commands/doctor-auth-oauth-sidecar.ts | 5 +- src/commands/doctor-config-flow.test.ts | 76 ++-- src/commands/doctor-config-flow.ts | 55 ++- ...ig-preflight.state-migration-input.test.ts | 4 +- ...doctor-heartbeat-cadence-migration.test.ts | 2 +- .../doctor-heartbeat-cadence-migration.ts | 4 +- ...doctor-heartbeat-scratch-migration.test.ts | 22 +- src/commands/doctor.test.ts | 6 +- src/commands/doctor/cron/legacy-repair.ts | 18 +- src/commands/doctor/repair-sequencing.test.ts | 96 +++- src/commands/doctor/repair-sequencing.ts | 18 +- .../shared/config-mutation-state.test.ts | 18 + .../doctor/shared/config-mutation-state.ts | 6 +- ...default-agent-role-materialization.test.ts | 63 ++- .../default-agent-role-materialization.ts | 150 ------- ...t-agent-role-materialization.write.test.ts | 324 ++++++++++++- .../shared/plugin-metadata-snapshot-scope.ts | 50 ++- src/commands/health.ts | 8 +- src/commands/onboard-agent-target.test.ts | 13 + src/commands/onboard-agent-target.ts | 7 +- src/commands/onboard-agent.test.ts | 4 +- src/commands/onboard-agent.ts | 8 +- src/commands/onboard-inference.ts | 13 +- .../onboard-non-interactive.gateway.test.ts | 2 +- src/commands/onboard-non-interactive/local.ts | 5 +- src/commands/sandbox-explain.ts | 1 + src/commands/sessions-tail.test.ts | 15 +- src/commands/sessions.test.ts | 43 ++ src/commands/sessions.ts | 4 + src/commands/setup.test.ts | 7 +- src/commands/setup.ts | 21 +- .../schema.help.quality.test.ts.snap | 1 - src/config/agent-roster-provenance.ts | 10 + .../agent-workspace-roster-transition.ts | 55 +++ src/config/config-misc.test.ts | 1 + src/config/config.plugin-validation.test.ts | 46 ++ src/config/io.auth-inheritance-owner.ts | 38 ++ src/config/io.best-effort.test.ts | 2 +- src/config/io.context.plugin-metadata.test.ts | 90 ++++ src/config/io.context.ts | 61 ++- src/config/io.cron-owner-refusal.test.ts | 43 ++ src/config/io.cron-owner-refusal.ts | 77 ++++ src/config/io.load.ts | 2 +- src/config/io.ownership-write-guard.ts | 16 + src/config/io.plugin-metadata.ts | 64 +++ src/config/io.session-store-owner.ts | 45 ++ src/config/io.snapshot-shared.ts | 4 + src/config/io.snapshot.ts | 30 +- src/config/io.write-config.test.ts | 28 +- src/config/io.write-prepare.test.ts | 25 ++ src/config/io.write-prepare.ts | 13 +- src/config/io.write.ts | 192 +++++++- .../legacy.default-agent-owner-state.ts | 14 + src/config/legacy.default-agent-owner.ts | 34 ++ src/config/legacy.default-agent-roles.ts | 148 ++++++ src/config/legacy.roster.test.ts | 135 +++--- src/config/legacy.roster.ts | 106 +++-- src/config/materialize.ts | 3 +- src/config/runtime-overrides.test.ts | 24 +- src/config/runtime-overrides.ts | 7 +- src/config/runtime-schema.test.ts | 2 +- src/config/runtime-schema.ts | 12 +- src/config/schema.help.core.ts | 10 + src/config/schema.labels.ts | 5 + src/config/schema.tiers.ts | 2 +- src/config/sessions/cleanup-service.ts | 4 +- src/config/sessions/combined-store-gateway.ts | 10 +- src/config/sessions/main-session.ts | 29 +- .../session-accessor.transcript-owner.test.ts | 280 ++++++++++++ .../session-accessor.transcript-turn.ts | 106 ++++- .../sessions/session-store-config.test.ts | 53 +++ src/config/sessions/session-store-config.ts | 125 ++++++ src/config/sessions/session-store-owner.ts | 62 +++ .../sessions/targets-path-validation.ts | 61 +++ .../targets-read-availability.test.ts | 31 +- .../sessions/targets-read-availability.ts | 12 +- src/config/sessions/targets.test-support.ts | 63 +++ src/config/sessions/targets.test.ts | 127 +++--- src/config/sessions/targets.ts | 152 +++---- src/config/types.agent-defaults.ts | 8 + src/config/types.agents.ts | 2 + src/config/types.openclaw.ts | 1 + src/config/validation-core.ts | 41 +- src/config/validation.ts | 122 +++-- src/config/zod-schema.agent-defaults.ts | 12 + src/config/zod-schema.agent-runtime.ts | 1 - src/config/zod-schema.agents.test.ts | 92 +++- src/config/zod-schema.agents.ts | 33 +- ...gacy-default-agent-owner-migration.test.ts | 154 +++++++ .../legacy-default-agent-owner-migration.ts | 19 + src/cron/service/ops-lifecycle.ts | 19 + src/cron/service/ops-mutations.ts | 11 +- src/cron/service/state.ts | 3 +- src/cron/store/row-codec.ts | 72 ++- src/cron/store/scalar-codec.ts | 6 + src/cron/store/schema.ts | 14 +- src/flows/doctor-health-contributions.test.ts | 8 + src/gateway/agent-list.test.ts | 31 ++ src/gateway/agent-list.ts | 53 ++- .../agent-turn/agent-admission-controller.ts | 7 +- src/gateway/agent-turn/agent-content-phase.ts | 45 +- .../agent-turn/agent-dedupe-lifecycle.ts | 12 +- src/gateway/agent-turn/agent-dedupe.ts | 8 + .../agent-turn/agent-delivery-phase.ts | 37 +- .../agent-request-preflight.test.ts | 48 ++ .../agent-turn/agent-request-preflight.ts | 33 +- .../agent-turn/agent-request-routing.ts | 104 +++-- .../agent-turn/agent-run-admission-phase.ts | 5 +- .../agent-turn/agent-run-execution-phase.ts | 22 +- .../agent-turn/agent-session-persist.ts | 4 +- src/gateway/agent-turn/agent-turn-service.ts | 5 +- src/gateway/assistant-identity.test.ts | 40 ++ src/gateway/assistant-identity.ts | 13 +- src/gateway/chat-abort.ts | 16 +- src/gateway/chat-queued-turns.ts | 18 +- src/gateway/chat-run-owner.test.ts | 27 ++ src/gateway/chat-run-owner.ts | 23 + src/gateway/embeddings-http.test.ts | 10 +- src/gateway/embeddings-http.ts | 8 +- .../health/collector.legacy-owner.test.ts | 132 ++++++ src/gateway/health/collector.ts | 37 +- src/gateway/health/types.ts | 2 +- src/gateway/hooks-test-helpers.ts | 1 + src/gateway/hooks.test.ts | 15 + src/gateway/hooks.ts | 54 ++- .../http-utils.request-context.test.ts | 11 + src/gateway/http-utils.ts | 43 +- src/gateway/local-request-context.ts | 12 +- src/gateway/managed-image-attachments.test.ts | 16 + src/gateway/managed-image-attachments.ts | 13 +- src/gateway/mcp-app-operations.ts | 14 +- src/gateway/mcp-app-reconstruction.test.ts | 1 + src/gateway/mcp-app-reconstruction.ts | 9 +- src/gateway/mcp-app-standalone.test.ts | 1 + src/gateway/mcp-grant-store.ts | 2 + src/gateway/mcp-http.runtime.test.ts | 11 + src/gateway/mcp-http.runtime.ts | 1 + src/gateway/mcp-http.test.ts | 3 +- src/gateway/mcp-http.ts | 1 + src/gateway/models-http.test.ts | 31 ++ src/gateway/models-http.ts | 28 +- src/gateway/openai-http.test.ts | 53 ++- src/gateway/openai-http.ts | 9 +- src/gateway/openresponses-http.test.ts | 53 ++- src/gateway/openresponses-http.ts | 15 +- src/gateway/server-broadcast.board.test.ts | 7 +- src/gateway/server-chat.agent-events.test.ts | 2 + src/gateway/server-core-runtime.ts | 12 +- src/gateway/server-cron.test.ts | 101 ++++- src/gateway/server-cron.ts | 25 +- src/gateway/server-kernel.test.ts | 2 + src/gateway/server-maintenance.ts | 10 +- .../server-methods/agent-cron-continuation.ts | 2 + src/gateway/server-methods/agent-id-shared.ts | 24 +- src/gateway/server-methods/agent-identity.ts | 26 +- .../server-methods/agent-reset-phase.ts | 6 +- .../server-methods/agent-session-prepare.ts | 22 +- .../server-methods/agent-session-reset.ts | 10 +- .../agent.abort-integration.test-utils.ts | 15 +- .../server-methods/agent.base.test-utils.ts | 13 +- .../server-methods/agent.create-event.test.ts | 1 + .../agent.media-and-routing.test-utils.ts | 5 +- .../agent.sessions-and-models.test-utils.ts | 28 +- .../server-methods/agents-mutate.test.ts | 23 + src/gateway/server-methods/agents.ts | 26 +- .../server-methods/artifacts-base64.ts | 100 +++++ .../server-methods/artifacts.test-support.ts | 93 ++++ src/gateway/server-methods/artifacts.test.ts | 242 +++++----- src/gateway/server-methods/artifacts.ts | 267 +++++------ src/gateway/server-methods/attach.test.ts | 46 +- src/gateway/server-methods/attach.ts | 35 +- .../server-methods/board.test-support.ts | 5 +- src/gateway/server-methods/board.test.ts | 32 ++ src/gateway/server-methods/board.ts | 73 ++- .../chat-abort-authorization.ts | 60 ++- .../server-methods/chat-abort-handler.ts | 77 +++- .../server-methods/chat-abort-runtime.ts | 11 +- .../server-methods/chat-assistant-content.ts | 20 +- .../server-methods/chat-broadcast.test.ts | 39 ++ src/gateway/server-methods/chat-broadcast.ts | 57 ++- .../chat-history-handler.test.ts | 43 ++ .../server-methods/chat-history-handler.ts | 51 ++- .../chat-message-get-handler.ts | 7 +- .../server-methods/chat-metadata-runtime.ts | 14 +- .../chat-origin-routing.test.ts | 32 ++ .../server-methods/chat-origin-routing.ts | 64 +-- .../server-methods/chat-restart-recovery.ts | 28 +- .../server-methods/chat-send-admission.ts | 4 +- .../chat-send-agent-dispatch.ts | 33 +- .../chat-send-dispatch-errors.test.ts | 90 ++++ .../chat-send-dispatch-errors.ts | 8 +- .../chat-send-nonagent-finalization.ts | 2 +- .../chat-send-pre-admission.test.ts | 24 + .../server-methods/chat-send-pre-admission.ts | 14 +- .../server-methods/chat-send-session.ts | 47 +- src/gateway/server-methods/chat-send-setup.ts | 8 +- .../chat-send-stop-owner-scope.ts | 13 + .../chat-startup-projection-contract.ts | 10 +- .../chat.abort-authorization.test-helpers.ts | 80 ++++ .../chat.abort-authorization.test.ts | 159 ++++--- .../chat.abort-persistence.test.ts | 34 +- .../chat.directive-tags.test.ts | 62 +-- src/gateway/server-methods/chat.ts | 26 +- src/gateway/server-methods/commands.test.ts | 2 + .../server-methods/conversations.test.ts | 68 ++- src/gateway/server-methods/conversations.ts | 64 ++- .../server-methods/cron-caller-scope.ts | 35 +- src/gateway/server-methods/cron.ts | 22 +- .../server-methods/cron.validation.test.ts | 18 +- src/gateway/server-methods/doctor.test.ts | 27 ++ src/gateway/server-methods/doctor.ts | 17 +- .../server-methods/hooks-status.test.ts | 40 ++ src/gateway/server-methods/hooks-status.ts | 14 +- src/gateway/server-methods/mcp-app.test.ts | 67 ++- src/gateway/server-methods/mcp-app.ts | 109 +++-- .../server-methods/memory-search.test.ts | 33 +- src/gateway/server-methods/memory-search.ts | 17 +- .../server-methods/model-auth-agent-scope.ts | 26 +- .../models-auth-status-usage-cache.ts | 9 +- .../server-methods/models-auth-status.test.ts | 8 + .../server-methods/models-auth-status.ts | 11 +- .../server-methods/models-probe.test.ts | 23 + src/gateway/server-methods/models-probe.ts | 7 +- src/gateway/server-methods/models.test.ts | 34 +- src/gateway/server-methods/models.ts | 14 +- .../plugin-approval.agent-runtime.test.ts | 1 + src/gateway/server-methods/plugin-approval.ts | 31 +- .../server-methods/plugin-host-hooks.ts | 26 +- src/gateway/server-methods/question.test.ts | 5 +- src/gateway/server-methods/question.ts | 29 +- src/gateway/server-methods/send.test.ts | 116 ++++- src/gateway/server-methods/send.ts | 56 ++- .../session-active-runs.test.ts | 103 ++++- .../server-methods/session-active-runs.ts | 82 +++- .../session-catalog-entry-snapshot.ts | 20 +- .../server-methods/session-catalog.test.ts | 10 +- .../session-change-event.test.ts | 101 ++++- .../server-methods/session-change-event.ts | 48 +- .../server-methods/session-discussion.test.ts | 53 ++- .../server-methods/session-discussion.ts | 39 +- src/gateway/server-methods/sessions-abort.ts | 75 +++- .../sessions-archive-lifecycle.ts | 2 +- .../server-methods/sessions-compact.ts | 25 +- .../sessions-compaction-checkpoints.ts | 8 +- src/gateway/server-methods/sessions-create.ts | 76 ++-- src/gateway/server-methods/sessions-delete.ts | 33 +- .../server-methods/sessions-diff.test.ts | 79 +++- src/gateway/server-methods/sessions-diff.ts | 41 +- .../sessions-files.preview.test.ts | 124 +++++ .../sessions-files.test-support.ts | 94 +++- .../server-methods/sessions-files.test.ts | 207 ++++----- .../sessions-files.touched-files.test.ts | 3 +- src/gateway/server-methods/sessions-files.ts | 78 +++- .../server-methods/sessions-messaging.ts | 20 +- .../server-methods/sessions-mutations.ts | 22 +- .../server-methods/sessions-patch-archive.ts | 4 +- .../server-methods/sessions-patch-engine.ts | 35 +- .../sessions-patch-model-selection.ts | 2 +- .../server-methods/sessions-read.test.ts | 42 ++ src/gateway/server-methods/sessions-read.ts | 68 ++- src/gateway/server-methods/sessions-rewind.ts | 17 +- .../server-methods/sessions-search-scope.ts | 73 +++ .../server-methods/sessions-search.test.ts | 47 ++ .../server-methods/sessions-sharing.test.ts | 36 ++ .../server-methods/sessions-sharing.ts | 12 +- .../server-methods/sessions-subscriptions.ts | 30 +- .../sessions-suggestions-access.ts | 83 ++++ .../sessions-suggestions.test-mocks.ts | 55 +++ .../sessions-suggestions.test-support.ts | 104 +++++ .../sessions-suggestions.test.ts | 260 +++++------ .../server-methods/sessions-suggestions.ts | 88 +--- .../server-methods/sessions-viewers.test.ts | 31 ++ .../sessions.abort-agent-scope.test.ts | 56 ++- .../skills-workspace-handler.test.ts | 32 ++ .../skills-workspace-handler.ts | 19 +- .../subagent-followup.test-helpers.ts | 4 +- .../system-event-routing.test.ts | 33 ++ src/gateway/server-methods/system.ts | 60 ++- .../talk-client-run-ownership.ts | 20 + src/gateway/server-methods/talk-client.ts | 58 ++- src/gateway/server-methods/talk-session.ts | 49 +- src/gateway/server-methods/talk.test.ts | 90 ++++ src/gateway/server-methods/talk.ts | 12 +- .../task-suggestions.test-support.ts | 106 +++++ .../server-methods/task-suggestions.test.ts | 169 +++---- .../server-methods/task-suggestions.ts | 78 ++-- src/gateway/server-methods/tasks.test.ts | 79 +++- src/gateway/server-methods/tasks.ts | 25 +- .../server-methods/tools-catalog.test.ts | 3 +- src/gateway/server-methods/tools-catalog.ts | 10 +- ...effective.global-agent.integration.test.ts | 2 +- .../server-methods/tools-effective.test.ts | 63 ++- src/gateway/server-methods/tools-effective.ts | 21 +- src/gateway/server-methods/ui-command.test.ts | 7 +- src/gateway/server-methods/ui-command.ts | 36 +- .../usage.sessions-usage.test.ts | 51 ++- src/gateway/server-methods/usage.test.ts | 2 +- src/gateway/server-methods/usage.ts | 62 ++- src/gateway/server-node-events.test.ts | 1 + src/gateway/server-plugin-bootstrap.ts | 2 +- src/gateway/server-plugins.ts | 2 +- src/gateway/server-restart-sentinel.test.ts | 5 +- .../server-runtime-subscriptions.test.ts | 45 ++ src/gateway/server-runtime-subscriptions.ts | 7 +- src/gateway/server-session-events.test.ts | 94 +++- src/gateway/server-session-events.ts | 88 ++-- src/gateway/server-startup-bootstrap.ts | 4 +- src/gateway/server-startup-config-helpers.ts | 12 +- .../server-startup-config.secrets.test.ts | 4 +- src/gateway/server-startup-config.ts | 3 +- src/gateway/server-startup-finish.ts | 2 +- src/gateway/server-startup-log.ts | 8 +- src/gateway/server-startup-plugins.test.ts | 1 + src/gateway/server-startup-plugins.ts | 12 +- ...erver.agent.gateway-server-agent-a.test.ts | 55 +++ .../server.chat.gateway-server-chat-b.test.ts | 9 +- src/gateway/server.config-patch.test.ts | 31 +- src/gateway/server.hooks.test.ts | 6 +- .../server.sessions.archive-owner.test.ts | 59 +++ src/gateway/server.sessions.create.test.ts | 80 +++- .../server.sessions.list-changed.test.ts | 2 +- .../server.sessions.preview-resolve.test.ts | 2 + ...ols-effective.global-agent-gateway.test.ts | 2 +- src/gateway/server/health-state.test.ts | 24 +- src/gateway/server/health-state.ts | 12 +- src/gateway/server/hooks-request-handler.ts | 92 ++-- src/gateway/server/hooks.agent-trust.test.ts | 28 +- .../server/hooks.terminal-target.test.ts | 2 + src/gateway/server/hooks.ts | 46 +- src/gateway/session-automation-index.ts | 21 +- src/gateway/session-compaction-checkpoints.ts | 2 + src/gateway/session-companion-ask.ts | 59 +-- src/gateway/session-companion-rpc.test.ts | 40 +- src/gateway/session-companion-rpc.ts | 59 ++- src/gateway/session-companion.test.ts | 107 ++++- src/gateway/session-companion.ts | 43 +- src/gateway/session-create-service.ts | 106 ++--- src/gateway/session-kill-http.test.ts | 2 + src/gateway/session-kill-http.ts | 15 +- src/gateway/session-message-events.test.ts | 58 ++- src/gateway/session-observer-audience.ts | 13 +- src/gateway/session-observer-companion.ts | 43 ++ src/gateway/session-observer-contract.ts | 2 +- src/gateway/session-observer-model.ts | 7 + src/gateway/session-observer.schema.test.ts | 43 ++ src/gateway/session-observer.test.ts | 91 ++-- src/gateway/session-observer.ts | 38 +- src/gateway/session-request-agent.test.ts | 94 ++++ src/gateway/session-request-agent.ts | 138 ++++-- src/gateway/session-reset-notifications.ts | 6 +- src/gateway/session-reset-service.ts | 34 +- src/gateway/session-sharing-target-input.ts | 40 ++ src/gateway/session-sharing.ts | 85 ++-- src/gateway/session-store-key.ts | 70 ++- src/gateway/session-utils-list.ts | 48 +- .../session-utils-model.acp-owner.test.ts | 36 ++ src/gateway/session-utils-model.ts | 38 +- src/gateway/session-utils-projection.ts | 4 +- src/gateway/session-utils-row.ts | 10 +- src/gateway/session-utils-search.ts | 7 +- src/gateway/session-utils-store.ts | 22 +- src/gateway/session-utils.perf.test.ts | 6 +- src/gateway/session-utils.test.ts | 112 ++++- src/gateway/sessions-patch.test.ts | 1 + src/gateway/sessions-patch.ts | 6 +- src/gateway/sessions-resolve-store.test.ts | 86 +++- src/gateway/sessions-resolve.test.ts | 13 +- src/gateway/sessions-resolve.ts | 126 +++++- src/gateway/terminal/launch.test.ts | 4 +- src/gateway/terminal/launch.ts | 5 +- .../session-manager.task-lifecycle.test.ts | 27 ++ src/gateway/terminal/session-manager.ts | 66 ++- src/gateway/terminal/session-manager.types.ts | 2 +- src/gateway/tool-resolution.test.ts | 45 ++ src/gateway/tool-resolution.ts | 48 +- src/gateway/tools-invoke-http.test.ts | 9 +- src/gateway/tools-invoke-shared.ts | 47 +- src/infra/agent-run-registry.ts | 46 +- src/infra/exec-approvals-effective.ts | 7 +- src/infra/heartbeat-agent-resolution.ts | 16 + src/infra/heartbeat-runner-config.ts | 14 +- src/infra/heartbeat-runner-execution.ts | 4 +- src/infra/heartbeat-runner-session.ts | 5 +- ...tbeat-runner.returns-default-unset.test.ts | 13 +- src/infra/heartbeat-summary.ts | 16 +- src/infra/path-case.ts | 29 +- src/infra/state-migrations.doctor.ts | 102 +++-- ...rations.onboarding-recommendations.test.ts | 6 +- ...e-migrations.onboarding-recommendations.ts | 52 ++- ...tate-migrations.workspace-setup-sandbox.ts | 2 + src/memory-host-sdk/dreaming.test.ts | 23 + src/memory-host-sdk/dreaming.ts | 8 +- src/plugin-sdk/agent-harness-runtime.ts | 2 + src/plugins/channel-plugin-ids.test.ts | 18 +- src/plugins/channel-presence-policy.ts | 84 +++- src/plugins/config-contracts.test.ts | 42 ++ src/plugins/config-contracts.ts | 10 +- src/plugins/effective-plugin-ids.test.ts | 24 + src/plugins/gateway-startup-plugin-config.ts | 43 +- .../gateway-startup-plugin-metadata.ts | 2 + src/plugins/gateway-startup-plugin-plan.ts | 12 +- src/plugins/host-hook-state.ts | 7 +- src/plugins/host-hooks.ts | 1 + src/plugins/plugin-metadata-snapshot.ts | 24 +- src/plugins/registry-contribution-types.ts | 4 +- src/plugins/runtime/load-context.test.ts | 34 +- src/plugins/runtime/load-context.ts | 47 +- src/plugins/runtime/runtime-agent.ts | 3 + src/plugins/runtime/runtime-tasks.test.ts | 84 ++++ src/plugins/runtime/runtime-tasks.ts | 13 +- src/plugins/runtime/runtime-tasks.types.ts | 3 +- src/routing/bindings.ts | 8 +- src/routing/channel-route-targets.ts | 15 +- src/routing/resolve-route.ts | 35 +- src/secrets/apply.test.ts | 2 + .../channel-contract-api.external.test.ts | 7 + .../channel-contract-api.fast-path.test.ts | 13 +- src/secrets/channel-contract-api.ts | 18 +- .../runtime-config-collectors-plugins.test.ts | 51 +++ .../runtime-config-collectors-plugins.ts | 16 +- .../runtime-external-channel-audit.test.ts | 7 + ...-external-channel-origin-discovery.test.ts | 9 + src/secrets/runtime-fast-path.ts | 21 +- src/secrets/runtime-manifest.runtime.ts | 6 +- src/secrets/runtime.coverage.test.ts | 20 - src/secrets/runtime.fast-path.test.ts | 14 +- .../runtime.loadable-plugin-origins.test.ts | 6 +- src/secrets/runtime.ts | 42 +- src/sessions/session-lifecycle-events.ts | 1 + src/state/openclaw-state-db-contract.ts | 1 + src/state/openclaw-state-db.generated.d.ts | 6 + src/state/openclaw-state-schema.sql | 5 + src/system-agent/inference-route.ts | 17 +- src/talk/agent-consult-runtime.ts | 9 +- src/talk/agent-target.ts | 20 +- src/tasks/task-owner-access.test.ts | 28 ++ src/tasks/task-owner-access.ts | 77 +++- src/tasks/task-registry-query.ts | 69 ++- src/tasks/task-registry.maintenance.ts | 2 + src/tui/tui-command-handlers.ts | 16 +- src/tui/tui-session-actions.test.ts | 40 +- src/tui/tui-session-actions.ts | 23 +- src/tui/tui.local-auth.test.ts | 62 +++ src/tui/tui.test.ts | 163 ++++--- src/tui/tui.ts | 106 ++++- test/non-isolated-runner.test.ts | 4 + ui/src/lib/session-viewer-presence.test.ts | 20 + ui/src/lib/session-viewer-presence.ts | 10 +- ui/src/pages/chat/chat-pane-base.ts | 25 +- ui/src/pages/chat/chat-pane-board.ts | 5 +- ui/src/pages/chat/chat-pane-header.ts | 4 + ui/src/pages/chat/chat-pane-render.ts | 4 +- ui/src/pages/chat/chat-session-companion.ts | 59 ++- ui/src/pages/chat/chat-session-rail.test.ts | 21 +- ui/src/pages/workboard/view-card-details.ts | 10 +- 814 files changed, 21400 insertions(+), 5690 deletions(-) create mode 100644 src/acp/runtime/session-meta-keys.ts create mode 100644 src/acp/runtime/session-meta-legacy-cleanup.ts create mode 100644 src/acp/runtime/session-meta-store.test.ts create mode 100644 src/agents/acp-spawn-heartbeat.test.ts create mode 100644 src/agents/command/explicit-session-key.ts create mode 100644 src/agents/command/prepare.session-key.test.ts create mode 100644 src/agents/legacy-inherited-auth-dir.test.ts create mode 100644 src/agents/legacy-inherited-auth-dir.ts create mode 100644 src/agents/openclaw-tools.media-yield.test.ts create mode 100644 src/agents/openclaw-tools.media-yield.ts create mode 100644 src/agents/openclaw-tools.requester-yield.ts create mode 100644 src/agents/session-agent-binding.test.ts create mode 100644 src/agents/subagent-requester-owner.test.ts create mode 100644 src/agents/subagent-requester-owner.ts create mode 100644 src/agents/subagent-requester-store-key.test.ts create mode 100644 src/agents/tools/ask-user-tool-normalization.ts create mode 100644 src/agents/tools/sessions-resolution.strict.test.ts create mode 100644 src/agents/tools/sessions-tool.test-helpers.ts create mode 100644 src/auto-reply/reply/directive-runtime-context.ts create mode 100644 src/channels/plugins/read-only.legacy-workspace.test.ts delete mode 100644 src/commands/doctor/shared/default-agent-role-materialization.ts create mode 100644 src/config/agent-workspace-roster-transition.ts create mode 100644 src/config/io.auth-inheritance-owner.ts create mode 100644 src/config/io.context.plugin-metadata.test.ts create mode 100644 src/config/io.cron-owner-refusal.test.ts create mode 100644 src/config/io.cron-owner-refusal.ts create mode 100644 src/config/io.ownership-write-guard.ts create mode 100644 src/config/io.plugin-metadata.ts create mode 100644 src/config/io.session-store-owner.ts create mode 100644 src/config/legacy.default-agent-owner-state.ts create mode 100644 src/config/legacy.default-agent-owner.ts create mode 100644 src/config/legacy.default-agent-roles.ts create mode 100644 src/config/sessions/session-accessor.transcript-owner.test.ts create mode 100644 src/config/sessions/session-store-config.test.ts create mode 100644 src/config/sessions/session-store-config.ts create mode 100644 src/config/sessions/session-store-owner.ts create mode 100644 src/config/sessions/targets-path-validation.ts create mode 100644 src/config/sessions/targets.test-support.ts create mode 100644 src/cron/legacy-default-agent-owner-migration.test.ts create mode 100644 src/cron/legacy-default-agent-owner-migration.ts create mode 100644 src/gateway/chat-run-owner.test.ts create mode 100644 src/gateway/chat-run-owner.ts create mode 100644 src/gateway/health/collector.legacy-owner.test.ts create mode 100644 src/gateway/server-methods/artifacts-base64.ts create mode 100644 src/gateway/server-methods/artifacts.test-support.ts create mode 100644 src/gateway/server-methods/chat-history-handler.test.ts create mode 100644 src/gateway/server-methods/chat-origin-routing.test.ts create mode 100644 src/gateway/server-methods/chat-send-pre-admission.test.ts create mode 100644 src/gateway/server-methods/chat-send-stop-owner-scope.ts create mode 100644 src/gateway/server-methods/chat.abort-authorization.test-helpers.ts create mode 100644 src/gateway/server-methods/sessions-files.preview.test.ts create mode 100644 src/gateway/server-methods/sessions-search-scope.ts create mode 100644 src/gateway/server-methods/sessions-suggestions-access.ts create mode 100644 src/gateway/server-methods/sessions-suggestions.test-mocks.ts create mode 100644 src/gateway/server-methods/sessions-suggestions.test-support.ts create mode 100644 src/gateway/server-methods/skills-workspace-handler.test.ts create mode 100644 src/gateway/server-methods/talk-client-run-ownership.ts create mode 100644 src/gateway/server-methods/task-suggestions.test-support.ts create mode 100644 src/gateway/server.sessions.archive-owner.test.ts create mode 100644 src/gateway/session-observer-companion.ts create mode 100644 src/gateway/session-observer.schema.test.ts create mode 100644 src/gateway/session-request-agent.test.ts create mode 100644 src/gateway/session-sharing-target-input.ts create mode 100644 src/gateway/session-utils-model.acp-owner.test.ts create mode 100644 src/infra/heartbeat-agent-resolution.ts create mode 100644 src/tui/tui.local-auth.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 036abff5b50f..1b0b2e342f4c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -777,38 +777,47 @@ public struct BoardWidgetGeneratedIdentity: Codable, Sendable { public struct BoardGetParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public init( - sessionkey: String) + sessionkey: String, + agentid: String? = nil) { self.sessionkey = sessionkey + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" } } public struct BoardUpdateParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public let ops: [BoardOp] public init( sessionkey: String, + agentid: String? = nil, ops: [BoardOp]) { self.sessionkey = sessionkey + self.agentid = agentid self.ops = ops } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" case ops } } public struct BoardWidgetPutParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public let name: String public let title: String? public let content: BoardWidgetPutContent @@ -820,6 +829,7 @@ public struct BoardWidgetPutParams: Codable, Sendable { public init( sessionkey: String, + agentid: String? = nil, name: String, title: String? = nil, content: BoardWidgetPutContent, @@ -830,6 +840,7 @@ public struct BoardWidgetPutParams: Codable, Sendable { generatedidentity: BoardWidgetGeneratedIdentity? = nil) { self.sessionkey = sessionkey + self.agentid = agentid self.name = name self.title = title self.content = content @@ -842,6 +853,7 @@ public struct BoardWidgetPutParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" case name case title case content @@ -885,6 +897,7 @@ public struct BoardWidgetPutResult: Codable, Sendable { public struct BoardWidgetGrantParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public let name: String public let decision: AnyCodable public let revision: Int @@ -892,12 +905,14 @@ public struct BoardWidgetGrantParams: Codable, Sendable { public init( sessionkey: String, + agentid: String? = nil, name: String, decision: AnyCodable, revision: Int, instanceid: String) { self.sessionkey = sessionkey + self.agentid = agentid self.name = name self.decision = decision self.revision = revision @@ -906,6 +921,7 @@ public struct BoardWidgetGrantParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" case name case decision case revision @@ -915,17 +931,20 @@ public struct BoardWidgetGrantParams: Codable, Sendable { public struct BoardWidgetAppViewParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public let name: String public let revision: Int public let instanceid: String public init( sessionkey: String, + agentid: String? = nil, name: String, revision: Int, instanceid: String) { self.sessionkey = sessionkey + self.agentid = agentid self.name = name self.revision = revision self.instanceid = instanceid @@ -933,6 +952,7 @@ public struct BoardWidgetAppViewParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" case name case revision case instanceid = "instanceId" @@ -959,21 +979,25 @@ public struct BoardWidgetAppViewResult: Codable, Sendable { public struct BoardEventParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public let widget: String public let payload: AnyCodable public init( sessionkey: String, + agentid: String? = nil, widget: String, payload: AnyCodable) { self.sessionkey = sessionkey + self.agentid = agentid self.widget = widget self.payload = payload } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" case widget case payload } @@ -4610,18 +4634,22 @@ public struct UiNavigateCommand: Codable, Sendable { public struct UiCommandParams: Codable, Sendable { public let command: UiCommand public let sessionkey: String? + public let agentid: String? public init( command: UiCommand, - sessionkey: String? = nil) + sessionkey: String? = nil, + agentid: String? = nil) { self.command = command self.sessionkey = sessionkey + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case command case sessionkey = "sessionKey" + case agentid = "agentId" } } @@ -6147,18 +6175,22 @@ public struct SessionRow: Codable, Sendable { public struct SessionsCompanionAskParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public let question: String public init( sessionkey: String, + agentid: String? = nil, question: String) { self.sessionkey = sessionkey + self.agentid = agentid self.question = question } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" case question } } @@ -6183,15 +6215,19 @@ public struct SessionsCompanionAskResult: Codable, Sendable { public struct SessionsCompanionResetParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public init( - sessionkey: String) + sessionkey: String, + agentid: String? = nil) { self.sessionkey = sessionkey + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" } } @@ -6211,15 +6247,19 @@ public struct SessionsCompanionResetResult: Codable, Sendable { public struct SessionsCompanionStateParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public init( - sessionkey: String) + sessionkey: String, + agentid: String? = nil) { self.sessionkey = sessionkey + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" } } @@ -7383,15 +7423,19 @@ public struct SessionDiscussionInfo: Codable, Sendable { public struct SessionDiscussionInfoParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public init( - sessionkey: String) + sessionkey: String, + agentid: String? = nil) { self.sessionkey = sessionkey + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" } } @@ -7419,15 +7463,19 @@ public struct SessionDiscussionInfoResult: Codable, Sendable { public struct SessionDiscussionOpenParams: Codable, Sendable { public let sessionkey: String + public let agentid: String? public init( - sessionkey: String) + sessionkey: String, + agentid: String? = nil) { self.sessionkey = sessionkey + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case agentid = "agentId" } } @@ -8478,15 +8526,19 @@ public struct SessionsMessagesUnsubscribeParams: Codable, Sendable { } public struct SessionsViewerPresenceSetParams: Codable, Sendable { + public let agentid: String? public let sessionkeys: [String] public init( + agentid: String? = nil, sessionkeys: [String]) { + self.agentid = agentid self.sessionkeys = sessionkeys } private enum CodingKeys: String, CodingKey { + case agentid = "agentId" case sessionkeys = "sessionKeys" } } @@ -8851,6 +8903,7 @@ public struct SessionsPatchManyResult: Codable, Sendable { public struct SessionsPluginPatchParams: Codable, Sendable { public let key: String + public let agentid: String? public let pluginid: String public let namespace: String public let value: AnyCodable? @@ -8858,12 +8911,14 @@ public struct SessionsPluginPatchParams: Codable, Sendable { public init( key: String, + agentid: String? = nil, pluginid: String, namespace: String, value: AnyCodable? = nil, unset: Bool? = nil) { self.key = key + self.agentid = agentid self.pluginid = pluginid self.namespace = namespace self.value = value @@ -8872,6 +8927,7 @@ public struct SessionsPluginPatchParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case key + case agentid = "agentId" case pluginid = "pluginId" case namespace case value @@ -13313,17 +13369,23 @@ public struct AgentsListParams: Codable, Sendable {} public struct AgentsListResult: Codable, Sendable { public let defaultid: String + public let ownership: AnyCodable? + public let selectionrequired: Bool? public let mainkey: String public let scope: AnyCodable public let agents: [AgentSummary] public init( defaultid: String, + ownership: AnyCodable? = nil, + selectionrequired: Bool? = nil, mainkey: String, scope: AnyCodable, agents: [AgentSummary]) { self.defaultid = defaultid + self.ownership = ownership + self.selectionrequired = selectionrequired self.mainkey = mainkey self.scope = scope self.agents = agents @@ -13331,6 +13393,8 @@ public struct AgentsListResult: Codable, Sendable { private enum CodingKeys: String, CodingKey { case defaultid = "defaultId" + case ownership + case selectionrequired = "selectionRequired" case mainkey = "mainKey" case scope case agents @@ -17241,7 +17305,19 @@ public struct QuestionListResult: Codable, Sendable { } } -public struct HooksStatusParams: Codable, Sendable {} +public struct HooksStatusParams: Codable, Sendable { + public let agentid: String? + + public init( + agentid: String? = nil) + { + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + } +} public struct PluginApprovalRequestParams: Codable, Sendable { public let pluginid: String? @@ -17739,17 +17815,20 @@ public struct PluginsSessionActionParams: Codable, Sendable { public let pluginid: String public let actionid: String public let sessionkey: String? + public let agentid: String? public let payload: AnyCodable? public init( pluginid: String, actionid: String, sessionkey: String? = nil, + agentid: String? = nil, payload: AnyCodable? = nil) { self.pluginid = pluginid self.actionid = actionid self.sessionkey = sessionkey + self.agentid = agentid self.payload = payload } @@ -17757,6 +17836,7 @@ public struct PluginsSessionActionParams: Codable, Sendable { case pluginid = "pluginId" case actionid = "actionId" case sessionkey = "sessionKey" + case agentid = "agentId" case payload } } diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 462832b1124c..d3c0828b33c0 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2301, + "core": 2306, "channel": 3582, "plugin": 3997 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 5d01a2786947..494cf9711b60 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -061d4bc0ba8f8aad8ee1593521d65edc3e947b6ff7e4149c9783ce6ef806d448 config-baseline.json -c12619ab274c0ce34d858a705a6ef542a4c0f71c9d58977b7949bb452b62f9eb config-baseline.core.json +09e85f06289696850e357f2e263ec4a84b81d17852df1d779c044703042611cb config-baseline.json +ce74623d1b19b178aee681a4d1e15eee17297f3a5d939ed1f0fba838abfabe6c config-baseline.core.json dd317647cf5ccf8d23774dcc7208d5ca749c08486137c6e5e6c8b7114592356f config-baseline.channel.json 4bcc2364924c80f38139f0508945b6d28b33b70dd2973f0685a8f221d672ac94 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline/account-core.json b/docs/.generated/plugin-sdk-api-baseline/account-core.json index 563a13f716e3..7cd49660c510 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-core.json @@ -1 +1 @@ -{"contentHash":"1dfd3e5fcb0bf796e5fbb2f900c1f24648ebc3c3f1e079edebc44713cdc73f95","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"} +{"contentHash":"0be1ecd06a745532dba9386e5b43315ee4c5081a0bd6a27a3c1029c916dae9b0","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/account-helpers.json b/docs/.generated/plugin-sdk-api-baseline/account-helpers.json index 67b6b5a71263..7de526bd86d4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-helpers.json @@ -1 +1 @@ -{"contentHash":"59289c6a9c0cd4d1f892ab44e33bf410eabb8fd2f413c101b0a4c258a161be93","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"} +{"contentHash":"f3112f65665d059d3d7ea7677095a981683205016a6ca7a5f77a256d298d3c0e","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/account-resolution.json b/docs/.generated/plugin-sdk-api-baseline/account-resolution.json index 991514030f74..b55bfc7af864 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-resolution.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-resolution.json @@ -1 +1 @@ -{"contentHash":"5592f32b9bfa75785413a8409ef4682d2f956ef797ff83fa4d65a260f06e5704","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"} +{"contentHash":"f06762f3a806b019ece755c7673a91d945e31d07c00dbd9a01b9e1cccc89fbc0","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 47e28e540f31..67c274762077 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"57cb6a98df58f04f4c31d37b35aa857e5329eb29560f272381be592e39946d5a","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"30cf4f65584fea104edf342dd3a9121d30de8c26d9cfe67825e7c8b0c777e21f","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index c76f918f806d..e394e6cfaaba 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"a3e367817d1aa078cbd028aec5b080e73f7b67a85f6b91e4cfdfe32a09e6b509","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"04a43ad5caff854c063d5151a119e7f6b566f326f56db5dfd8856e804f5fe2de","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json b/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json index d5ac5b8c010b..b3809539f850 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json @@ -1 +1 @@ -{"contentHash":"2bf740e161c58fd9e29f934effd7f28496049729b12a92872bd8b38d3071d569","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"} +{"contentHash":"86c968ddac58cefe2e0a9c67478903cfb85cb017ae7535db10cd2c231413b61a","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index f28c79c245c1..632e8ca5bf90 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"96ad6087a29b2cb11f793f954afd31446854d173396b0d7fded033fddddf4dd3","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"acdc878b3110b6eb7a56b4be45faa5ee938e83bba08da469338e74ff2272d7f4","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json index 3603db6a6522..2e133483fbc0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json @@ -1 +1 @@ -{"contentHash":"10b523b3750029ac9d1390c322d6fa5ee1e0c55b579fa1519cd5c35c38291279","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"} +{"contentHash":"3ea765e3c5492b33086589d4772f8847cd8441e9eac28117b47263b6c7536b47","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json b/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json index 391456952710..9ed771f32f20 100644 --- a/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json +++ b/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json @@ -1 +1 @@ -{"contentHash":"b9c883f336b7156d79f68cc65e2c937d69ab8da53cc7d78905e91c5e784475b5","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"} +{"contentHash":"bdb2693c65e8dae7e5deaf1507529fe2a91d7b341e33d2eb4f7279bc9c67eb2b","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json index 60f39c616066..776909f59aeb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json @@ -1 +1 @@ -{"contentHash":"1c90bce36b78d093dda5a4d3ff90aa511a0d69dd4d49d65142d268f3c82abb8b","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"} +{"contentHash":"7a3d22f2b1504590786324d6c7e1b2a2c389a7fed88c025f88f47c813259e6c7","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json index f4c14593c593..975bbd8aa124 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json @@ -1 +1 @@ -{"contentHash":"378f0c57b9e17e146f3182202b7edbcaec7bcb934df620f97e91d4ac47cfbe58","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"} +{"contentHash":"f12ce671c60f4473907187aff4af3d9e5fc73db23485762a89655671aa5a7fbf","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json index 4d71b22d303e..e21eb9fd08fb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json @@ -1 +1 @@ -{"contentHash":"5c9b0f82c64298e0ac423022c23c6d8ce6b31a9f23eaca6ae90bbf90df1ce73c","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"} +{"contentHash":"6faf1a3e2cf6b2934948e716fa9aea8742c20843a32fb314ec9222a438b3ae85","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json index 7f022da1c29f..f91813e9b716 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"5b01b050a74336ecf3e95ef7d036761d97c401fac7074a7dfb1166764b97fadf","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"} +{"contentHash":"8756a06d212ec1cfe9633549092623ce7459bcdace543e7532e936cc2223f23b","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json index 0aea30d3967d..32b70994fc03 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json @@ -1 +1 @@ -{"contentHash":"14ecb0ffdaae14230d2d0a18270c3d7566277059bf69a4c9589ec6010c7b1f7f","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"} +{"contentHash":"892c0c77c54cbf77a08cad7f0b2450d016389dcd08ae1827bc12a0a394be17dd","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json index 0e7d9e0ed2f1..98ad572bd9a9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json @@ -1 +1 @@ -{"contentHash":"c9b78fb10e5fc6e9f96d64177abede58966fd3a8db162df8a5349b70ebd430e7","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"} +{"contentHash":"4c1627db8894476e2abe57feca7028146182fdb20e26735cdc545eb8172dfab3","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json index ebc21b0f2e57..3ebe722dd362 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json @@ -1 +1 @@ -{"contentHash":"3d3baba1c37c4d27090fda14faa338533d4032c5f700f5df51a1c39be75999f0","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"} +{"contentHash":"acf34cc656faaaf61cea7bf57c5aa309571d4f06e2cfe61873f523b7d1382d9c","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json index 4f654eadb81e..23d11ccedc10 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json @@ -1 +1 @@ -{"contentHash":"08e39079741ad673a97766f787a6fed9bcfd0de77587204a5811abf0381539df","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"} +{"contentHash":"1c89dd2c42a71a8ca7979185c0d2bddbce7c34474f190fbd6e9bf57576513498","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json b/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json index 9dfdd2567312..9029497dd15a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json @@ -1 +1 @@ -{"contentHash":"d330fade8c60b208ec4ea5436d240542aef8b185c59fdfd16b25d959fc5c84b6","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"} +{"contentHash":"0da13b719253c656d1b8e87b6e9031eaf30071403b88df638db27dff49d5afcf","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-contract.json index 273e604cb2c6..a9040276c7dc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-contract.json @@ -1 +1 @@ -{"contentHash":"419727ccbae7734ca6ec2641e11a483ed3a672b1fd443080942ad1e6fe3827a6","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"} +{"contentHash":"b217974cb762cbcafccda7b997bc46fa4b023a8a8f5eecb49d10e11380c862fb","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index a523521b8e3c..5e81bc5936cc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"355f3a91dabce0393a540e1d9f476eba6926cff82153f41e98c5a01d4e011ea2","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"2984444f1ee0ebd2a6597d6a9fb4ce5286b39613b685bf307a775a0c85a5751f","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json b/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json index f8db35972733..1f89e1a0aa96 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json @@ -1 +1 @@ -{"contentHash":"39368bb1e4590774289e4cde181193f8f110a7da463f5776a4d3fecc214d0897","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"} +{"contentHash":"3fcd57cb76695fab51ea9c8d74fb83713548c3ac9cc16015cbe3832f345ba26e","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index ac28841df7cb..d0166da306e4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"695b06456e6e2bb7c3ca5891619683bf14e5cb5939e4a44bd907b2c798910cae","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"aed307240ced7847020be82f398b688fccbcafb8b0d5f1ca8ae6d6bae756f21a","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json b/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json index 02af8328177d..99af081205f8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json @@ -1 +1 @@ -{"contentHash":"35deda8c1727cdfc13b06ea28f11369b81c89dfe4a999d60b8b3f54658c5a073","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"} +{"contentHash":"dd6e8cd23f5d49ff1a0ee80e2a43345e68354536c125c3572bdef0f0251c04ec","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json index 08eaa361ad83..224b73b97315 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json @@ -1 +1 @@ -{"contentHash":"80245339a2fc974765c2ad5042d71f6765d5e2775d535c3f2b98a5d78536882d","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"} +{"contentHash":"94d09f8bdc746a5dc59ccea64a8de82828c895f71d70272101e426c3d27de24c","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 0279888b253e..db6c283ec355 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"cf487c3dd656a5f9d8c4a927bf0cb8ff27b1fb29c09c466c8685482c647df12d","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"13c0832e71dab56c9cd61c8b63e38be59ba73d6e668a292d350ca717f31de129","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json index c0734dd626f8..cd3869c1ff43 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json @@ -1 +1 @@ -{"contentHash":"a8de2c1ed05d17353b0ffa7a6e94c802fba1acdcfea71ff0d415a7f1944fe7ba","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"} +{"contentHash":"6b9a749d15afad2c7ed1c6f2ec0cc7e0cdd2d7c5f11824942fdecc1f14ec2fdb","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index ad991daf3eae..0a61cb67e2d4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"f9a6c7bd0ded857948b59ec8a8e2e8cc36204888e76cd5cfb50133439be59b1a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"d3315da878582d8333a99d3ca1ba5451d447d191894df8ee35540db7c0870b8e","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index c3bc84b19510..f1e8a90b8e63 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"dfebc8eae36256aedf44b8e4ea9a137f3b35946cc624d09246ec8d614223d752","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"5f61f7addeb83870fbf35d569624313f3e31cc84275ec6b5f4a65fde4c2e07ce","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json index d9f559e8fb26..9d30862644d9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json @@ -1 +1 @@ -{"contentHash":"0d9fc3f9ef06aaa9995c5a670d62b99cfce25f0b2c42152aa33214f9dec78b19","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} +{"contentHash":"19379d0ce6b726c7a754de86d8baf620706460fbb8dac335555fd5b06358e60e","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 17512d7ba4f2..0617f16acf67 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"524471a854edf593a8f9f3b80eaedf4492e9bc60cea40e314ad7efbbbbf46d32","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"3a7cc068c6fb57a76a30f2708a5ec67d3a5023f5c55445f525550fa47a725b73","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-policy.json b/docs/.generated/plugin-sdk-api-baseline/channel-policy.json index 6f71782e6ca3..afbe717b01c7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-policy.json @@ -1 +1 @@ -{"contentHash":"d6252e057b614160557b42923f45f42491b5872c835e5f1d372a031e338ecdd4","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"} +{"contentHash":"60b1641edc2afee722d9eb3adffebd09d38c56bd9ad54693cb0fd43e8385a434","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json index 71ee29870fd5..b46fab2dc463 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json @@ -1 +1 @@ -{"contentHash":"322b8abf1d45d609858a04a871dda329a51b99faab6bbded1c7ef8847bf4038b","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} +{"contentHash":"64b9d73bee51fd041dc4555fe9dbb06179140b035b4956571c2374f8ee10628d","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json index 685315a3a6e9..8f7ef8ba3234 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json @@ -1 +1 @@ -{"contentHash":"7e762365f58f15f23cc90ff932c3bfab59c91a2ba975bdc68f55c92c47ba14bd","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} +{"contentHash":"77d4ed20cdc8f2b18f2255b175768abdf7ff4649e539e47763269b908df7b810","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json b/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json index a33dbbfb66cb..526027582430 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json @@ -1 +1 @@ -{"contentHash":"186aacedd02ffd2011c38cdfceba621674dd65135be8e1b805f30cb20410fa96","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"} +{"contentHash":"0aaced44c2c2d4236dd996f9143aca80debfc491716e50e29db4a7c33f2b1b56","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json index 4919d067f119..6970dce2d492 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json @@ -1 +1 @@ -{"contentHash":"7a5e4516352775c3c3ed72faf36240357c6437dda7f9dd223088f5e5766cc3e2","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} +{"contentHash":"e8cba169976fcc238d43939019f8f18f7b82e2937ca1e75c136d32b923ddbc74","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json index 58a2575c376a..33feac6d5e13 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json @@ -1 +1 @@ -{"contentHash":"9eb8249b9cbec967cc0954897c79ff3ebbad2441fe2f386df39d63ee588427f2","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} +{"contentHash":"b226a1e13c943d0b973ab845f86840ddc03d773c8447ac7c8e5007e65b599581","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth.json b/docs/.generated/plugin-sdk-api-baseline/command-auth.json index d3d90be96cea..f9db2203df25 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth.json @@ -1 +1 @@ -{"contentHash":"81a7bb481ca6a66f6aff242e567e29a765f5cef396239231e93fadd2b223e61d","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} +{"contentHash":"db3b9ff52de1b3b650cdfde4d64019209326f5d6e2203259822a6df946201002","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-detection.json b/docs/.generated/plugin-sdk-api-baseline/command-detection.json index fed6396ba100..a556d555c0f5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-detection.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-detection.json @@ -1 +1 @@ -{"contentHash":"a350b99ea3eebfbe401f833f9e24348fb4341ed6693c384f344da9954821be69","entrypoint":"command-detection","importSpecifier":"openclaw/plugin-sdk/command-detection"} +{"contentHash":"bbfee2f16c8736bb7259bff1c178a40ed6ad9cb87771ae3ca5aa60e719900441","entrypoint":"command-detection","importSpecifier":"openclaw/plugin-sdk/command-detection"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-status.json b/docs/.generated/plugin-sdk-api-baseline/command-status.json index 66d8cf2d60e8..36c1016519f9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-status.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-status.json @@ -1 +1 @@ -{"contentHash":"1e8367e67d7a7a1fb88b3f7c8a96039623dbba8c9b16c69df085181a4fca32f7","entrypoint":"command-status","importSpecifier":"openclaw/plugin-sdk/command-status"} +{"contentHash":"41896e7a003f0e1633e559beb6fea48aa1c651a5f5abe126fe9bc67d89b1ccc6","entrypoint":"command-status","importSpecifier":"openclaw/plugin-sdk/command-status"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-contracts.json b/docs/.generated/plugin-sdk-api-baseline/config-contracts.json index d00412999dd3..fa631f9bef5f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-contracts.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-contracts.json @@ -1 +1 @@ -{"contentHash":"02217a87f4bf0e081b406641519df90da15acd8aa430a9049f0c1584aabd2e3a","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"} +{"contentHash":"16df9cfec8f814a76b34fc62a0f5fb28b477803dee3cb8ea7cbe2b6157231474","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json index 58dbd1435035..1b8169163236 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json @@ -1 +1 @@ -{"contentHash":"56a3c566d98d262c3ac07c830f2b33bd864e35930335d7b4e3e2ccc76607779e","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} +{"contentHash":"95d101bc4087ab0e95a571dd75010d70f89cbc81a9aa541d073d95abc0e9e11e","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index cc46bb7e5ed9..e2777566e36c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"180a151549ad301c08f9265c3f9fb7a17821c93dd5b0e4f6128f7512312dc909","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"ff0d61988c767ef519ed3ca8d65ec8c52fb9c95b9273bf76a5209ee0c6e92c44","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json b/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json index 39cba9f730c0..8082717209e7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json @@ -1 +1 @@ -{"contentHash":"3005e6516bf12583bd1107524180c272e7bcb01fdc5b8564b8e25af9363dcfcf","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"} +{"contentHash":"d7ef8d1efdfdf641565bde7ffd6b3e991aebb7c85a403d826b0b6b634993e01d","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 0bba4e2c152d..6b3403900ed3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"f0a95c43b07dffdfbd05ac63a1b1dfd4b4828fab8d3613ca51465a7bbcae8cdf","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"bb6c1b3ba80725df4f77d9fdcb7cfa23708a89dc283f179c417b2413f393edce","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json index 7057593fea1a..e2fc2491b2f4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json @@ -1 +1 @@ -{"contentHash":"fc77340b5990c42e5c9b18fd24f85ced694e2b9b66a85e236031bdf3d09d727a","entrypoint":"diagnostic-runtime","importSpecifier":"openclaw/plugin-sdk/diagnostic-runtime"} +{"contentHash":"e2bfc1a03cae36660484b68702dfaa71399f42b35fb31578e5fe23ede93ca0e4","entrypoint":"diagnostic-runtime","importSpecifier":"openclaw/plugin-sdk/diagnostic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json b/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json index e628de5aeb17..488d25ae1586 100644 --- a/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json @@ -1 +1 @@ -{"contentHash":"a3e92384acd12f3e97e3698e70fff6eb4fb32d69a48f80e2303056919215a9fd","entrypoint":"directory-runtime","importSpecifier":"openclaw/plugin-sdk/directory-runtime"} +{"contentHash":"70ad8e3167dea54086b8018bb44867a54ef32e36e8fb729555ce691d1f128886","entrypoint":"directory-runtime","importSpecifier":"openclaw/plugin-sdk/directory-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 3776e2eb546d..fcb61796b93d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"8c243343f62835f8570cf85a7c58523d76d5fed4f80704a179417d993a3ffc5d","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"e937d0c9dcd7e614f2606bb1fd11524b6cbd8b18aef4bf1abf053958ce5af0fb","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/extension-shared.json b/docs/.generated/plugin-sdk-api-baseline/extension-shared.json index 04612a05d45c..90b9501f6dea 100644 --- a/docs/.generated/plugin-sdk-api-baseline/extension-shared.json +++ b/docs/.generated/plugin-sdk-api-baseline/extension-shared.json @@ -1 +1 @@ -{"contentHash":"8d9d4df3025f92b910ba57099b02b3d7ed1e422b5d2fc603ad8d7a140ad57ca1","entrypoint":"extension-shared","importSpecifier":"openclaw/plugin-sdk/extension-shared"} +{"contentHash":"4159a662a4807e86ffbceb54f883513f63fc7014424342d431d9ada6c836a509","entrypoint":"extension-shared","importSpecifier":"openclaw/plugin-sdk/extension-shared"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 4ea9b3719b6b..14a051f5f45f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"9f395b8d8bc7f59316cc969ab88242928e383241ddb5e4e30e43419823bb2724","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"cccc089cc9b839725c8f8ecc828ade72fb797684b21548977041122a42ac4d2d","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/health.json b/docs/.generated/plugin-sdk-api-baseline/health.json index 9796c76b8397..334c1552c296 100644 --- a/docs/.generated/plugin-sdk-api-baseline/health.json +++ b/docs/.generated/plugin-sdk-api-baseline/health.json @@ -1 +1 @@ -{"contentHash":"c788ea0db94a409b16d3342c927340c4922103a9280612faef514bf0cb21f73f","entrypoint":"health","importSpecifier":"openclaw/plugin-sdk/health"} +{"contentHash":"428abfa6612a253e48f8435415f9798927595212bbe79be7f796643ee0b63630","entrypoint":"health","importSpecifier":"openclaw/plugin-sdk/health"} diff --git a/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json b/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json index a393c5f5c399..48835e6af5a9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json @@ -1 +1 @@ -{"contentHash":"c27a0ddd9f0a12bbb8377e8fcf8422ed2e066b8d248bc250c34e06518f1891a3","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"} +{"contentHash":"86057f22f1ee829026ed3157180b7223827ab04b4a862d83d6581d95acff5d18","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index e6f08fd922d1..e2b1867c9320 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"a78162d9df85c5e96ef95b985a036af55110afe5768174c761d6fa0a485aa621","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"61b0578351fa18e36fd88426e541101f368749fde15163cea9b3ae0861efe9fe","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json index 783581c41fe1..67c43079313d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json @@ -1 +1 @@ -{"contentHash":"6f58c40f7e733eb8dae7d8afd7e49fd79d57d9ea4b808617990bf812dc6e1b71","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} +{"contentHash":"69721e0b21629a434fe3dae57483799be48d7a3a63b23934bc43a74cee50c994","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/logging-core.json b/docs/.generated/plugin-sdk-api-baseline/logging-core.json index 0c8517a847d5..ce8fec803d65 100644 --- a/docs/.generated/plugin-sdk-api-baseline/logging-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/logging-core.json @@ -1 +1 @@ -{"contentHash":"2efeb44c37461f4b366018d4e5e11b0c2e107336202388d8f17688b71954fa39","entrypoint":"logging-core","importSpecifier":"openclaw/plugin-sdk/logging-core"} +{"contentHash":"3013eee275ccabf9be934493fc671bf9e300afdc874f5199ef0921a741b6e21d","entrypoint":"logging-core","importSpecifier":"openclaw/plugin-sdk/logging-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json b/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json index 80ccef5060ff..bc53f59a6e57 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json @@ -1 +1 @@ -{"contentHash":"2bf19492700a2ef6f81c21af1a7515f05f08d4be5de6d30ab2f21f3b1c65af66","entrypoint":"media-local-roots","importSpecifier":"openclaw/plugin-sdk/media-local-roots"} +{"contentHash":"37625e1ab388431c5fd13dd276932d507428e2a0be9508512bce06fcf7e0c10c","entrypoint":"media-local-roots","importSpecifier":"openclaw/plugin-sdk/media-local-roots"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json index accfc46764cf..24d1af6076cb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json @@ -1 +1 @@ -{"contentHash":"f7969ed39adf8c33720aa5e781185872a73768b74f77326b826c8c70a90b4b7e","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} +{"contentHash":"e1304a282d60688698128f377d9cbbfbd1faf768bd9f5c4ce06209732df129ff","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json index 1eb55f1f35bd..d8aa854312a7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json @@ -1 +1 @@ -{"contentHash":"6966ddf947e1f318ab46b3158f074c601c0e6906eaf4e40c2e25277af1212ed0","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"} +{"contentHash":"392a5461e6d37d74c38e153a217baceb5b500f588987188d3a9a0dc40585f16b","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-understanding.json b/docs/.generated/plugin-sdk-api-baseline/media-understanding.json index adac7b0376b4..26e60cf28cbc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-understanding.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-understanding.json @@ -1 +1 @@ -{"contentHash":"002950c5f044a412a18c5de204bd63e9af41a18eb43923d58c6fda3fff595470","entrypoint":"media-understanding","importSpecifier":"openclaw/plugin-sdk/media-understanding"} +{"contentHash":"27c5985c0e552173f6bbd0910468ec578d70bf15663f02473adf8c3edb43d434","entrypoint":"media-understanding","importSpecifier":"openclaw/plugin-sdk/media-understanding"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 01360aa32342..603c90df5964 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"46ce609eeb7ac8b23cdfb90469301cb988d4c18fdecfe5aa9aa2e444a52cb345","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"52462bacc819d8a62678b5eec09a5aa8f2f544bd5d67869c8a5afe465f1b2fe2","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json b/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json index 5e610da2e145..91079a0b53ab 100644 --- a/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json +++ b/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json @@ -1 +1 @@ -{"contentHash":"1349b8b6998001a68c3e4469fbfcdd14da38d7399d62fcce30a502b95457b295","entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation"} +{"contentHash":"9ca0b19d2e1603633f7ba3e394492dc368185c8594bc71146f3d2b6efc976f7a","entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json b/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json index 51becb4b770c..7920ec898642 100644 --- a/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json @@ -1 +1 @@ -{"contentHash":"63211bfcb6d04ba1fdc9fe03c2964d92f9d3260468c96847d0c0bfffd7eeab8d","entrypoint":"memory-host-core","importSpecifier":"openclaw/plugin-sdk/memory-host-core"} +{"contentHash":"b501d0a86575ddbbc80f0bff86595876f9311cb589810ef366260f3afe548e99","entrypoint":"memory-host-core","importSpecifier":"openclaw/plugin-sdk/memory-host-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index f6b00167233c..6ff01f6bbdeb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"cd909c52a27a558de6f2647c44544b81f80038bb02577fe9b60ea612a609948e","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"49858013fb88e4d2fa389b1530519ff710eeb9829619e84c57c2762743901dd3","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json index 87be07491d26..d6bf2491c2de 100644 --- a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json @@ -1 +1 @@ -{"contentHash":"a9d389647438b2fbe962b37cae518c416154a4e872324732d152d08246e21cae","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} +{"contentHash":"fa28516c53cb3c131c365f6ceef286a4cd3025a610545f32bd091b9d40c6d932","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json index 1439c42fa52c..b759adfb3aab 100644 --- a/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json @@ -1 +1 @@ -{"contentHash":"99f1e86b4454c90181c548085cf35c0a737ebb56996a996d56de232fb377fd40","entrypoint":"native-command-config-runtime","importSpecifier":"openclaw/plugin-sdk/native-command-config-runtime"} +{"contentHash":"423e039dfb779cf3a7015f555488228134c206a8dc67b9ded1abb1eed2ae8f3c","entrypoint":"native-command-config-runtime","importSpecifier":"openclaw/plugin-sdk/native-command-config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json b/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json index a91bd7b28dd5..28ffe85a00d7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json +++ b/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json @@ -1 +1 @@ -{"contentHash":"a07f438f4f414de99b5d6fad1eea5123132b18a313da460d8c2b8fa75fa9400d","entrypoint":"native-command-registry","importSpecifier":"openclaw/plugin-sdk/native-command-registry"} +{"contentHash":"b8fd0f64294bc480bbafe0587f4e09ead29094ec4c8e36c2830ddca332147bc8","entrypoint":"native-command-registry","importSpecifier":"openclaw/plugin-sdk/native-command-registry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json index 1cbc4e1402bd..260de341da0e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json @@ -1 +1 @@ -{"contentHash":"5bb2b4560b715a99bb635510b031b4350b9af16ca4c9247c6d28b54753ef1b7a","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} +{"contentHash":"60cb9df1dfabddde17570f7782f7a8e593f466677fc33dc9b50b6709de8559fc","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json index fed10437541a..a0780eb9e534 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json @@ -1 +1 @@ -{"contentHash":"d7215ce585999368c60af1cd9f36333f9b17e2f53bc0adb5c8a4f23d3d7d6515","entrypoint":"plugin-config-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-config-runtime"} +{"contentHash":"9dd3f937dbd4a8926bd23cfbbae7307078ab3b7de8b4e51f407beb13167a3705","entrypoint":"plugin-config-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 824c124c059c..ed2a2f70b807 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"fd5a6f903c1d177ccb090d0507bdec51d9494f094c8219d56334553044fff7cf","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"6ec95a6a1a3b8efc15d32d7c929b983225a9953efd1c28e415f7d4b8e888a6af","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index cd28c10daf0e..3bdc7f20feeb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"bbd1fa1f3afa8c8699a69a17d33d4ec9e6db46c5b94636f19e0cc5fe31b0b125","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"752b3e01c032e68d70e2d24972366c7d0655d1007af00ea1b71d3e13083b91c0","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json index 3834a8520cb8..9a41ca9d508e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json @@ -1 +1 @@ -{"contentHash":"ae9c54c47a1937328f56b23f03590d458248f58c597645ec5e0f65b19464e75a","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} +{"contentHash":"bc7f2cd48ff01ba675c49a067cee0d29b11b1c753e202ca45a86b4a26d9b1405","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 7afbc163161e..3cd6a8b9764a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"9bab151043bafb665d20beb0f3e2685bbb9ac8fd7a19316cc695dafa7f977a5b","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"c5c0d35df118a2f51de7f9b2abc021a4d9ebfbc2fe7802d5ac23263a3990c195","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json index 21307705ab97..ac659d5aa204 100644 --- a/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"61d5219638be2337e6a3d0aa2cf7b50b8962d4ecf87895eaca10c211c384b742","entrypoint":"question-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/question-gateway-runtime"} +{"contentHash":"5cb9922ca26d7e97db8df3f8d0f8e005b2bfa0b1e450a433959422ac62364386","entrypoint":"question-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/question-gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json b/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json index d9f5bd9c22c3..b4a40d5147c7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json @@ -1 +1 @@ -{"contentHash":"c5fb8c5d88e490361472e9e4d4b8616dd72bac43a11d9b90ab91303085ae8b05","entrypoint":"reply-chunking","importSpecifier":"openclaw/plugin-sdk/reply-chunking"} +{"contentHash":"72ea5a473a7658c1d1874ccde34f49cec5169f43b84840ec1800629e4976519b","entrypoint":"reply-chunking","importSpecifier":"openclaw/plugin-sdk/reply-chunking"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json index 05eb0d0d046a..cff195a386f6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json @@ -1 +1 @@ -{"contentHash":"c12fa6e4997bd855a67a6a291016c6a8d3154994c4a27b220cddcd0ff3b8cf79","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} +{"contentHash":"3f9c3de29ec5aca2f1e2d09e6032fce20ed9f350f30750dc85f4b3a8c41c21c4","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-payload.json b/docs/.generated/plugin-sdk-api-baseline/reply-payload.json index 422e3171c073..1f3d3cecc466 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-payload.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-payload.json @@ -1 +1 @@ -{"contentHash":"be9fa539e6075c7cdd0d5dc66a6ed8e90baba8c7a98a50790ca9073bf87441c2","entrypoint":"reply-payload","importSpecifier":"openclaw/plugin-sdk/reply-payload"} +{"contentHash":"122760a4c26f02eba70d6e2894012e0b959f64476bada7c3106ff95d14197377","entrypoint":"reply-payload","importSpecifier":"openclaw/plugin-sdk/reply-payload"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json index 8f7d70d2af71..857eb9d398eb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json @@ -1 +1 @@ -{"contentHash":"317332317ab54babfd869195f95db402a3fc5ac34c9adfdd7991b969e46fd6ab","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} +{"contentHash":"94d2227081920079037cf5e3b4b6cf69c42d6241438220f9aa3eed724b063236","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/routing.json b/docs/.generated/plugin-sdk-api-baseline/routing.json index 05bf7b7f9ec5..469c2baab597 100644 --- a/docs/.generated/plugin-sdk-api-baseline/routing.json +++ b/docs/.generated/plugin-sdk-api-baseline/routing.json @@ -1 +1 @@ -{"contentHash":"521b8ed7abf623eb96009f4adc726d7ef27c59bc5cd2cdb3b9a4fd5f62a13c03","entrypoint":"routing","importSpecifier":"openclaw/plugin-sdk/routing"} +{"contentHash":"7b09be7ed0529be69388936914a0a10a9f9e25c0228f2fc403361f79b5a48bd2","entrypoint":"routing","importSpecifier":"openclaw/plugin-sdk/routing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json b/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json index 87d64b14ab6b..80ae4f5912bf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json @@ -1 +1 @@ -{"contentHash":"2598fcb9b1bd93bad5667809c44ff26b6996b8b32ea50ef3769189b426adb161","entrypoint":"runtime-config-snapshot","importSpecifier":"openclaw/plugin-sdk/runtime-config-snapshot"} +{"contentHash":"ebb3fbc05499fef75ae6712e1f6070b0652d1b4ec175b37574166d56c18fced6","entrypoint":"runtime-config-snapshot","importSpecifier":"openclaw/plugin-sdk/runtime-config-snapshot"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json index d7a05cfce8fd..2eda66f06cba 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json @@ -1 +1 @@ -{"contentHash":"b965caa74f02ea0fa0b4b5a57bfacd0699df991452b5aa95f21237858930508f","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} +{"contentHash":"75361fbead55fda2647aa2a3d003a15084f354f11a477e28cc29a5ec6fc6cfcd","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime.json b/docs/.generated/plugin-sdk-api-baseline/runtime.json index 540844a9eb0d..c69b1578f381 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime.json @@ -1 +1 @@ -{"contentHash":"bfb35e2e7547303fc9dbe0a8564720fef1f2341d8967a42caf84f837a8e30418","entrypoint":"runtime","importSpecifier":"openclaw/plugin-sdk/runtime"} +{"contentHash":"0faf7866f6d778a2529498e410f67f28284a5b3bc7d373a5671c862c1eca981b","entrypoint":"runtime","importSpecifier":"openclaw/plugin-sdk/runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json index 41d61dc8b9fa..492aeeac7028 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json @@ -1 +1 @@ -{"contentHash":"3686c85e955c6ae8a13b1a42772eb720ea7ac55fa4e12e02b179ec16160fbdd1","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} +{"contentHash":"44ea9d383fa2e8a2eb370fec6d3701175a889fb71b8fd37ed7a029316de7d292","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json index a6fcf0c61273..599a954cea86 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json @@ -1 +1 @@ -{"contentHash":"0d0d184165355b27731442674cc6b7b641c30cf90889a833effb3e8733f5a7d4","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} +{"contentHash":"4304e954ea6252e5f1423fcede86b668c5896b13e4b715ed40fa9270f76a97f8","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json index ea07ae323dbd..dee0800d6224 100644 --- a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json @@ -1 +1 @@ -{"contentHash":"e793cfb3c3eb076bb73ed290ddc08c49d2c8ef35fcf46bdbcac66f24c6413bf2","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} +{"contentHash":"9755df5724d34f2082bd6c7386e38697084bc3fd78570a5d6114c1844941220a","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json index 6135612f8dec..9dc13897dbff 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json @@ -1 +1 @@ -{"contentHash":"1c76ac2e6a40a56ae0dbf5f6e2b5d911883f8e11cc7c0f435f6cce0c185a60c2","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} +{"contentHash":"ba2d77ba16690bca9a09e83ae1a5fc2569017338ef9a43fb810015d6d2d7205c","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-discussion.json b/docs/.generated/plugin-sdk-api-baseline/session-discussion.json index de89627577b6..2c4e30df53d2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-discussion.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-discussion.json @@ -1 +1 @@ -{"contentHash":"fc930f917790f90cde187615a3f637b3c0f0f1e2e3d272ca825845da8af721fd","entrypoint":"session-discussion","importSpecifier":"openclaw/plugin-sdk/session-discussion"} +{"contentHash":"2bd5596f0ed8b7fabdb8fb502f2965ae4307795e20de4dd08807ccad560d62b7","entrypoint":"session-discussion","importSpecifier":"openclaw/plugin-sdk/session-discussion"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json index 6bfd1cab6435..50fce3c9a278 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json @@ -1 +1 @@ -{"contentHash":"96150841a6e550228fcf0310e0aaf56ebd75b53ad9c5eb2d0557bbb8a5b67290","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} +{"contentHash":"312b644d9fb54857501442486f9fa6dadf538c34a6277cd24ef457c72c0d2944","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json index f3a720adf3bf..b4b5f88994a0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json @@ -1 +1 @@ -{"contentHash":"19bbe24a8d2e70f8c9a34686275d86df4ac4829dbe37c83df530fe5685d3ae9b","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} +{"contentHash":"d210ce49387a08b462ee3c7438b0742b37fda875cf3df5583562f7ad479f939d","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup.json b/docs/.generated/plugin-sdk-api-baseline/setup.json index 6527a8e31027..812b5ece0455 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup.json @@ -1 +1 @@ -{"contentHash":"fe9cc43264a17feb6d108ddeb13a6b0ebc731575fca56460c10291fe116102fb","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} +{"contentHash":"137a2c6fda49c369b8c05c87195acd163e4fd26a6099bcd5c661cb6d7afaa722","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json index bed821d5f982..be4e27f0ee3b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json @@ -1 +1 @@ -{"contentHash":"77ec91309a3ed556bcc6e88ef4711e74e46e4ad849182467ef95fef21ac4bccd","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} +{"contentHash":"1f3096342401055bb64eab79e81229145bcb24b2b4ad8fe95028cd4b80c15317","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/speech-settings.json b/docs/.generated/plugin-sdk-api-baseline/speech-settings.json index 1111367824f9..c1088483dbbf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/speech-settings.json +++ b/docs/.generated/plugin-sdk-api-baseline/speech-settings.json @@ -1 +1 @@ -{"contentHash":"1519ec4e44700eb1624376267f4387f4513b194cf2312aae5ca5f550618be82a","entrypoint":"speech-settings","importSpecifier":"openclaw/plugin-sdk/speech-settings"} +{"contentHash":"2ec1e8d96db4b63259e60760a3c493ba1d77fe7447c29518c9da121505e0410e","entrypoint":"speech-settings","importSpecifier":"openclaw/plugin-sdk/speech-settings"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json index c8d1474a022b..1e79cc2d301e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json @@ -1 +1 @@ -{"contentHash":"41c511b778c45e8b90dc0a0bcc15a3d81c0668e1ea56355f32b783179b6dc289","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} +{"contentHash":"be52a2c8fd6d99820ccaf3b2fad9f30f6d9d4c0c953acfaa6194755d6807adf2","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json index 975b5e0c28c2..ba89ec7ddea4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json @@ -1 +1 @@ -{"contentHash":"06c31b1f542206ad85ea7908f8c4a7ae89040035abf799a79e19a955efdaab99","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} +{"contentHash":"c8c80db62e71ea346c18cfe8b3d9a9bd5d25ecd499ce64fbd0466ee1fb720709","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/status-helpers.json b/docs/.generated/plugin-sdk-api-baseline/status-helpers.json index f7f9ac0519dd..5b611d0b24e9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/status-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/status-helpers.json @@ -1 +1 @@ -{"contentHash":"2b94e68f63d8fedb89aeba2d30b0b0c261b5a571c706f979c5bc5909b39690a6","entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers"} +{"contentHash":"bc984adbd749c5a40a8a7df7fcdabac0086f5cb015b39e315536204371296842","entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/telegram-account.json b/docs/.generated/plugin-sdk-api-baseline/telegram-account.json index b22cb3c54188..4196bcfb3731 100644 --- a/docs/.generated/plugin-sdk-api-baseline/telegram-account.json +++ b/docs/.generated/plugin-sdk-api-baseline/telegram-account.json @@ -1 +1 @@ -{"contentHash":"8639fb724906978cdeec3fbafe93ea85245a0016a69e8c8824524ce6bc5e2925","entrypoint":"telegram-account","importSpecifier":"openclaw/plugin-sdk/telegram-account"} +{"contentHash":"3b21f813063d83fdd1e2dfdc5b791f307c8fafe463b88463beb5fb21de9b4f41","entrypoint":"telegram-account","importSpecifier":"openclaw/plugin-sdk/telegram-account"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index da2b38e190cc..d3b40774b4bd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"6e2ee162d479ea014a08c64d6b85276e60d099137baf348f5d193bc8ed1cf162","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"b873b17e9949d28e6dbf0ca28ef17d11c250990def15db725ded5fcad737bef5","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index f83a0576dc7e..3cb2b8c9a811 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"267759cc867869904787ed33483da8c59eba7f290427500836d98247ed135d91","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"b21b50ff43ddc09e336a8127f71d58c99a3e5e691970bdf15d0ebeb2bede0f68","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json b/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json index 7e494ab77201..2a96656cbd2e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json @@ -1 +1 @@ -{"contentHash":"42e050a4c6e39320a8c67b7c4664e9eb817c0b0c040c065f18230f5311c2055b","entrypoint":"webhook-request-guards","importSpecifier":"openclaw/plugin-sdk/webhook-request-guards"} +{"contentHash":"3d188951bcfb3a9a97c2d1bbd4783ebbec8e773cb480f97be7ad018fff41a568","entrypoint":"webhook-request-guards","importSpecifier":"openclaw/plugin-sdk/webhook-request-guards"} diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index e91691504c9b..6857de41dcba 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -551,13 +551,13 @@ Query-string tokens are rejected. - Enqueue a system event for the main session: + Enqueue a system event for the selected agent's main session: ```bash curl -X POST http://127.0.0.1:18789/hooks/wake \ -H 'Authorization: Bearer SECRET' \ -H 'Content-Type: application/json' \ - -d '{"text":"New email received","mode":"now"}' + -d '{"text":"New email received","mode":"now","agentId":"main"}' ``` @@ -566,6 +566,9 @@ Query-string tokens are rejected. `now` or `next-heartbeat`. + + Target agent. Required when the configured agent fleet has no implicit or retained legacy owner. + @@ -629,15 +632,16 @@ Wire Gmail inbox triggers to OpenClaw via Google PubSub. ### Configure a restricted Gmail reader (recommended) -Before connecting Gmail transport, merge a dedicated reader and hook policy into your existing config. Preserve the real settings on your existing default agent; the `main` entry below only shows the required roster shape. +Before connecting Gmail transport, merge a dedicated reader and hook policy into your existing config. Preserve the real settings on your existing agent; the `main` entry below only shows the required roster shape. + +Adding `mail_reader` creates an explicit fleet. Keep existing bindings and add one channel-wide binding per enabled channel that `main` still owns; there is no cross-channel wildcard. ```json5 { agents: { + ownership: "explicit", entries: { - main: { - default: true, - }, + main: {}, mail_reader: { workspace: "~/.openclaw/workspace-mail-reader", model: "openai/gpt-5.6-sol", @@ -654,6 +658,7 @@ Before connecting Gmail transport, merge a dedicated reader and hook policy into }, }, }, + bindings: [{ agentId: "main", match: { channel: "", accountId: "*" } }], hooks: { defaultSessionKey: "hook:gmail:ingress", allowRequestSessionKey: true, @@ -676,9 +681,12 @@ Before connecting Gmail transport, merge a dedicated reader and hook policy into } ``` +Before restart, run `openclaw agents list --bindings`; replace every placeholder and verify each channel owner. + Why this shape is safer: -- `agentId: "mail_reader"` keeps Gmail off the default agent. +- The explicit `main` binding preserves existing channel ownership instead of leaving non-Gmail traffic ownerless. Use a specific `accountId` instead of `"*"` when only one account belongs to `main`. +- `agentId: "mail_reader"` keeps Gmail off the `main` agent. - `allowedAgentIds` prevents this hook endpoint from selecting another agent. If the Gateway serves other hook workflows, include only their intended agent ids too. - `scope: "session"` gives each Gmail message its own sandbox; `workspaceAccess: "none"` keeps the host agent workspace out of that sandbox. - `allow: ["session_status"]` is an absolute per-agent clamp, so global `tools.alsoAllow` additions cannot leak into the reader. The minimal profile and explicit deny list make the intended boundary auditable. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 728da695aeae..1d6dc0b43fff 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -825,7 +825,6 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior agents: { entries: { codex: { - default: true, runtime: { type: "acp", acp: { diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index a7d5b5b2ee3b..ee1d19fdb9e8 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -11,6 +11,8 @@ Agent-scoped configuration keys under `agents.*`, `multiAgent.*`, `session.*`, `messages.*`, and `talk.*`. For channels, tools, gateway runtime, and other top-level keys, see [Configuration reference](/gateway/configuration-reference). +OpenClaw stamps `agents.ownership: "explicit"` when creating a multi-agent fleet. Such fleets have no default: channels and ambient services need bindings or surface-specific `agentId` targets. Doctor materializes legacy owners during upgrade; sole-agent configs need no marker. + ## Agent defaults ### `agents.defaults.workspace` @@ -23,9 +25,7 @@ Default: `OPENCLAW_WORKSPACE_DIR` when set, otherwise `~/.openclaw/workspace` (o } ``` -An explicit `agents.defaults.workspace` value takes precedence over -`OPENCLAW_WORKSPACE_DIR`. Use the environment variable to point default agents -at a mounted workspace when you do not want to write that path into config. +An explicit `agents.defaults.workspace` value takes precedence over `OPENCLAW_WORKSPACE_DIR`. A sole agent uses this path directly. In a multi-agent fleet, agents without their own `workspace` use an agent-id subdirectory so no implicit owner claims the shared root. ### `agents.defaults.repoRoot` @@ -45,9 +45,10 @@ Optional default skill allowlist for agents that do not set ```json5 { agents: { + ownership: "explicit", defaults: { skills: ["github", "weather"] }, entries: { - writer: { default: true }, // inherits github, weather + writer: {}, // inherits github, weather docs: { skills: ["docs-search"] }, // replaces defaults "locked-down": { skills: [] }, // no skills }, @@ -143,7 +144,6 @@ injection behavior from the shared defaults. Omitted fields inherit from }, entries: { "strict-worker": { - default: true, contextInjection: "always", bootstrapMaxChars: 50000, bootstrapTotalMaxChars: 300000, @@ -245,7 +245,6 @@ from `agents.defaults.contextLimits`. }, entries: { "tiny-local": { - default: true, contextLimits: { memoryGetMaxChars: 6000, }, @@ -274,7 +273,7 @@ Per-agent override for the skills prompt budget. { agents: { entries: { - "tiny-local": { default: true, skillsLimits: { maxSkillsPromptChars: 6000 } }, + "tiny-local": { skillsLimits: { maxSkillsPromptChars: 6000 } }, }, }, } @@ -557,7 +556,7 @@ Periodic heartbeat runs. - `lightContext`: when true, heartbeat runs use lightweight bootstrap context and skip workspace bootstrap files. Monitor scratch is injected by the heartbeat runner either way. - `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens. - Busy deferral is automatic: scheduled heartbeats wait for main/cron activity, same-agent active runs, and target-session work. Immediate and manual wakes bypass only the broad same-agent active-run precheck. -- The default agent's Heartbeats system-prompt section is included automatically while its cadence is enabled. Ack suppression uses a fixed 300-character remainder budget, reasoning payloads remain internal, and tool error warnings remain enabled. +- An enrolled agent's Heartbeats system-prompt section is included automatically while that agent's cadence is enabled. Ack suppression uses a fixed 300-character remainder budget, reasoning payloads remain internal, and tool error warnings remain enabled. - Per-agent: set `agents.entries.*.heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. @@ -575,7 +574,7 @@ Selects the agent whose model and credentials own ambient OpenClaw system-agent } ``` -Delegated consults with a requesting agent keep that requester as their owner. When `agentId` is absent, OpenClaw preserves configured-default routing. +Delegated consults with a requesting agent keep that requester as their owner. When `agentId` is absent, a sole configured agent resolves implicitly; ambient consults in a multi-agent fleet fail with an actionable error. Upgrade-only ownership lives at `agents.defaults.authInheritance.agentId` for inherited credentials and `agents.defaults.sessionStore.agentId` for unscoped rows in a fixed `session.store`. ### `agents.defaults.compaction` @@ -968,7 +967,6 @@ for provider examples and precedence. agents: { entries: { main: { - default: true, name: "Main Agent", workspace: "~/.openclaw/workspace", agentDir: "~/.openclaw/agents/main/agent", @@ -1014,8 +1012,8 @@ for provider examples and precedence. } ``` -- Each key in `agents.entries` is the stable agent id. -- `default`: exactly one agent entry must set `default: true`. +- The `agents.entries` object key is the stable agent id. +- `default` is retired. Exactly one configured agent resolves implicitly; multi-agent operations require a binding, surface `agentId` target, scoped session/store owner, or explicit `--agent`/request field. - `model`: string form sets a strict per-agent primary with no model fallback; object form `{ primary }` is also strict unless you add `fallbacks`. Use `{ primary, fallbacks: [...] }` to opt that agent into fallback, or `{ primary, fallbacks: [] }` to make strict behavior explicit. Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`. - `utilityModel`: optional per-agent override for short internal tasks such as generated session and thread titles. Falls back to `agents.defaults.utilityModel`, then the effective session provider's declared small-model default. Dashboard titles retry once with the effective regular session model. An empty string skips the alternate utility route for this agent without disabling dashboard title generation. - `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog. @@ -1046,8 +1044,10 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul ```json5 { agents: { + ownership: "explicit", + defaults: { heartbeat: { agentId: "home" }, systemAgent: { agentId: "home" } }, entries: { - home: { default: true, workspace: "~/.openclaw/workspace-home" }, + home: { workspace: "~/.openclaw/workspace-home" }, work: { workspace: "~/.openclaw/workspace-work" }, }, }, @@ -1055,6 +1055,7 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul { agentId: "home", match: { channel: "whatsapp", accountId: "personal" } }, { agentId: "work", match: { channel: "whatsapp", accountId: "biz" } }, ], + talk: { agentId: "home" }, } ``` @@ -1074,7 +1075,7 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul 3. `match.teamId` 4. `match.accountId` (exact, no peer/guild/team) 5. `match.accountId: "*"` (channel-wide) -6. Default agent +6. Sole-agent fallback (only when exactly one agent is configured; explicit multi-agent fleets without a matching binding fail closed) Within each tier, the first matching `bindings` entry wins. @@ -1089,7 +1090,6 @@ For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`ma agents: { entries: { personal: { - default: true, workspace: "~/.openclaw/workspace-personal", sandbox: { mode: "off" }, }, @@ -1107,7 +1107,6 @@ For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`ma agents: { entries: { family: { - default: true, workspace: "~/.openclaw/workspace-family", sandbox: { mode: "all", scope: "agent", workspaceAccess: "ro" }, tools: { @@ -1136,7 +1135,6 @@ For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`ma agents: { entries: { public: { - default: true, workspace: "~/.openclaw/workspace-public", sandbox: { mode: "all", scope: "agent", workspaceAccess: "none" }, tools: { diff --git a/docs/help/faq.md b/docs/help/faq.md index 2e53c3998fe7..68d0251ed14c 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -150,9 +150,9 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - ```json5 { agents: { + ownership: "explicit", entries: { coder: { - default: true, model: "xiaomi/mimo-v2.5-pro", thinkingDefault: "high", params: { temperature: 0.1 }, diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index b203abbaad46..dd36e118e862 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -549,7 +549,7 @@ route is eligible to select Codex implicitly: ### Mixed provider deployment -Keep Claude as the default agent and add a named Codex agent: +Configure a Claude `main` agent and add a named Codex agent: ```json5 { @@ -561,12 +561,12 @@ Keep Claude as the default agent and add a named Codex agent: }, }, agents: { + ownership: "explicit", defaults: { model: "anthropic/claude-opus-4-6", }, entries: { main: { - default: true, model: "anthropic/claude-opus-4-6", }, codex: { @@ -578,10 +578,7 @@ Keep Claude as the default agent and add a named Codex agent: } ``` -The `main` agent uses its normal provider path. The `codex` agent uses Codex -app-server when its effective OpenAI route remains compatible; add explicit -model-scoped `agentRuntime.id: "codex"` when that should be a fail-closed -requirement. +This explicit fleet has no default agent; target `main` or `codex` with a session, `--agent`, or binding. The `main` agent uses its normal provider path. The `codex` agent uses Codex app-server when its effective OpenAI route remains compatible; add explicit model-scoped `agentRuntime.id: "codex"` when that should be a fail-closed requirement. ### Fail-closed Codex deployment diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index a124bb6e9805..487638ae02e5 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -377,9 +377,9 @@ Use `agents.entries.*.runtime` to define ACP defaults once per agent: ```json5 { agents: { + ownership: "explicit", entries: { codex: { - default: true, runtime: { type: "acp", acp: { diff --git a/packages/gateway-protocol/CHANGELOG.md b/packages/gateway-protocol/CHANGELOG.md index 83c68d163e33..fb5b6cd63328 100644 --- a/packages/gateway-protocol/CHANGELOG.md +++ b/packages/gateway-protocol/CHANGELOG.md @@ -8,6 +8,7 @@ version and the additive schema surface. Dates are authoring dates (2026). ## Unreleased - Add bounded `sessions.patchMany` session mutation orchestration. +- Preserve required legacy agent-default fields while adding honest `ownership` and `selectionRequired` state to agent lists and initial snapshots. - Add semantic `agent` / `system` roster kinds negotiated through the `agent-kind` client capability. - Rename structured-question item `id` to `questionId` and flatten keyed answer arrays. - Slim worker and session-catalog payloads to the active wire contract. diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index db3c75a724ec..31e26286640f 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -152,6 +152,24 @@ describe("AgentsListResultSchema", () => { expectAccepted(AgentsListResultSchema, result); }); + it("keeps the legacy default required while accepting additive ownership metadata", () => { + const legacy = { + defaultId: "ops", + mainKey: "main", + scope: "per-sender", + agents: [{ id: "ops" }, { id: "research" }], + }; + const current = { + ...legacy, + ownership: "explicit", + selectionRequired: true, + }; + + expect(Value.Check(AgentsListResultSchema, legacy)).toBe(true); + expect(Value.Check(AgentsListResultSchema, current)).toBe(true); + expect(Value.Check(AgentsListResultSchema, { ...current, defaultId: undefined })).toBe(false); + }); + it("accepts system and legacy omitted kinds but rejects unknown kinds", () => { const result = { defaultId: "main", @@ -183,10 +201,13 @@ describe("ModelsListParamsSchema", () => { { agentId: "writer", view: "all", + }, + { + agentId: "research", includeProviderCapabilities: true, }, ); - expectRejected(ModelsListParamsSchema, { view: "provider-route" }); + expectRejected(ModelsListParamsSchema, { view: "provider-route" }, { agentId: "" }); }); }); diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index bf381945cb5c..108eb27cf082 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -94,9 +94,16 @@ export const AgentSummarySchema = closedObject({ /** Empty request payload for listing configured agents. */ export const AgentsListParamsSchema = closedObject({}); -/** Agent list result including the default agent and session scoping mode. */ +export const AgentOwnershipSchema = Type.Union([ + Type.Literal("sole"), + Type.Literal("legacy"), + Type.Literal("explicit"), +]); + export const AgentsListResultSchema = closedObject({ defaultId: NonEmptyString, + ownership: Type.Optional(AgentOwnershipSchema), + selectionRequired: Type.Optional(Type.Boolean()), mainKey: NonEmptyString, scope: Type.Union([Type.Literal("per-sender"), Type.Literal("global")]), agents: Type.Array(AgentSummarySchema), @@ -221,7 +228,7 @@ export const AgentsFilesSetResultSchema = closedObject({ /** Model catalog request with optional visibility scope. */ export const ModelsListParamsSchema = closedObject({ - agentId: Type.Optional(Type.String()), + agentId: Type.Optional(NonEmptyString), includeProviderCapabilities: Type.Optional(Type.Boolean()), view: Type.Optional( Type.Union([ diff --git a/packages/gateway-protocol/src/schema/board.ts b/packages/gateway-protocol/src/schema/board.ts index 431e833cfee5..8dcadd266f51 100644 --- a/packages/gateway-protocol/src/schema/board.ts +++ b/packages/gateway-protocol/src/schema/board.ts @@ -157,11 +157,15 @@ export const BoardOpSchema = Type.Union([ ]); export type BoardOp = Static; -export const BoardGetParamsSchema = closedObject({ sessionKey: NonEmptyString }); +export const BoardGetParamsSchema = closedObject({ + sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), +}); export type BoardGetParams = Static; export const BoardUpdateParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), ops: Type.Array(BoardOpSchema), }); export type BoardUpdateParams = Static; @@ -218,6 +222,7 @@ export type BoardWidgetPutContent = Static; export const BoardWidgetPutParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), name: BoardWidgetNameSchema, title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })), content: BoardWidgetPutContentSchema, @@ -246,6 +251,7 @@ export type BoardWidgetPutResult = Static; export const BoardWidgetGrantParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), name: BoardWidgetNameSchema, decision: Type.Union([Type.Literal("granted"), Type.Literal("rejected")]), revision: Type.Integer({ minimum: 1 }), @@ -255,6 +261,7 @@ export type BoardWidgetGrantParams = Static export const BoardWidgetAppViewParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), name: BoardWidgetNameSchema, revision: Type.Integer({ minimum: 1 }), instanceId: NonEmptyString, @@ -271,6 +278,7 @@ export const BoardViewTicketSchema = Type.String({ minLength: 1, maxLength: 2048 export const BoardLegacyEventParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), widget: BoardWidgetNameSchema, payload: Type.Unknown(), }); diff --git a/packages/gateway-protocol/src/schema/hooks.test.ts b/packages/gateway-protocol/src/schema/hooks.test.ts index 606327d78525..c45c895714fb 100644 --- a/packages/gateway-protocol/src/schema/hooks.test.ts +++ b/packages/gateway-protocol/src/schema/hooks.test.ts @@ -4,6 +4,8 @@ import { validateHooksStatusParams } from "../index.js"; describe("hook protocol schemas", () => { it("accepts an empty status request and rejects extra fields", () => { expect(validateHooksStatusParams({})).toBe(true); + expect(validateHooksStatusParams({ agentId: "research" })).toBe(true); + expect(validateHooksStatusParams({ agentId: "" })).toBe(false); expect(validateHooksStatusParams({ reload: true })).toBe(false); }); }); diff --git a/packages/gateway-protocol/src/schema/hooks.ts b/packages/gateway-protocol/src/schema/hooks.ts index 0eb853e63c5e..c5cee4f6ce0c 100644 --- a/packages/gateway-protocol/src/schema/hooks.ts +++ b/packages/gateway-protocol/src/schema/hooks.ts @@ -1,7 +1,11 @@ import type { Static } from "typebox"; +import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; -/** Empty request payload for the live Gateway hook status report. */ -export const HooksStatusParamsSchema = closedObject({}); +/** Request payload for one agent's live Gateway hook status report. */ +export const HooksStatusParamsSchema = closedObject({ + agentId: Type.Optional(NonEmptyString), +}); export type HooksStatusParams = Static; diff --git a/packages/gateway-protocol/src/schema/plugins.ts b/packages/gateway-protocol/src/schema/plugins.ts index 0069d572879b..529cebf05b18 100644 --- a/packages/gateway-protocol/src/schema/plugins.ts +++ b/packages/gateway-protocol/src/schema/plugins.ts @@ -47,6 +47,7 @@ export const PluginsSessionActionParamsSchema = closedObject({ pluginId: NonEmptyString, actionId: NonEmptyString, sessionKey: Type.Optional(NonEmptyString), + agentId: Type.Optional(NonEmptyString), payload: Type.Optional(PluginJsonValueSchema), }); diff --git a/packages/gateway-protocol/src/schema/session-discussion.ts b/packages/gateway-protocol/src/schema/session-discussion.ts index 04f993a2c5e0..d6cd21283a71 100644 --- a/packages/gateway-protocol/src/schema/session-discussion.ts +++ b/packages/gateway-protocol/src/schema/session-discussion.ts @@ -18,10 +18,12 @@ export const SessionDiscussionInfoSchema = closedObject({ export const SessionDiscussionInfoParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), }); export const SessionDiscussionOpenParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), }); export const SessionDiscussionInfoResultSchema = SessionDiscussionInfoSchema; diff --git a/packages/gateway-protocol/src/schema/sessions-viewer-presence.ts b/packages/gateway-protocol/src/schema/sessions-viewer-presence.ts index 8419ba164cdb..36d5ecf5254d 100644 --- a/packages/gateway-protocol/src/schema/sessions-viewer-presence.ts +++ b/packages/gateway-protocol/src/schema/sessions-viewer-presence.ts @@ -1,13 +1,14 @@ import type { Static } from "typebox"; import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; -import { ChatSendSessionKeyString } from "./primitives.js"; +import { ChatSendSessionKeyString, NonEmptyString } from "./primitives.js"; /** Maximum sessions one connection may declare as concurrently visible. */ export const SESSION_VIEWER_PRESENCE_MAX_KEYS = 32; /** Replaces the sessions this connection is currently rendering. */ export const SessionsViewerPresenceSetParamsSchema = closedObject({ + agentId: Type.Optional(NonEmptyString), sessionKeys: Type.Array(ChatSendSessionKeyString, { maxItems: SESSION_VIEWER_PRESENCE_MAX_KEYS, }), diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index cd69c84831df..e802d7a6c9ad 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -93,6 +93,7 @@ export const SessionCompanionExchangeSchema = closedObject({ /** Asks the read-only companion about one session and its workspace. */ export const SessionsCompanionAskParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), question: Type.String({ minLength: 1, maxLength: 400 }), }); @@ -105,6 +106,7 @@ export const SessionsCompanionAskResultSchema = closedObject({ /** Selects the in-memory companion thread for one session. */ export const SessionsCompanionStateParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), }); /** Current bounded exchanges for one session companion thread. */ @@ -115,6 +117,7 @@ export const SessionsCompanionStateResultSchema = closedObject({ /** Selects the in-memory companion thread to clear. */ export const SessionsCompanionResetParamsSchema = closedObject({ sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), }); /** Acknowledges clearing one companion thread. */ @@ -515,6 +518,7 @@ export const SessionsAbortParamsSchema = closedObject({ /** Updates or clears one plugin namespace value on a session record. */ export const SessionsPluginPatchParamsSchema = closedObject({ key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), pluginId: NonEmptyString, namespace: NonEmptyString, value: Type.Optional(PluginJsonValueSchema), diff --git a/packages/gateway-protocol/src/schema/snapshot.ts b/packages/gateway-protocol/src/schema/snapshot.ts index af248e91439b..e8366e33a272 100644 --- a/packages/gateway-protocol/src/schema/snapshot.ts +++ b/packages/gateway-protocol/src/schema/snapshot.ts @@ -1,6 +1,7 @@ // Gateway Protocol schema module defines protocol validation shapes. import type { Static } from "typebox"; import { Type } from "typebox"; +import { AgentOwnershipSchema } from "./agents-models-skills.js"; import { closedObject } from "./closed-object.js"; import { UpdateAvailableSchema, UpdateScheduleStateSchema } from "./config.js"; import { NonEmptyString } from "./primitives.js"; @@ -189,6 +190,8 @@ const HealthSnapshotSchema = closedObject({ /** Default session routing keys included in initial gateway snapshots. */ const SessionDefaultsSchema = closedObject({ defaultAgentId: NonEmptyString, + ownership: Type.Optional(AgentOwnershipSchema), + selectionRequired: Type.Optional(Type.Boolean()), mainKey: NonEmptyString, mainSessionKey: NonEmptyString, scope: Type.Optional(NonEmptyString), diff --git a/packages/gateway-protocol/src/schema/ui-command.ts b/packages/gateway-protocol/src/schema/ui-command.ts index 84aadd90d727..99024f18b75e 100644 --- a/packages/gateway-protocol/src/schema/ui-command.ts +++ b/packages/gateway-protocol/src/schema/ui-command.ts @@ -45,6 +45,7 @@ export type UiCommand = Static; export const UiCommandParamsSchema = closedObject({ command: UiCommandSchema, sessionKey: Type.Optional(NonEmptyString), + agentId: Type.Optional(NonEmptyString), }); export type UiCommandParams = Static; diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 464b59ed05bd..fb8ff2cb71d8 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -119,7 +119,10 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ core: 3, "plugin-entry": 1, routing: 1, - health: 0, + // +1 each: the shipped default-agent resolver remains available through + // compatibility barrels while callers migrate to explicit/sole selection. + health: 1, + "agent-scope-runtime": 1, // +1: shipped channel setup state-migration declaration during its migration window. "channel-entry-contract": 1, "approval-gateway-runtime": 1, @@ -140,7 +143,8 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ "agent-media-payload": 3, // +2: deprecated media projection type and builder. "reply-payload": 2, - "agent-runtime": 2, + "agent-runtime": 3, + "memory-host-core": 1, // +4: session-write lease no-op compatibility stubs through the 2026.10 train. // +4: legacy AgentHarness, attempt, embedded-run, and side-question contracts remain // deprecated while external harnesses migrate to required-capability V2 contracts. @@ -348,7 +352,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +4: session-write lease no-op compatibility stubs through the 2026.10 train. // +7: restore still-existing deprecated inbound-dispatch compatibility re-exports. // +6: source-compatible harness contracts retained during the V2 migration window. - 1142, + // +4: shipped default-agent resolver projections retained during explicit-owner migration. + 1146, env, ), publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/acp/runtime/session-meta-keys.ts b/src/acp/runtime/session-meta-keys.ts new file mode 100644 index 000000000000..ad96ff157a94 --- /dev/null +++ b/src/acp/runtime/session-meta-keys.ts @@ -0,0 +1,205 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { Selectable } from "kysely"; +import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../../infra/kysely-sync.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js"; +import { runOpenClawStateWriteTransaction } from "../../state/openclaw-state-db.js"; + +export type AcpSessionsTable = OpenClawStateKyselyDatabase["acp_sessions"]; +type AcpSessionMetaDatabase = Pick; +export type AcpSessionRow = Selectable; +export type AcpSessionEntryBinding = Pick & + Partial>; + +export function getAcpSessionKysely(db: DatabaseSync) { + return getNodeSqliteKysely(db); +} + +export function selectAcpSessionRow( + db: DatabaseSync, + sessionKey: string, +): AcpSessionRow | undefined { + return executeSqliteQueryTakeFirstSync( + db, + getAcpSessionKysely(db) + .selectFrom("acp_sessions") + .selectAll() + .where("session_key", "=", sessionKey), + ); +} + +const ACP_DATABASE_KEY_PREFIX = "@acp:v1:"; +const ACP_LEGACY_AGENT_SCOPED_DB_KEY_PREFIX = "@agent:"; + +export function buildAcpDatabaseSessionKey(storeSessionKey: string, agentId?: string): string { + const normalizedKey = storeSessionKey.trim(); + const identity = [agentId ? normalizeAgentId(agentId) : null, normalizedKey]; + return `${ACP_DATABASE_KEY_PREFIX}${Buffer.from(JSON.stringify(identity), "utf8").toString("base64url")}`; +} + +function parseAcpDatabaseSessionKey(sessionKey: string): { + agentId?: string; + storeSessionKey: string; +} { + if (sessionKey.startsWith(ACP_DATABASE_KEY_PREFIX)) { + try { + const decoded = JSON.parse( + Buffer.from(sessionKey.slice(ACP_DATABASE_KEY_PREFIX.length), "base64url").toString("utf8"), + ) as unknown; + if ( + Array.isArray(decoded) && + decoded.length === 2 && + (decoded[0] === null || typeof decoded[0] === "string") && + typeof decoded[1] === "string" + ) { + return { + ...(decoded[0] ? { agentId: normalizeAgentId(decoded[0]) } : {}), + storeSessionKey: decoded[1], + }; + } + } catch { + // A legacy raw key may happen to use the reserved prefix. Treat it as raw. + } + return { storeSessionKey: sessionKey }; + } + if (!sessionKey.startsWith(ACP_LEGACY_AGENT_SCOPED_DB_KEY_PREFIX)) { + return { storeSessionKey: sessionKey }; + } + const remainder = sessionKey.slice(ACP_LEGACY_AGENT_SCOPED_DB_KEY_PREFIX.length); + const separator = remainder.indexOf(":"); + return separator > 0 + ? { + agentId: normalizeAgentId(remainder.slice(0, separator)), + storeSessionKey: remainder.slice(separator + 1), + } + : { storeSessionKey: sessionKey }; +} + +export function parseAcpDatabaseSessionKeyCandidates(sessionKey: string): Array<{ + agentId?: string; + storeSessionKey: string; +}> { + const parsed = parseAcpDatabaseSessionKey(sessionKey); + if (parsed.storeSessionKey === sessionKey && parsed.agentId === undefined) { + return [parsed]; + } + return [parsed, { storeSessionKey: sessionKey }]; +} + +function resolveAcpLegacyUnscopedOwner( + cfg: OpenClawConfig | undefined, + storeSessionKey: string, +): string | undefined { + if (!cfg) { + return undefined; + } + const persistedOwner = resolvePersistedSessionStoreOwnerForKey(cfg, storeSessionKey); + return persistedOwner.kind === "configured" + ? persistedOwner.agentId + : persistedOwner.kind === "none" + ? tryResolveLegacyCompatibilityAgentId(cfg) + : undefined; +} + +export function legacyAcpDatabaseSessionKeys( + storeSessionKey: string, + agentId?: string, + cfg?: OpenClawConfig, +): string[] { + const normalizedKey = storeSessionKey.trim(); + const keys: string[] = []; + if (agentId && !parseAgentSessionKey(normalizedKey)) { + keys.push( + `${ACP_LEGACY_AGENT_SCOPED_DB_KEY_PREFIX}${normalizeAgentId(agentId)}:${normalizedKey}`, + ); + } + const compatibilityOwner = resolveAcpLegacyUnscopedOwner(cfg, normalizedKey); + if ( + parseAgentSessionKey(normalizedKey) || + !agentId || + compatibilityOwner === normalizeAgentId(agentId) + ) { + keys.push(normalizedKey); + } + return [...new Set(keys)]; +} + +export function acpSessionRowMatchesEntry( + row: AcpSessionRow, + entry: AcpSessionEntryBinding | undefined, +): boolean { + return ( + row.session_id == null || + row.session_id === entry?.lifecycleRevision || + (row.session_id === entry?.sessionId && + (entry?.sessionStartedAt === undefined || row.updated_at >= entry.sessionStartedAt)) + ); +} + +export function selectAcpSessionRowForStoreEntry( + db: DatabaseSync, + storeSessionKey: string, + agentId?: string, + cfg?: OpenClawConfig, + entry?: AcpSessionEntryBinding, +): AcpSessionRow | undefined { + const databaseKey = buildAcpDatabaseSessionKey(storeSessionKey, agentId); + for (const key of [databaseKey, ...legacyAcpDatabaseSessionKeys(storeSessionKey, agentId, cfg)]) { + const row = selectAcpSessionRow(db, key); + if (row && (!entry || acpSessionRowMatchesEntry(row, entry))) { + return row; + } + } + return undefined; +} + +export function resolveReadableAcpSessionRow(params: { + row: AcpSessionRow | undefined; + entry: AcpSessionEntryBinding | undefined; + env?: NodeJS.ProcessEnv; + databasePath?: string; +}): AcpSessionRow | undefined { + const { row, entry } = params; + if (!row || !acpSessionRowMatchesEntry(row, entry)) { + return undefined; + } + const legacySessionId = entry?.sessionId; + const lifecycleRevision = entry?.lifecycleRevision; + if ( + !legacySessionId || + !lifecycleRevision || + row.session_id !== legacySessionId || + row.session_id === lifecycleRevision + ) { + return row; + } + return runOpenClawStateWriteTransaction( + (database) => { + const current = selectAcpSessionRow(database.db, row.session_key); + if (!current || current.session_id === lifecycleRevision || current.session_id == null) { + return current; + } + if (current.session_id !== legacySessionId) { + return undefined; + } + executeSqliteQuerySync( + database.db, + getAcpSessionKysely(database.db) + .updateTable("acp_sessions") + .set({ session_id: lifecycleRevision }) + .where("session_key", "=", row.session_key) + .where("session_id", "=", legacySessionId), + ); + return { ...current, session_id: lifecycleRevision }; + }, + { env: params.env, path: params.databasePath }, + ); +} diff --git a/src/acp/runtime/session-meta-legacy-cleanup.ts b/src/acp/runtime/session-meta-legacy-cleanup.ts new file mode 100644 index 000000000000..abf1c6f4c245 --- /dev/null +++ b/src/acp/runtime/session-meta-legacy-cleanup.ts @@ -0,0 +1,26 @@ +import { patchSessionEntryWithKey } from "../../config/sessions/session-accessor.js"; + +export async function clearLegacyEmbeddedAcpMetadata(params: { + storePath: string; + sessionKeys: Iterable; +}): Promise { + const sessionKeys = new Set( + Array.from(params.sessionKeys, (sessionKey) => sessionKey?.trim()).filter( + (sessionKey): sessionKey is string => Boolean(sessionKey), + ), + ); + for (const sessionKey of sessionKeys) { + await patchSessionEntryWithKey( + { storePath: params.storePath, sessionKey }, + (entry) => { + if (!entry.acp) { + return null; + } + const next = { ...entry }; + delete next.acp; + return next; + }, + { replaceEntry: true, skipMaintenance: true }, + ); + } +} diff --git a/src/acp/runtime/session-meta-store.test.ts b/src/acp/runtime/session-meta-store.test.ts new file mode 100644 index 000000000000..ff2ee8844060 --- /dev/null +++ b/src/acp/runtime/session-meta-store.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +const mocks = vi.hoisted(() => ({ + listSessionEntryKeysReadOnly: vi.fn((): string[] => []), + loadExactSessionEntryReadOnly: vi.fn(), +})); + +vi.mock("../../config/sessions/session-accessor.js", () => ({ + listSessionEntryKeysReadOnly: mocks.listSessionEntryKeysReadOnly, + loadExactSessionEntryReadOnly: mocks.loadExactSessionEntryReadOnly, +})); + +vi.mock("../../config/sessions/paths.js", () => ({ + resolveSessionStorePathCore: (_store: string | undefined, params: { agentId?: string }) => + `/stores/${params.agentId ?? "main"}.json`, +})); + +const { readSessionEntryFromStore, resolveSessionStorePathForAcp } = + await import("./session-meta-store.js"); + +function explicitFleet(): OpenClawConfig { + return { + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }; +} + +describe("ACP session metadata store ownership", () => { + beforeEach(() => { + mocks.listSessionEntryKeysReadOnly.mockClear(); + mocks.loadExactSessionEntryReadOnly.mockReset(); + }); + + it("returns a typed selection error for an ownerless bare key", () => { + expect(() => + readSessionEntryFromStore({ + cfg: explicitFleet(), + sessionKey: "global", + }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + expect(mocks.loadExactSessionEntryReadOnly).not.toHaveBeenCalled(); + }); + + it("reads a persisted fixed-store owner's store after restart", () => { + const cfg = { + ...explicitFleet(), + session: { store: "/stores/shared.sqlite" }, + agents: { + ...explicitFleet().agents, + defaults: { sessionStore: { agentId: "ops" } }, + }, + } satisfies OpenClawConfig; + mocks.loadExactSessionEntryReadOnly.mockReturnValue({ + entry: { sessionId: "ops-session" }, + }); + + const result = readSessionEntryFromStore({ cfg, sessionKey: "global" }); + + expect(result).toMatchObject({ + agentId: "ops", + storePath: "/stores/ops.json", + entry: { sessionId: "ops-session" }, + }); + expect(mocks.loadExactSessionEntryReadOnly).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "ops", storePath: "/stores/ops.json" }), + ); + }); + + it("returns a typed selection error when the persisted fixed-store owner is retired", () => { + const cfg = { + ...explicitFleet(), + session: { store: "/stores/shared.sqlite" }, + agents: { + ...explicitFleet().agents, + defaults: { sessionStore: { agentId: "retired" } }, + }, + } satisfies OpenClawConfig; + + expect(() => readSessionEntryFromStore({ cfg, sessionKey: "global" })).toThrowError( + expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" }), + ); + expect(() => + readSessionEntryFromStore({ cfg, agentId: "research", sessionKey: "global" }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + expect(mocks.loadExactSessionEntryReadOnly).not.toHaveBeenCalled(); + }); + + it("rejects a supplied agent that conflicts with a bare fixed-store owner", () => { + const cfg = { + ...explicitFleet(), + session: { store: "/stores/shared.sqlite" }, + agents: { + ...explicitFleet().agents, + defaults: { sessionStore: { agentId: "ops" } }, + }, + } satisfies OpenClawConfig; + + expect(() => + readSessionEntryFromStore({ cfg, agentId: "research", sessionKey: "global" }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + expect(mocks.loadExactSessionEntryReadOnly).not.toHaveBeenCalled(); + }); + + it("rejects a supplied agent that conflicts with an agent-qualified key", () => { + expect(() => + resolveSessionStorePathForAcp({ + cfg: explicitFleet(), + agentId: "ops", + sessionKey: "agent:research:work", + }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + }); +}); diff --git a/src/acp/runtime/session-meta-store.ts b/src/acp/runtime/session-meta-store.ts index 9af525328281..650516970e6e 100644 --- a/src/acp/runtime/session-meta-store.ts +++ b/src/acp/runtime/session-meta-store.ts @@ -1,15 +1,17 @@ /** Store binding for ACP session metadata: resolves which session-store row owns a key. */ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; +import { AgentSelectionRequiredError, listAgentIds } from "../../agents/agent-scope-config.js"; import { getRuntimeConfig } from "../../config/config.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; import { resolveSessionStorePathCore } from "../../config/sessions/paths.js"; import { listSessionEntryKeysReadOnly, loadExactSessionEntryReadOnly, } from "../../config/sessions/session-accessor.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; /** * Resolve one session's store key and entry with targeted single-row probes. @@ -61,38 +63,86 @@ export function resolveStoreEntryForSessionKey(params: { /** Resolves the session store path that owns an ACP session key. */ export function resolveSessionStorePathForAcp(params: { sessionKey: string; + agentId?: string; cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; -}): { cfg: OpenClawConfig; agentId?: string; storePath: string } { +}): { cfg: OpenClawConfig; agentId?: string; storePath?: string } { const cfg = params.cfg ?? getRuntimeConfig(); const parsed = parseAgentSessionKey(params.sessionKey); - const agentId = parsed?.agentId ?? resolveDefaultAgentId(cfg); + const requestedAgentId = params.agentId?.trim() ? normalizeAgentId(params.agentId) : undefined; + const parsedAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : undefined; + if (requestedAgentId && parsedAgentId && requestedAgentId !== parsedAgentId) { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: `ACP session key "${params.sessionKey}"`, + hint: `Agent "${requestedAgentId}" does not own agent-scoped session key "${params.sessionKey}".`, + }); + } + const persistedStoreOwner = resolvePersistedSessionStoreOwnerForKey(cfg, params.sessionKey); + const agentId = requestedAgentId ?? parsedAgentId; + if ( + requestedAgentId && + persistedStoreOwner.kind === "configured" && + requestedAgentId !== persistedStoreOwner.agentId + ) { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: `ACP session key "${params.sessionKey}"`, + hint: `The shared fixed-store row belongs to agent "${persistedStoreOwner.agentId}", not agent "${requestedAgentId}".`, + }); + } + if (persistedStoreOwner.kind === "retired") { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: `ACP session key "${params.sessionKey}"`, + hint: `The shared fixed-store row belongs to retired agent "${persistedStoreOwner.agentId}".`, + }); + } + const resolvedAgentId = + agentId ?? + (persistedStoreOwner.kind === "configured" ? persistedStoreOwner.agentId : undefined) ?? + tryResolveLegacyCompatibilityAgentId(cfg); + if (!resolvedAgentId) { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: `ACP session key "${params.sessionKey}"`, + hint: "Pass an explicit agent owner for this ACP session.", + }); + } return { cfg, - agentId, - storePath: resolveSessionStorePathCore(cfg.session?.store, { agentId, env: params.env }), + agentId: resolvedAgentId, + storePath: resolveSessionStorePathCore(cfg.session?.store, { + agentId: resolvedAgentId, + env: params.env, + }), }; } /** Reads one session's store binding, falling back to a lowercased key on store errors. */ export function readSessionEntryFromStore(params: { sessionKey: string; + agentId?: string; cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; clone?: boolean; }): { cfg: OpenClawConfig; agentId?: string; - storePath: string; + storePath?: string; storeSessionKey: string; entry?: SessionEntry; storeReadFailed?: boolean; } { const { cfg, agentId, storePath } = resolveSessionStorePathForAcp({ sessionKey: params.sessionKey, + agentId: params.agentId, cfg: params.cfg, env: params.env, }); + if (!storePath) { + return { + cfg, + agentId, + storeSessionKey: normalizeLowercaseStringOrEmpty(params.sessionKey), + }; + } try { const { storeSessionKey, entry } = resolveStoreEntryForSessionKey({ ...(agentId ? { agentId } : {}), diff --git a/src/acp/runtime/session-meta.test.ts b/src/acp/runtime/session-meta.test.ts index a5e194cb3a44..421142188017 100644 --- a/src/acp/runtime/session-meta.test.ts +++ b/src/acp/runtime/session-meta.test.ts @@ -1,7 +1,7 @@ /** Tests ACP session metadata persistence, joins, and migration helpers. */ import fs from "node:fs"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import type { SessionEntry } from "../../config/sessions/types.js"; @@ -11,6 +11,8 @@ import { withTestDir } from "../../test-helpers/temp-dir.js"; import { listAcpSessionEntries, readAcpSessionEntry, + readAcpSessionMeta, + readAcpSessionMetaBatch, readAcpSessionMetaForEntry, repairAcpSessionMetaKeyForMigration, upsertAcpSessionMeta, @@ -51,6 +53,274 @@ describe("ACP session metadata SQLite store", () => { closeOpenClawStateDatabaseForTest(); }); + it("persists bare global metadata under a configured fixed-store owner", async () => { + await withTestDir({ prefix: "openclaw-acp-global-owner-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const cfg = { + session: { scope: "global", store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + const databasePath = path.join(dir, "state", "openclaw.sqlite"); + await replaceSessionEntry( + { + agentId: "ops", + storePath, + sessionKey: "global", + }, + { sessionId: "ops-global", updatedAt: 100, sessionStartedAt: 100 }, + ); + const mutate = () => ({ + backend: "acpx", + agent: "codex", + runtimeSessionName: "global", + mode: "persistent" as const, + state: "idle" as const, + lastActivityAt: 123, + }); + + const persisted = await upsertAcpSessionMeta({ + cfg, + databasePath, + sessionKey: "global", + mutate, + }); + + expect(persisted?.acp?.runtimeSessionName).toBe("global"); + expect( + readAcpSessionMeta({ + cfg, + databasePath, + sessionKey: "global", + })?.runtimeSessionName, + ).toBe("global"); + const conflictingMutate = vi.fn(mutate); + await expect( + upsertAcpSessionMeta({ + cfg, + databasePath, + sessionKey: "global", + agentId: "research", + mutate: conflictingMutate, + }), + ).rejects.toMatchObject({ code: "AGENT_SELECTION_REQUIRED" }); + expect(conflictingMutate).not.toHaveBeenCalled(); + const ownerlessCfg = { + ...cfg, + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + } satisfies OpenClawConfig; + const ownerlessMutate = vi.fn(mutate); + await expect( + upsertAcpSessionMeta({ + cfg: ownerlessCfg, + databasePath, + sessionKey: "ownerless-global", + mutate: ownerlessMutate, + }), + ).rejects.toMatchObject({ code: "AGENT_SELECTION_REQUIRED" }); + expect(ownerlessMutate).not.toHaveBeenCalled(); + }); + }); + + it("keeps identical bare keys isolated by explicit agent owner", async () => { + await withTestDir({ prefix: "openclaw-acp-pair-owner-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const databasePath = path.join(dir, "state", "openclaw.sqlite"); + const cfg = { + session: { store: storePath }, + agents: { ownership: "explicit", entries: { research: {}, ops: {} } }, + } satisfies OpenClawConfig; + for (const agentId of ["research", "ops"]) { + await replaceSessionEntry( + { agentId, storePath, sessionKey: "global" }, + { sessionId: `${agentId}-global`, updatedAt: 100 }, + ); + await upsertAcpSessionMeta({ + cfg, + databasePath, + sessionKey: "global", + agentId, + mutate: () => ({ + backend: "acpx", + agent: "codex", + runtimeSessionName: agentId, + mode: "persistent", + state: "idle", + lastActivityAt: 123, + }), + }); + } + + expect( + readAcpSessionMeta({ cfg, databasePath, sessionKey: "global", agentId: "research" }) + ?.runtimeSessionName, + ).toBe("research"); + expect( + readAcpSessionMeta({ cfg, databasePath, sessionKey: "global", agentId: "ops" }) + ?.runtimeSessionName, + ).toBe("ops"); + + await upsertAcpSessionMeta({ + cfg, + databasePath, + sessionKey: "global", + agentId: "research", + mutate: () => null, + }); + expect( + readAcpSessionMeta({ cfg, databasePath, sessionKey: "global", agentId: "ops" }) + ?.runtimeSessionName, + ).toBe("ops"); + }); + }); + + it("batch-loads and rekeys legacy bare metadata for the stable store owner", async () => { + await withTestDir({ prefix: "openclaw-acp-batch-owner-" }, async (dir) => { + const databasePath = path.join(dir, "state", "openclaw.sqlite"); + const cfg = { + session: { store: path.join(dir, "sessions.json") }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + const entry: SessionEntry = { + sessionId: "ops-global", + lifecycleRevision: "ops-revision", + updatedAt: 100, + }; + writeAcpSessionMetaForMigration({ + databasePath, + sessionKey: "global", + lifecycleRevision: "ops-revision", + meta: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "legacy-global", + mode: "persistent", + state: "idle", + lastActivityAt: 123, + }, + }); + + const batch = readAcpSessionMetaBatch({ + cfg, + databasePath, + entries: [{ sessionKey: "global", agentId: "ops", entry }], + }); + + expect(batch.get(entry)?.runtimeSessionName).toBe("legacy-global"); + expect( + readAcpSessionMetaForEntry({ + cfg, + databasePath, + sessionKey: "global", + agentId: "ops", + entry, + })?.runtimeSessionName, + ).toBe("legacy-global"); + expect( + readAcpSessionMetaForEntry({ databasePath, sessionKey: "global", entry }), + ).toBeUndefined(); + }); + }); + + it("deletes the legacy row selected by fallback when metadata is cleared", async () => { + await withTestDir({ prefix: "openclaw-acp-clear-legacy-owner-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const databasePath = path.join(dir, "state", "openclaw.sqlite"); + const cfg = { + session: { scope: "global", store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + const entry: SessionEntry = { + sessionId: "ops-global", + lifecycleRevision: "ops-revision", + updatedAt: 100, + }; + await replaceSessionEntry({ agentId: "ops", storePath, sessionKey: "global" }, entry); + writeAcpSessionMetaForMigration({ + databasePath, + sessionKey: "global", + lifecycleRevision: "ops-revision", + meta: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "legacy-global", + mode: "persistent", + state: "idle", + lastActivityAt: 123, + }, + }); + + await upsertAcpSessionMeta({ + cfg, + databasePath, + sessionKey: "global", + mutate: (current) => { + expect(current?.runtimeSessionName).toBe("legacy-global"); + return null; + }, + }); + + expect(readAcpSessionMeta({ cfg, databasePath, sessionKey: "global" })).toBeUndefined(); + }); + }); + + it("escapes composite identities from legacy raw keys that use the old prefix", async () => { + await withTestDir({ prefix: "openclaw-acp-prefix-collision-" }, async (dir) => { + const databasePath = path.join(dir, "state", "openclaw.sqlite"); + const rawSessionKey = "@agent:research:foo"; + const rawEntry: SessionEntry = { + sessionId: "raw-session", + lifecycleRevision: "raw-revision", + updatedAt: 100, + }; + writeAcpSessionMetaForMigration({ + databasePath, + sessionKey: rawSessionKey, + lifecycleRevision: "raw-revision", + meta: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "literal-prefix-key", + mode: "persistent", + state: "idle", + lastActivityAt: 123, + }, + }); + + const batch = readAcpSessionMetaBatch({ + databasePath, + entries: [{ sessionKey: rawSessionKey, entry: rawEntry }], + }); + expect(batch.get(rawEntry)?.runtimeSessionName).toBe("literal-prefix-key"); + expect( + readAcpSessionMetaForEntry({ databasePath, sessionKey: rawSessionKey, entry: rawEntry }) + ?.runtimeSessionName, + ).toBe("literal-prefix-key"); + expect( + readAcpSessionMetaForEntry({ + databasePath, + sessionKey: "foo", + agentId: "research", + entry: { + sessionId: "other-session", + lifecycleRevision: "other-revision", + }, + }), + ).toBeUndefined(); + }); + }); + it("persists ACP metadata in SQLite without writing sessions.json acp blocks", async () => { await withTestDir({ prefix: "openclaw-acp-meta-" }, async (dir) => { const storePath = path.join(dir, "sessions.json"); diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index 55b897263a11..c72d040995ca 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -2,7 +2,7 @@ import type { DatabaseSync } from "node:sqlite"; import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import type { Insertable, Selectable } from "kysely"; +import type { Insertable } from "kysely"; import { getRuntimeConfig } from "../../config/config.js"; import { patchSessionEntryWithKey } from "../../config/sessions/session-accessor.js"; import { @@ -13,17 +13,26 @@ import { type SessionEntry, } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { - executeSqliteQuerySync, - executeSqliteQueryTakeFirstSync, - getNodeSqliteKysely, -} from "../../infra/kysely-sync.js"; -import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js"; +import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { openOpenClawStateDatabase, type OpenClawStateDatabaseOptions, runOpenClawStateWriteTransaction, } from "../../state/openclaw-state-db.js"; +import { + acpSessionRowMatchesEntry, + type AcpSessionEntryBinding, + type AcpSessionRow, + type AcpSessionsTable, + buildAcpDatabaseSessionKey, + getAcpSessionKysely, + legacyAcpDatabaseSessionKeys, + parseAcpDatabaseSessionKeyCandidates, + resolveReadableAcpSessionRow, + selectAcpSessionRow, + selectAcpSessionRowForStoreEntry, +} from "./session-meta-keys.js"; +import { clearLegacyEmbeddedAcpMetadata } from "./session-meta-legacy-cleanup.js"; import { readSessionEntryFromStore, resolveSessionStorePathForAcp, @@ -44,17 +53,6 @@ export type AcpSessionStoreEntry = { storeReadFailed?: boolean; }; -// ACP metadata lives in SQLite but is keyed through the legacy JSON session store. -type AcpSessionsTable = OpenClawStateKyselyDatabase["acp_sessions"]; -type AcpSessionMetaDatabase = Pick; -type AcpSessionRow = Selectable; -type AcpSessionEntryBinding = Pick & - Partial>; - -function getAcpSessionKysely(db: DatabaseSync) { - return getNodeSqliteKysely(db); -} - function rowToAcpSessionMeta(row: AcpSessionRow): SessionAcpMeta { const identity = safeParseJsonRecord(row.identity_json ?? "") as SessionAcpIdentity | undefined; const runtimeOptions = safeParseJsonRecord(row.runtime_options_json ?? "") as @@ -102,74 +100,9 @@ function bindAcpSessionMeta(params: { }; } -function selectAcpSessionRow(db: DatabaseSync, sessionKey: string): AcpSessionRow | undefined { - return executeSqliteQueryTakeFirstSync( - db, - getAcpSessionKysely(db) - .selectFrom("acp_sessions") - .selectAll() - .where("session_key", "=", sessionKey), - ); -} - -function acpSessionRowMatchesEntry( - row: AcpSessionRow, - entry: AcpSessionEntryBinding | undefined, -): boolean { - return ( - row.session_id == null || - row.session_id === entry?.lifecycleRevision || - // Pre-boundary rows stored sessionId here; the next read rebinds them to the revision. - (row.session_id === entry?.sessionId && - (entry?.sessionStartedAt === undefined || row.updated_at >= entry.sessionStartedAt)) - ); -} - -function resolveReadableAcpSessionRow(params: { - row: AcpSessionRow | undefined; - entry: AcpSessionEntryBinding | undefined; - env?: NodeJS.ProcessEnv; - databasePath?: string; -}): AcpSessionRow | undefined { - const { row, entry } = params; - if (!row || !acpSessionRowMatchesEntry(row, entry)) { - return undefined; - } - const legacySessionId = entry?.sessionId; - const lifecycleRevision = entry?.lifecycleRevision; - if ( - !legacySessionId || - !lifecycleRevision || - row.session_id !== legacySessionId || - row.session_id === lifecycleRevision - ) { - return row; - } - return runOpenClawStateWriteTransaction( - (database) => { - const current = selectAcpSessionRow(database.db, row.session_key); - if (!current || current.session_id === lifecycleRevision || current.session_id == null) { - return current; - } - if (current.session_id !== legacySessionId) { - return undefined; - } - executeSqliteQuerySync( - database.db, - getAcpSessionKysely(database.db) - .updateTable("acp_sessions") - .set({ session_id: lifecycleRevision }) - .where("session_key", "=", row.session_key) - .where("session_id", "=", legacySessionId), - ); - return { ...current, session_id: lifecycleRevision }; - }, - { env: params.env, path: params.databasePath }, - ); -} - export function readAcpSessionMeta(params: { sessionKey: string; + agentId?: string; cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; databasePath?: string; @@ -180,16 +113,26 @@ export function readAcpSessionMeta(params: { } const storeEntry = readSessionEntryFromStore({ sessionKey, + agentId: params.agentId, cfg: params.cfg, env: params.env, clone: false, }); + if (!storeEntry.storePath) { + return undefined; + } const database = openOpenClawStateDatabase({ env: params.env, path: params.databasePath, }); const row = resolveReadableAcpSessionRow({ - row: selectAcpSessionRow(database.db, storeEntry.storeSessionKey), + row: selectAcpSessionRowForStoreEntry( + database.db, + storeEntry.storeSessionKey, + storeEntry.agentId, + storeEntry.cfg, + storeEntry.entry, + ), entry: storeEntry.entry, env: params.env, databasePath: params.databasePath, @@ -202,6 +145,8 @@ export function readAcpSessionMeta(params: { export function readAcpSessionMetaForEntry(params: { sessionKey: string; + agentId?: string; + cfg?: OpenClawConfig; entry: AcpSessionEntryBinding | undefined; env?: NodeJS.ProcessEnv; databasePath?: string; @@ -215,7 +160,13 @@ export function readAcpSessionMetaForEntry(params: { path: params.databasePath, }); const row = resolveReadableAcpSessionRow({ - row: selectAcpSessionRow(database.db, sessionKey), + row: selectAcpSessionRowForStoreEntry( + database.db, + sessionKey, + params.agentId, + params.cfg, + params.entry, + ), entry: params.entry, env: params.env, databasePath: params.databasePath, @@ -229,15 +180,21 @@ export function readAcpSessionMetaForEntry(params: { export function readAcpSessionMetaBatch(params: { entries: ReadonlyArray<{ sessionKey: string; + agentId?: string; entry: SessionEntry; }>; env?: NodeJS.ProcessEnv; databasePath?: string; + cfg?: OpenClawConfig; }): Map { const result = new Map(); - const entriesByKey = new Map(); + const entriesByKey = new Map< + string, + Array<{ entry: SessionEntry; rawSessionKey: string; legacyKeys: string[] }> + >(); for (const item of params.entries) { - const sessionKey = item.sessionKey.trim(); + const rawSessionKey = item.sessionKey.trim(); + const sessionKey = buildAcpDatabaseSessionKey(rawSessionKey, item.agentId); if (!sessionKey) { continue; } @@ -245,8 +202,9 @@ export function readAcpSessionMetaBatch(params: { result.set(item.entry, item.entry.acp); continue; } + const legacyKeys = legacyAcpDatabaseSessionKeys(rawSessionKey, item.agentId, params.cfg); const entries = entriesByKey.get(sessionKey) ?? []; - entries.push(item.entry); + entries.push({ entry: item.entry, rawSessionKey, legacyKeys }); entriesByKey.set(sessionKey, entries); } if (entriesByKey.size === 0) { @@ -260,7 +218,16 @@ export function readAcpSessionMetaBatch(params: { // Chunked IN keeps each statement under SQLite's bind-variable cap, matching the // sharing-store membership precedent; one statement per 500 keys instead of per row. const db = getAcpSessionKysely(database.db); - const requestedKeys = [...entriesByKey.keys()]; + const requestedKeySet = new Set(); + for (const [sessionKey, entries] of entriesByKey) { + requestedKeySet.add(sessionKey); + for (const item of entries) { + for (const legacyKey of item.legacyKeys) { + requestedKeySet.add(legacyKey); + } + } + } + const requestedKeys = [...requestedKeySet]; const keyChunks: string[][] = []; for (let index = 0; index < requestedKeys.length; index += 500) { keyChunks.push(requestedKeys.slice(index, index + 500)); @@ -273,17 +240,42 @@ export function readAcpSessionMetaBatch(params: { ).rows, ); const rowsByKey = new Map(rows.map((row) => [row.session_key, row])); + const legacyRowsToRekey: Array<{ row: AcpSessionRow; sessionKey: string }> = []; for (const [sessionKey, entries] of entriesByKey) { - for (const entry of entries) { - const row = resolveReadableAcpSessionRow({ - row: rowsByKey.get(sessionKey), - entry, - env: params.env, - databasePath: params.databasePath, - }); - result.set(entry, row ? rowToAcpSessionMeta(row) : undefined); + for (const item of entries) { + const row = [sessionKey, ...item.legacyKeys] + .map((key) => rowsByKey.get(key)) + .map((candidateRow) => + resolveReadableAcpSessionRow({ + row: candidateRow, + entry: item.entry, + env: params.env, + databasePath: params.databasePath, + }), + ) + .find((candidateRow) => candidateRow !== undefined); + result.set(item.entry, row ? rowToAcpSessionMeta(row) : undefined); + if (row && row.session_key !== sessionKey) { + legacyRowsToRekey.push({ row, sessionKey }); + } } } + if (legacyRowsToRekey.length > 0) { + runOpenClawStateWriteTransaction( + (transactionDatabase) => { + for (const { row, sessionKey } of legacyRowsToRekey) { + upsertAcpSessionMetaRow(transactionDatabase.db, { ...row, session_key: sessionKey }); + executeSqliteQuerySync( + transactionDatabase.db, + getAcpSessionKysely(transactionDatabase.db) + .deleteFrom("acp_sessions") + .where("session_key", "=", row.session_key), + ); + } + }, + { env: params.env, path: params.databasePath }, + ); + } return result; } @@ -431,6 +423,7 @@ function upsertAcpSessionMetaRow(db: DatabaseSync, row: Insertable; -}): Promise { - const sessionKeys = new Set( - Array.from(params.sessionKeys, (sessionKey) => sessionKey?.trim()).filter( - (sessionKey): sessionKey is string => Boolean(sessionKey), - ), - ); - if (sessionKeys.size === 0) { - return; - } - for (const sessionKey of sessionKeys) { - await patchSessionEntryWithKey( - { - storePath: params.storePath, - sessionKey, - }, - (entry) => { - if (!entry.acp) { - return null; - } - const next = { ...entry }; - delete next.acp; - return next; - }, - { - replaceEntry: true, - skipMaintenance: true, - }, - ); - } -} - export async function upsertAcpSessionMeta(params: { sessionKey: string; + agentId?: string; cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; databasePath?: string; @@ -588,23 +564,33 @@ export async function upsertAcpSessionMeta(params: { } const storeEntry = readSessionEntryFromStore({ sessionKey, + agentId: params.agentId, cfg: params.cfg, env: params.env, clone: false, }); + if (!storeEntry.storePath) { + return null; + } const { entry } = storeEntry; const storageSessionKey = storeEntry.storeSessionKey; + const databaseSessionKey = buildAcpDatabaseSessionKey(storageSessionKey, storeEntry.agentId); let current: SessionAcpMeta | undefined; + let currentRowKey: string | undefined; let nextMeta: SessionAcpMeta | null | undefined; let preparedEntry: SessionEntry | undefined; const updatedAt = params.now?.() ?? Date.now(); runOpenClawStateWriteTransaction( (database) => { - const currentRow = selectAcpSessionRow(database.db, storageSessionKey); - current = - currentRow && acpSessionRowMatchesEntry(currentRow, entry) - ? rowToAcpSessionMeta(currentRow) - : undefined; + const currentRow = selectAcpSessionRowForStoreEntry( + database.db, + storageSessionKey, + storeEntry.agentId, + storeEntry.cfg, + entry, + ); + currentRowKey = currentRow?.session_key; + current = currentRow ? rowToAcpSessionMeta(currentRow) : undefined; preparedEntry = mergeSessionEntry(entry, { updatedAt }); nextMeta = params.mutate( current, @@ -638,9 +624,14 @@ export async function upsertAcpSessionMeta(params: { : null; runOpenClawStateWriteTransaction( (database) => { - const sessionKeysToDelete = new Set([storageSessionKey]); + const sessionKeysToDelete = new Set([databaseSessionKey]); + if (currentRowKey) { + sessionKeysToDelete.add(currentRowKey); + } if (patched?.sessionKey) { - sessionKeysToDelete.add(patched.sessionKey); + sessionKeysToDelete.add( + buildAcpDatabaseSessionKey(patched.sessionKey, storeEntry.agentId), + ); } for (const key of sessionKeysToDelete) { executeSqliteQuerySync( @@ -687,24 +678,47 @@ export async function upsertAcpSessionMeta(params: { }); runOpenClawStateWriteTransaction( (database) => { + const persistedDatabaseSessionKey = buildAcpDatabaseSessionKey( + persisted.sessionKey, + storeEntry.agentId, + ); upsertAcpSessionMetaRow( database.db, bindAcpSessionMeta({ - sessionKey: persisted.sessionKey, + sessionKey: persistedDatabaseSessionKey, sessionId: persisted.entry.sessionId, lifecycleRevision: persisted.entry.lifecycleRevision, meta: metaToPersist, updatedAt, }), ); - if (persisted.sessionKey !== storageSessionKey) { + if (persistedDatabaseSessionKey !== databaseSessionKey) { executeSqliteQuerySync( database.db, getAcpSessionKysely(database.db) .deleteFrom("acp_sessions") - .where("session_key", "=", storageSessionKey), + .where("session_key", "=", databaseSessionKey), ); } + if (currentRowKey && currentRowKey !== persistedDatabaseSessionKey) { + executeSqliteQuerySync( + database.db, + getAcpSessionKysely(database.db) + .deleteFrom("acp_sessions") + .where("session_key", "=", currentRowKey), + ); + } + if (persistedDatabaseSessionKey !== persisted.sessionKey) { + const legacyRow = selectAcpSessionRow(database.db, persisted.sessionKey); + if (legacyRow && acpSessionRowMatchesEntry(legacyRow, persisted.entry)) { + executeSqliteQuerySync( + database.db, + getAcpSessionKysely(database.db) + .deleteFrom("acp_sessions") + .where("session_key", "=", persisted.sessionKey), + ); + } + } }, { env: params.env, path: params.databasePath }, ); diff --git a/src/agents/acp-spawn-heartbeat.test.ts b/src/agents/acp-spawn-heartbeat.test.ts new file mode 100644 index 000000000000..ae45a1794260 --- /dev/null +++ b/src/agents/acp-spawn-heartbeat.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isHeartbeatEnabledForSessionAgent } from "./subagents/spawn/acp-spawn-heartbeat.js"; +import { resolveAcpSpawnRequesterState } from "./subagents/spawn/acp-spawn-requester.js"; + +describe("isHeartbeatEnabledForSessionAgent", () => { + it("uses the persisted fixed-store owner for a bare requester key", () => { + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "research" } }, + entries: { + ops: {}, + research: { heartbeat: { every: "5m" } }, + }, + }, + } satisfies OpenClawConfig; + + expect(isHeartbeatEnabledForSessionAgent({ cfg, sessionKey: "global" })).toBe(true); + }); + + it("honors an explicit ambient heartbeat owner after resolving the requester", () => { + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { + sessionStore: { agentId: "research" }, + heartbeat: { agentId: "research", every: "5m" }, + }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect(isHeartbeatEnabledForSessionAgent({ cfg, sessionKey: "global" })).toBe(true); + }); + + it("uses the prepared requester owner for a bare key in an ownerless fleet", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { + ops: {}, + research: { heartbeat: { every: "5m" } }, + }, + }, + } satisfies OpenClawConfig; + + expect( + isHeartbeatEnabledForSessionAgent({ + cfg, + requesterAgentId: "research", + sessionKey: "global", + }), + ).toBe(true); + + expect( + resolveAcpSpawnRequesterState({ + cfg, + parentSessionKey: "global", + requesterAgentId: "research", + targetAgentId: "ops", + ctx: {}, + }).heartbeatEnabled, + ).toBe(true); + }); +}); diff --git a/src/agents/agent-bundle-mcp-harness.test.ts b/src/agents/agent-bundle-mcp-harness.test.ts index 28684099ec8d..f2c55b833780 100644 --- a/src/agents/agent-bundle-mcp-harness.test.ts +++ b/src/agents/agent-bundle-mcp-harness.test.ts @@ -237,6 +237,7 @@ describe("materializeStaticMcpToolsForScheduledHarnessRunCore", () => { it("binds persistent app views to the same finite scheduled cap", async () => { const runtime = makeRuntime({ sessionId: "scheduled-app", requesterSenderId: "unused" }); + runtime.sessionKey = "agent:main:main"; delete runtime.requesterScope; const catalog = runtime.peekCatalog()!; catalog.servers["user-mail"]!.toolCount = 2; @@ -274,6 +275,8 @@ describe("materializeStaticMcpToolsForScheduledHarnessRunCore", () => { const result = await materializeStaticMcpToolsForScheduledHarnessRunCore({ sessionId: "scheduled-app", + sessionKey: "agent:main:main", + agentId: "main", workspaceDir: "/workspace", toolsAllow: ["user-mail__show", "user-mail__app-only"], }); @@ -298,6 +301,7 @@ describe("materializeStaticMcpToolsForScheduledHarnessRunCore", () => { sessionId: "scheduled-app-approval", requesterSenderId: "unused", }); + runtime.sessionKey = "agent:main:main"; delete runtime.requesterScope; const catalog = runtime.peekCatalog()!; catalog.servers["user-mail"]!.toolCount = 3; @@ -345,6 +349,8 @@ describe("materializeStaticMcpToolsForScheduledHarnessRunCore", () => { const result = await materializeStaticMcpToolsForScheduledHarnessRunCore({ sessionId: "scheduled-app-approval", + sessionKey: "agent:main:main", + agentId: "main", workspaceDir: "/workspace", toolsAllow: ["*"], }); @@ -373,6 +379,7 @@ describe("materializeStaticMcpToolsForScheduledHarnessRunCore", () => { it("allows prompt-mode app tools only under host-confirmed yolo", async () => { const runtime = makeRuntime({ sessionId: "scheduled-app-yolo", requesterSenderId: "unused" }); + runtime.sessionKey = "agent:main:main"; delete runtime.requesterScope; const catalog = runtime.peekCatalog()!; catalog.servers["user-mail"]!.toolCount = 2; @@ -409,6 +416,8 @@ describe("materializeStaticMcpToolsForScheduledHarnessRunCore", () => { const result = await materializeStaticMcpToolsForScheduledHarnessRunCore({ sessionId: "scheduled-app-yolo", + sessionKey: "agent:main:main", + agentId: "main", workspaceDir: "/workspace", toolsAllow: ["*"], autoApproveCodexAppServerApprovals: true, diff --git a/src/agents/agent-bundle-mcp-harness.ts b/src/agents/agent-bundle-mcp-harness.ts index e26ca3fcfa2d..ee57ffc8637a 100644 --- a/src/agents/agent-bundle-mcp-harness.ts +++ b/src/agents/agent-bundle-mcp-harness.ts @@ -88,6 +88,7 @@ function filterScheduledCodexApproval( type MaterializeRequesterScopedMcpToolsForHarnessRunParams = { sessionId: string; sessionKey?: string; + agentId?: string; workspaceDir: string; agentDir?: string; cfg?: OpenClawConfig; @@ -201,6 +202,7 @@ export async function materializeStaticMcpToolsForScheduledHarnessRunCore( try { liveRuntime = await materializeBundleMcpToolsForRun({ runtime, + agentId: params.agentId, reservedToolNames: params.reservedToolNames, ...(retireSnapshotRuntime ? { disposeRuntime: retireSnapshotRuntime } : {}), }); @@ -282,6 +284,7 @@ export async function materializeRequesterScopedMcpToolsForHarnessRunCore( if (scopedRuntime) { liveRuntime = await materializeBundleMcpToolsForRun({ runtime: scopedRuntime, + agentId: params.agentId, reservedToolNames: params.reservedToolNames, }); liveCatalog = scopedRuntime.peekCatalog() ?? (await scopedRuntime.getCatalog()); diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index a506542a46b6..1a18c89e8b22 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -460,6 +460,7 @@ export function buildBundleMcpToolsFromCatalog(params: { export async function materializeBundleMcpToolsForRun(params: { runtime: SessionMcpRuntime; + agentId?: string; reservedToolNames?: Iterable; disposeRuntime?: () => Promise; }): Promise { @@ -503,6 +504,7 @@ export async function materializeBundleMcpToolsForRun(params: { : undefined; const view = await fetchMcpAppView({ runtime: params.runtime, + agentId: params.agentId, serverName: tool.serverName, toolName: tool.toolName, uiResourceUri: tool.uiResourceUri, diff --git a/src/agents/agent-create.integration.test.ts b/src/agents/agent-create.integration.test.ts index f4f0482e381b..eb9e82bb3ed5 100644 --- a/src/agents/agent-create.integration.test.ts +++ b/src/agents/agent-create.integration.test.ts @@ -62,13 +62,11 @@ describe("agent roster persistence", () => { it("writes injected main and a new worker as one complete keyed roster", async () => { const persisted = await addWorkerToConfig({ gateway: { mode: "local" } }); - expect(persisted.agents?.entries).toMatchObject({ - main: { default: true }, - worker: { workspace: expect.any(String) }, - }); - expect( - Object.values(persisted.agents?.entries ?? {}).filter((entry) => entry.default === true), - ).toHaveLength(1); + expect(persisted.agents?.entries?.main).toMatchObject({ workspace: expect.any(String) }); + expect(persisted.agents?.entries?.worker).toMatchObject({ workspace: expect.any(String) }); + expect(Object.values(persisted.agents?.entries ?? {})).not.toContainEqual( + expect.objectContaining({ default: expect.anything() }), + ); }); it("replaces a legacy list with the complete keyed roster", async () => { @@ -82,8 +80,8 @@ describe("agent roster persistence", () => { }); expect(persisted.agents).not.toHaveProperty("list"); + expect(persisted.agents?.entries?.main).toMatchObject({ workspace: expect.any(String) }); expect(persisted.agents?.entries).toMatchObject({ - main: { default: true }, ops: { workspace: "/srv/ops" }, worker: { workspace: expect.any(String) }, }); diff --git a/src/agents/agent-create.test.ts b/src/agents/agent-create.test.ts index d76f4b67ecef..bd3342a77901 100644 --- a/src/agents/agent-create.test.ts +++ b/src/agents/agent-create.test.ts @@ -72,7 +72,7 @@ import { createAgent } from "./agent-create.js"; describe("createAgent", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.config = { agents: { list: [{ id: "main", default: true }] } }; + mocks.config = { agents: { list: [{ id: "main" }] } }; mocks.persisted = {}; mocks.readAgentDeletionJournal.mockReturnValue(undefined); mocks.claimCompletedAgentDeletion.mockReturnValue(true); @@ -166,16 +166,16 @@ describe("createAgent", () => { expect((mocks.persisted.agents as { list?: unknown }).list).toBeUndefined(); }); - it("keeps the first staged roster entry as the default", async () => { + it("keeps the first staged roster entry marker-free", async () => { mocks.config = { agents: { list: [] } }; await createAgent({ entry: { id: "researcher", name: "Researcher", default: false }, }); - expect(mocks.persisted).toMatchObject({ - agents: { entries: { researcher: expect.objectContaining({ default: true }) } }, - }); + expect( + (mocks.persisted.agents as { entries?: Record })?.entries?.researcher, + ).not.toHaveProperty("default"); }); it.each([ @@ -224,7 +224,7 @@ describe("createAgent", () => { mocks.config = { agents: { list: [ - { id: "main", default: true, name: "Main" }, + { id: "main", name: "Main" }, { id: "ops", name: "Ops" }, ], }, @@ -237,7 +237,7 @@ describe("createAgent", () => { expect(mocks.persisted).toMatchObject({ agents: { entries: { - main: { default: true, name: "Main" }, + main: { name: "Main" }, ops: { name: "Ops" }, researcher: expect.objectContaining({ model: "openai/gpt-5.5" }), }, @@ -252,9 +252,9 @@ describe("createAgent", () => { entry: { id: "main", name: "main", - default: true, workspace: "/tmp/main-work", }, + bootstrapMain: true, }), ).resolves.toMatchObject({ status: "existing", agentId: "main" }); expect(mocks.ensureAgentWorkspace).toHaveBeenCalledOnce(); @@ -266,14 +266,15 @@ describe("createAgent", () => { it("does not overwrite an already materialized main agent", async () => { mocks.config = { agents: { - list: [{ id: "main", default: true, name: "Existing", workspace: "/tmp/existing" }], + list: [{ id: "main", name: "Existing", workspace: "/tmp/existing" }], }, }; mocks.resolveAgentWorkspaceDir.mockReturnValueOnce("/tmp/existing"); await expect( createAgent({ - entry: { id: "main", name: "Replacement", default: true, workspace: "/tmp/new" }, + entry: { id: "main", name: "Replacement", workspace: "/tmp/new" }, + bootstrapMain: true, }), ).resolves.toMatchObject({ status: "existing", @@ -295,50 +296,44 @@ describe("createAgent", () => { await expect( createAgent({ - entry: { id: "main", default: true, workspace: "/tmp/replacement" }, + entry: { id: "main", workspace: "/tmp/replacement" }, + bootstrapMain: true, }), ).resolves.toMatchObject({ status: "existing", workspace: "/tmp/persisted" }); expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); }); - it("rejects a default marker when a roster already exists", async () => { - const before = structuredClone(mocks.config); - + it("drops a deprecated staged default marker", async () => { await expect( - createAgent({ - entry: { id: "researcher", name: "Researcher", default: true }, - }), - ).resolves.toMatchObject({ - status: "error", - reason: "default-conflict", - message: expect.stringContaining("Reassign the default separately"), - }); - expect(mocks.config).toEqual(before); - expect(mocks.persisted).toEqual({}); - expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + createAgent({ entry: { id: "researcher", name: "Researcher", default: true } }), + ).resolves.toMatchObject({ status: "created", agentId: "researcher" }); + expect( + (mocks.persisted.agents as { entries?: Record })?.entries?.researcher, + ).not.toHaveProperty("default"); + expect(mocks.ensureAgentWorkspace).toHaveBeenCalledOnce(); }); it("rejects a concurrent non-main roster during main bootstrap", async () => { const transformConfig = vi.fn(async ({ transform }) => - transform({ agents: { list: [{ id: "main" }, { id: "ops", default: true }] } }), + transform({ agents: { list: [{ id: "main" }, { id: "ops" }] } }), ); await expect( createAgent({ - entry: { id: "main", default: true, workspace: "/tmp/main" }, + entry: { id: "main", workspace: "/tmp/main" }, + bootstrapMain: true, transformConfig, }), ).resolves.toMatchObject({ - status: "error", - reason: "default-conflict", - message: expect.stringContaining("Reassign the default separately"), + status: "existing", + agentId: "main", }); expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); }); it("respects skipBootstrap from the current config", async () => { mocks.config = { - agents: { defaults: { skipBootstrap: true }, list: [{ id: "main", default: true }] }, + agents: { defaults: { skipBootstrap: true }, list: [{ id: "main" }] }, }; await createAgent({ name: "researcher", workspace: "/tmp/work" }); @@ -456,7 +451,7 @@ describe("createAgent", () => { it("claims a recovered completed tombstone only once for an existing roster entry", async () => { mocks.config = { - agents: { list: [{ id: "main", default: true }, { id: "researcher" }] }, + agents: { list: [{ id: "main" }, { id: "researcher" }] }, }; mocks.readAgentDeletionJournal.mockReturnValue({ operationId: "delete-1", @@ -488,7 +483,7 @@ describe("createAgent", () => { it("rejects a concurrent duplicate from the mutation snapshot", async () => { mocks.config = { - agents: { list: [{ id: "main", default: true }, { id: "researcher" }] }, + agents: { list: [{ id: "main" }, { id: "researcher" }] }, }; await expect(createAgent({ name: "researcher" })).resolves.toMatchObject({ @@ -505,7 +500,7 @@ describe("createAgent", () => { }); const transformConfig = vi.fn(async ({ maxAttempts, transform }) => { expect(maxAttempts).toBe(1); - return await transform({ agents: { list: [{ id: "main", default: true }] } }); + return await transform({ agents: { list: [{ id: "main" }] } }); }); await expect( diff --git a/src/agents/agent-create.ts b/src/agents/agent-create.ts index 774f86d9c2f4..59d35cb88965 100644 --- a/src/agents/agent-create.ts +++ b/src/agents/agent-create.ts @@ -43,7 +43,6 @@ type CreateAgentResult = reason: | "invalid-name" | "reserved-id" - | "default-conflict" | "already-exists" | "deletion-pending" | "invalid-bindings" @@ -59,6 +58,8 @@ type CreateAgentEntry = AgentEntryConfig & { id: string }; type CreateAgentParams = { name?: string; entry?: CreateAgentEntry; + /** Internal authorization for onboarding to materialize the reserved sole `main` agent. */ + bootstrapMain?: boolean; workspace?: string; model?: string; emoji?: unknown; @@ -71,7 +72,6 @@ type CreateAgentParams = { }; class DuplicateAgentError extends Error {} -class DefaultAgentConflictError extends Error {} class InvalidAgentBindingsError extends Error {} function createError( @@ -89,9 +89,7 @@ function hasValidRawAgentIdCharacters(value: string): boolean { function isInjectedBootstrapMainEntry(entry: CreateAgentEntry | undefined): boolean { return ( - entry?.id === RESERVED_BOOTSTRAP_AGENT_ID && - entry.default === true && - Object.keys(entry).every((key) => key === "id" || key === "default") + entry?.id === RESERVED_BOOTSTRAP_AGENT_ID && Object.keys(entry).every((key) => key === "id") ); } @@ -126,7 +124,7 @@ export async function createAgent(params: CreateAgentParams): Promise entry.default === true); - const stagedDefaultMatchesCurrent = - existingEntry?.default === true && currentDefaults.length === 1; if ( - params.entry?.default === true && + isBootstrapMain && currentEntries.length > 0 && - !stagedDefaultMatchesCurrent + !currentEntries.some( + (entry) => normalizeAgentId(entry.id) === RESERVED_BOOTSTRAP_AGENT_ID, + ) ) { - throw new DefaultAgentConflictError(); + // Never inject reserved main into a concurrently authored fleet. + throw new DuplicateAgentError(); } if (existingIndex >= 0 && !isBootstrapMain) { throw new DuplicateAgentError(); @@ -196,7 +194,9 @@ export async function createAgent(params: CreateAgentParams): Promise= 0 && isBootstrapMain && - (!isInjectedBootstrapMainEntry(existingEntry) || context.snapshot.exists) + (currentEntries.length !== 1 || + !isInjectedBootstrapMainEntry(existingEntry) || + context.snapshot.exists) ) { return { nextConfig: currentConfig, @@ -231,17 +231,17 @@ export async function createAgent(params: CreateAgentParams): Promise { expect(() => resolveDefaultAgentId({ agents: { list: [] } })).toThrow("No agents configured"); }); - it("preserves legacy first-entry selection while diagnostic lookup stays strict", () => { + it("preserves raw legacy markers while sole-agent lookup stays strict", () => { + expect(resolveSoleAgentId({ agents: { entries: { alpha: {} } } })).toBe("alpha"); + expect(tryResolveSoleAgentId({ agents: { entries: { alpha: {} } } })).toBe("alpha"); const missingDefault = { agents: { list: [{ id: "alpha" }, { id: "beta" }] } }; - expect(resolveDefaultAgentId(missingDefault)).toBe("alpha"); + expect(() => resolveDefaultAgentId(missingDefault)).toThrow(AgentSelectionRequiredError); expect(tryResolveDefaultAgentId(missingDefault)).toBeUndefined(); expect( resolveDefaultAgentId({ @@ -43,7 +50,7 @@ describe("agent roster resolution", () => { ], }, }; - expect(resolveDefaultAgentId(duplicateDefaults)).toBe("alpha"); + expect(() => resolveDefaultAgentId(duplicateDefaults)).toThrow(AgentSelectionRequiredError); expect(tryResolveDefaultAgentId(duplicateDefaults)).toBeUndefined(); }); @@ -56,14 +63,40 @@ describe("agent roster resolution", () => { expect(resolveAgentConfig({ agents: { defaults, list: [] } }, "main")).toBeUndefined(); }); + it("keeps the retained legacy owner on the inherited workspace before config write", () => { + const cfg = migratePersistedImplicitMainRoster({ + agents: { + defaults: { workspace: "/srv/ops" }, + entries: { ops: { default: true }, research: {} }, + }, + }).config as OpenClawConfig; + + expect(cfg.agents?.entries?.ops?.default).toBeUndefined(); + expect(cfg.agents?.entries?.ops?.workspace).toBeUndefined(); + expect(resolveAgentWorkspaceDir(cfg, "ops")).toBe("/srv/ops"); + expect(resolveAgentWorkspaceDir(cfg, "research")).toBe("/srv/ops/research"); + }); + + it("keeps a raw legacy marker owner on the inherited workspace", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { workspace: "/srv/ops" }, + entries: { ops: { default: true }, research: {} }, + }, + }; + + expect(resolveAgentWorkspaceDir(cfg, "ops")).toBe("/srv/ops"); + expect(resolveAgentWorkspaceDir(cfg, "research")).toBe("/srv/ops/research"); + }); + it("offers a non-throwing diagnostic lookup for malformed rosters", () => { - expect(tryResolveDefaultAgentId({ agents: { list: [{ id: "alpha" }] } })).toBeUndefined(); + expect(tryResolveDefaultAgentId({ agents: { list: [{ id: "alpha" }] } })).toBe("alpha"); for (const marker of ["false", 1]) { expect( tryResolveDefaultAgentId({ agents: { entries: { alpha: { default: marker } } }, } as unknown as OpenClawConfig), - ).toBeUndefined(); + ).toBe("alpha"); } }); diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index 6378cf43f7c6..b75cd12b363a 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -1,6 +1,7 @@ /** Resolves configured agent ids, directories, workspaces, and merged agent defaults. */ import path from "node:path"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; +import { getRetainedLegacyDefaultAgentId } from "../config/legacy.default-agent-owner-state.js"; import { hasExplicitModelPolicyAllow } from "../config/model-policy-allowlist-migration.js"; import { resolveStateDir } from "../config/paths.js"; import type { @@ -21,6 +22,30 @@ export type ListedAgentEntry = { source: { kind: "entries"; key: string } | { kind: "list"; index: number }; }; +export type AgentSelectionContext = { + surface: string; + hint: string; +}; + +export class AgentSelectionRequiredError extends Error { + readonly code = "AGENT_SELECTION_REQUIRED"; + readonly agentIds: string[]; + readonly surface: string; + readonly hint: string; + + constructor(agentIds: string[], context?: AgentSelectionContext) { + const surface = context?.surface ?? "this operation"; + const hint = + context?.hint ?? + "Select an agent explicitly; CLI callers can pass --agent , channels can add a binding, and ambient services can set their agentId target."; + super(`Multiple agents are configured, but ${surface} has no explicit owner. ${hint}`); + this.name = "AgentSelectionRequiredError"; + this.agentIds = agentIds; + this.surface = surface; + this.hint = hint; + } +} + /** Per-agent config after applying agent defaults and normalizing scalar fields. */ export type ResolvedAgentConfig = { name?: string; @@ -65,11 +90,22 @@ function stripNullBytes(s: string): string { /** Lists valid configured agent entries from config. */ export function listAgentEntriesWithSource(cfg: OpenClawConfig): ListedAgentEntry[] { const roster = readAgentRosterProperty(cfg); - if (roster?.kind === "entries" && roster.value && typeof roster.value === "object") { - return Object.entries(roster.value).map(([id, entry]) => ({ - entry: { ...(entry as Omit), id }, - source: { kind: "entries", key: id }, - })); + if ( + roster?.kind === "entries" && + roster.value && + typeof roster.value === "object" && + !Array.isArray(roster.value) + ) { + return Object.entries(roster.value).flatMap(([id, entry]) => + entry !== null && typeof entry === "object" && !Array.isArray(entry) + ? [ + { + entry: { ...(entry as Omit), id }, + source: { kind: "entries" as const, key: id }, + }, + ] + : [], + ); } if (roster?.kind !== "list" || !Array.isArray(roster.value)) { return []; @@ -141,30 +177,56 @@ export function listAgentIds(cfg: OpenClawConfig): string[] { return ids; } -/** Resolves the configured default while preserving the shipped Plugin SDK legacy shape. */ -export function resolveDefaultAgentId(cfg: OpenClawConfig): string { +export function tryResolveSoleAgentId(cfg: OpenClawConfig): string | undefined { const agents = listAgentEntries(cfg); if (agents.length === 0) { - // Runtime config loading materializes this entry. Keep the roster-property-absent - // case for shipped Plugin SDK callers that still pass a pre-roster config object. if (!hasAgentRosterProperty(cfg)) { return LEGACY_IMPLICIT_AGENT_ID; } - throw new Error("No agents configured. Run `openclaw onboard` or `openclaw agents add` first."); - } - // Runtime config loading canonicalizes zero/multiple markers before this helper is called. - // External SDK callers may still pass the shipped list shape, which chose the first candidate. - return normalizeAgentId((agents.find((agent) => agent?.default === true) ?? agents[0])!.id); -} - -/** Returns the configured default when diagnostics must tolerate an invalid raw roster. */ -export function tryResolveDefaultAgentId(cfg: OpenClawConfig): string | undefined { - const agents = listAgentEntries(cfg); - const defaults = agents.filter((agent) => agent?.default === true); - if (defaults.length !== 1) { return undefined; } - return normalizeAgentId(defaults[0]!.id); + return agents.length === 1 ? normalizeAgentId(agents[0]!.id) : undefined; +} + +export function resolveSoleAgentId(cfg: OpenClawConfig, context?: AgentSelectionContext): string { + const sole = tryResolveSoleAgentId(cfg); + if (sole) { + return sole; + } + const agentIds = listAgentIds(cfg); + if (agentIds.length === 0) { + throw new Error("No agents configured. Run `openclaw onboard` or `openclaw agents add` first."); + } + throw new AgentSelectionRequiredError(agentIds, context); +} + +function tryResolveRawLegacyDefaultAgentId(cfg: OpenClawConfig): string | undefined { + if (cfg.agents?.ownership === "explicit") { + return undefined; + } + const marked = listAgentEntries(cfg).filter((entry) => entry.default === true); + return marked.length === 1 ? normalizeAgentId(marked[0]!.id) : undefined; +} + +/** Resolves sole/raw legacy owners plus the retained in-process migration owner. */ +export function tryResolveLegacyCompatibilityAgentId(cfg: OpenClawConfig): string | undefined { + const retainedAgentId = getRetainedLegacyDefaultAgentId(cfg); + return retainedAgentId && listAgentIds(cfg).includes(retainedAgentId) + ? retainedAgentId + : tryResolveDefaultAgentId(cfg); +} + +/** @deprecated Use resolveSoleAgentId; accepts raw shipped markers only for input compatibility. */ +export function resolveDefaultAgentId( + cfg: OpenClawConfig, + context?: AgentSelectionContext, +): string { + return tryResolveRawLegacyDefaultAgentId(cfg) ?? resolveSoleAgentId(cfg, context); +} + +/** @deprecated Use tryResolveSoleAgentId; accepts raw shipped markers only for input compatibility. */ +export function tryResolveDefaultAgentId(cfg: OpenClawConfig): string | undefined { + return tryResolveRawLegacyDefaultAgentId(cfg) ?? tryResolveSoleAgentId(cfg); } export function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined { @@ -262,6 +324,10 @@ export function resolveAgentContextLimits( return resolveAgentConfig(cfg, agentId)?.contextLimits ?? defaults; } +function tryResolveInheritedWorkspaceAgentId(cfg: OpenClawConfig): string | undefined { + return tryResolveLegacyCompatibilityAgentId(cfg); +} + export function resolveAgentWorkspaceDir( cfg: OpenClawConfig, agentId: string, @@ -272,9 +338,10 @@ export function resolveAgentWorkspaceDir( if (configured) { return stripNullBytes(resolveUserPath(configured, env)); } - const defaultAgentId = resolveDefaultAgentId(cfg); + // Read-time migration removes default:true before write-time workspace pinning can run. + const inheritedWorkspaceAgentId = tryResolveInheritedWorkspaceAgentId(cfg); const fallback = cfg.agents?.defaults?.workspace?.trim(); - if (id === defaultAgentId) { + if (inheritedWorkspaceAgentId && id === inheritedWorkspaceAgentId) { if (fallback) { return stripNullBytes(resolveUserPath(fallback, env)); } @@ -287,6 +354,18 @@ export function resolveAgentWorkspaceDir( return stripNullBytes(path.join(stateDir, `workspace-${id}`)); } +export function tryResolveConfiguredAgentWorkspaceDir( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const inheritedWorkspaceAgentId = tryResolveInheritedWorkspaceAgentId(cfg); + if (inheritedWorkspaceAgentId) { + return resolveAgentWorkspaceDir(cfg, inheritedWorkspaceAgentId, env); + } + const configured = cfg.agents?.defaults?.workspace?.trim(); + return configured ? stripNullBytes(resolveUserPath(configured, env)) : undefined; +} + export function resolveAgentDir( cfg: OpenClawConfig, agentId: string, @@ -309,5 +388,9 @@ export function resolveDefaultAgentDir( cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, ): string { - return resolveAgentDir(cfg, resolveDefaultAgentId(cfg), env); + return resolveAgentDir( + cfg, + tryResolveLegacyCompatibilityAgentId(cfg) ?? resolveDefaultAgentId(cfg), + env, + ); } diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 7a9920f1d63a..c850aad63372 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import { resolveSessionAuthProfileOverrideSource } from "../config/sessions/auth-profile-override-provenance.js"; import { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; export { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js"; import { lowercasePreservingWhitespace, @@ -26,11 +27,13 @@ import { import { resolveEffectiveAgentSkillFilter } from "../skills/discovery/agent-filter.js"; import { resolveUserPath } from "../utils.js"; import { + AgentSelectionRequiredError, listAgentIds, resolveMutableAgentEntry, resolveAgentConfig, resolveAgentWorkspaceDir, resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, } from "./agent-scope-config.js"; export { listAgentEntries, @@ -43,8 +46,14 @@ export { resolveAgentDir, resolveDefaultAgentDir, resolveAgentWorkspaceDir, + tryResolveConfiguredAgentWorkspaceDir, resolveDefaultAgentId, + resolveSoleAgentId, + tryResolveLegacyCompatibilityAgentId, + tryResolveSoleAgentId, tryResolveDefaultAgentId, + AgentSelectionRequiredError, + type AgentSelectionContext, type ResolvedAgentConfig, } from "./agent-scope-config.js"; @@ -305,7 +314,6 @@ export function resolveSessionAgentIds(params: { defaultAgentId: string; sessionAgentId: string; } { - const defaultAgentId = resolveDefaultAgentId(params.config ?? {}); const explicitAgentIdRaw = normalizeLowercaseStringOrEmpty(params.agentId); const explicitAgentId = explicitAgentIdRaw ? normalizeAgentId(explicitAgentIdRaw) : null; const fallbackAgentIdRaw = normalizeLowercaseStringOrEmpty(params.fallbackAgentId); @@ -313,9 +321,44 @@ export function resolveSessionAgentIds(params: { const sessionKey = params.sessionKey?.trim(); const normalizedSessionKey = sessionKey ? normalizeLowercaseStringOrEmpty(sessionKey) : undefined; const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null; + const sessionKeyAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : null; + const cfg = params.config ?? {}; + const persistedStoreOwner = resolvePersistedSessionStoreOwnerForKey(cfg, sessionKey); + if (sessionKeyAgentId && explicitAgentId && explicitAgentId !== sessionKeyAgentId) { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: "session agent resolution", + hint: `The agent-scoped session key belongs to "${sessionKeyAgentId}", not "${explicitAgentId}".`, + }); + } + const requestedUnscopedAgentId = explicitAgentId ?? fallbackAgentId; + if (!sessionKeyAgentId && persistedStoreOwner.kind === "retired") { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: "session agent resolution", + hint: `The shared fixed-store row belongs to retired agent "${persistedStoreOwner.agentId}".`, + }); + } + if ( + !sessionKeyAgentId && + persistedStoreOwner.kind === "configured" && + requestedUnscopedAgentId && + requestedUnscopedAgentId !== persistedStoreOwner.agentId + ) { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: "session agent resolution", + hint: `The shared fixed-store row belongs to "${persistedStoreOwner.agentId}", not "${requestedUnscopedAgentId}".`, + }); + } + const compatibilityAgentId = tryResolveLegacyCompatibilityAgentId(cfg); const sessionAgentId = - explicitAgentId ?? - (parsed?.agentId ? normalizeAgentId(parsed.agentId) : (fallbackAgentId ?? defaultAgentId)); + sessionKeyAgentId ?? + (persistedStoreOwner.kind === "configured" ? persistedStoreOwner.agentId : undefined) ?? + requestedUnscopedAgentId ?? + compatibilityAgentId ?? + resolveDefaultAgentId(cfg, { + surface: "session agent resolution", + hint: "Pass an agentId, an agent-scoped session key, or a prepared fallbackAgentId.", + }); + const defaultAgentId = compatibilityAgentId ?? sessionAgentId; return { defaultAgentId, sessionAgentId }; } @@ -514,8 +557,12 @@ export function resolveRunModelFallbacksOverride(params: { const explicitAgentId = normalizeOptionalString(params.agentId); const agentId = explicitAgentId ? normalizeAgentId(explicitAgentId) - : (parseAgentSessionKey(params.sessionKey)?.agentId ?? - (listAgentIds(params.cfg).length > 0 ? resolveDefaultAgentId(params.cfg) : undefined)); + : listAgentIds(params.cfg).length > 0 + ? resolveSessionAgentIds({ + config: params.cfg, + sessionKey: params.sessionKey ?? undefined, + }).sessionAgentId + : undefined; return agentId ? resolveAgentModelFallbacksOverride(params.cfg, agentId) : undefined; } diff --git a/src/agents/agent-tools.policy.test.ts b/src/agents/agent-tools.policy.test.ts index 176d02dc881f..a5acc845f4b8 100644 --- a/src/agents/agent-tools.policy.test.ts +++ b/src/agents/agent-tools.policy.test.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js"; @@ -508,6 +509,48 @@ describe("resolveEffectiveToolPolicy", () => { expect(result.agentPolicy).toEqual({ deny: ["exec"] }); }); + it("uses the retained legacy owner policy when no session scope is provided", () => { + const cfg = retainLegacyDefaultAgentId( + { + agents: { + ownership: "explicit", + entries: { + ops: { tools: { deny: ["read"] } }, + research: { tools: { deny: ["exec"] } }, + }, + }, + }, + "research", + ); + + const result = resolveEffectiveToolPolicy({ config: cfg }); + + expect(result.agentId).toBe("research"); + expect(result.agentPolicy).toEqual({ deny: ["exec"] }); + }); + + it("uses the configured fixed-store owner policy for an unscoped session key", () => { + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "research" } }, + entries: { + ops: { tools: { deny: ["read"] } }, + research: { tools: { deny: ["exec"] } }, + }, + }, + } satisfies OpenClawConfig; + + const result = resolveEffectiveToolPolicy({ config: cfg, sessionKey: "global" }); + + expect(result.agentId).toBe("research"); + expect(result.agentPolicy).toEqual({ deny: ["exec"] }); + expect(() => + resolveEffectiveToolPolicy({ config: cfg, agentId: "ops", sessionKey: "global" }), + ).toThrow(/belongs to "research"/); + }); + it("keeps slash-containing modelId scoped to the selected provider", () => { const cfg = { tools: { diff --git a/src/agents/agent-tools.policy.ts b/src/agents/agent-tools.policy.ts index aa9f2868bdd7..003da166d622 100644 --- a/src/agents/agent-tools.policy.ts +++ b/src/agents/agent-tools.policy.ts @@ -26,7 +26,7 @@ import { } from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { hasAgentRosterProperty } from "./agent-scope-config.js"; -import { listAgentEntries, resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; +import { listAgentEntries, resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js"; import { resolveProviderToolPolicy } from "./provider-tool-policy.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import type { SandboxToolPolicy } from "./sandbox.js"; @@ -378,13 +378,16 @@ export function resolveEffectiveToolPolicy(params: { typeof params.agentId === "string" && params.agentId.trim() ? normalizeAgentId(params.agentId) : undefined; - const agentId = - explicitAgentId ?? - (params.sessionKey ? parseAgentSessionKey(params.sessionKey)?.agentId : undefined) ?? - (params.config && - (!hasAgentRosterProperty(params.config) || listAgentEntries(params.config).length > 0) - ? resolveDefaultAgentId(params.config) - : undefined); + const canResolveConfiguredAgent = + params.config && + (!hasAgentRosterProperty(params.config) || listAgentEntries(params.config).length > 0); + const agentId = canResolveConfiguredAgent + ? resolveSessionAgentIds({ + config: params.config, + agentId: explicitAgentId, + sessionKey: params.sessionKey, + }).sessionAgentId + : (explicitAgentId ?? parseAgentSessionKey(params.sessionKey)?.agentId); const agentConfig = params.config && agentId ? resolveAgentConfig(params.config, agentId) : undefined; // Shipped pre-roster SDK inputs allowed this raw defaults shape. Runtime-loaded diff --git a/src/agents/bash-tools.exec-approval-followup.test.ts b/src/agents/bash-tools.exec-approval-followup.test.ts index 50e344366cde..83e1d0c1bc1a 100644 --- a/src/agents/bash-tools.exec-approval-followup.test.ts +++ b/src/agents/bash-tools.exec-approval-followup.test.ts @@ -146,6 +146,17 @@ function expectStableDirectDelivery(params: Record, approvalId: } describe("exec approval followup", () => { + it("carries the prepared agent owner for a bare session key", async () => { + await sendExecApprovalFollowup({ + approvalId: "req-bare-owner", + agentId: "research", + sessionKey: "global", + resultText: "Exec finished (gateway id=req-bare-owner, code 0)\nok", + }); + + expectGatewayAgentFollowup({ sessionKey: "global", agentId: "research" }); + }); + it("uses an explicit denial prompt when the command did not run", async () => { await sendExecApprovalFollowup({ approvalId: "req-1", @@ -813,7 +824,8 @@ describe("exec approval followup", () => { it("can force direct delivery even when a session key exists", async () => { await sendExecApprovalFollowup({ approvalId: "req-direct", - sessionKey: "agent:main:telegram:direct:123", + agentId: "research", + sessionKey: "global", turnSourceChannel: "telegram", turnSourceTo: "123", turnSourceAccountId: "default", @@ -826,6 +838,7 @@ describe("exec approval followup", () => { channel: "telegram", to: "123", accountId: "default", + agentId: "research", content: "pasteable diagnostics report", idempotencyKey: "exec-approval-followup:req-direct", }); diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts index 2bc8b5b3b28b..37b907c8219a 100644 --- a/src/agents/bash-tools.exec-approval-followup.ts +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -76,6 +76,7 @@ async function callExecApprovalFollowupGateway( type ExecApprovalFollowupParams = { approvalId: string; + agentId?: string; sessionKey?: string; /** Session UUID active when the approval was requested. Carried to the gateway * so a followup whose session key was rebound by /new or /reset is dropped. */ @@ -144,6 +145,7 @@ function shouldSuppressExecDeniedFollowup(sessionKey: string | undefined): boole * real result is never suppressed by accident. */ function isExecApprovalFollowupDirectDeliveryStale(params: { + agentId: string | undefined; sessionKey: string | undefined; expectedSessionId: string | undefined; sessionStore: string | undefined; @@ -155,10 +157,11 @@ function isExecApprovalFollowupDirectDeliveryStale(params: { } try { const storePath = resolveSessionStorePathCore(normalizeOptionalString(params.sessionStore), { - agentId: resolveAgentIdFromSessionKey(sessionKey), + agentId: params.agentId ?? resolveAgentIdFromSessionKey(sessionKey), }); const resolvedSessionId = normalizeOptionalString( loadSessionEntryReadOnly({ + agentId: params.agentId, storePath, sessionKey, clone: false, @@ -335,6 +338,7 @@ function canDirectSendDeniedFollowup(sessionError: unknown): boolean { function buildAgentFollowupArgs(params: { approvalId: string; + agentId?: string; sessionKey: string; expectedSessionId?: string; resultText: string; @@ -354,6 +358,7 @@ function buildAgentFollowupArgs(params: { const fallbackChannel = sessionOnlyOriginChannel ?? params.turnSourceChannel; const isDenied = isExecDeniedResultText(params.resultText.trim()); return { + ...(params.agentId ? { agentId: params.agentId } : {}), sessionKey: params.sessionKey, message: isDenied ? buildExecApprovalFollowupPrompt(params.resultText) @@ -388,6 +393,7 @@ function buildAgentFollowupArgs(params: { async function sendDirectFollowupFallback(params: { approvalId: string; + agentId?: string; deliveryTarget: ExternalBestEffortDeliveryTarget; resultText: string; sessionError: unknown; @@ -420,7 +426,7 @@ async function sendDirectFollowupFallback(params: { accountId: params.deliveryTarget.accountId, threadId: params.deliveryTarget.threadId, content, - agentId: undefined, + agentId: params.agentId, gatewayOwnedDelivery: true, idempotencyKey: deliveryIntentId, deliveryIntentId, @@ -487,6 +493,7 @@ export async function sendExecApprovalFollowup( try { const agentArgs = buildAgentFollowupArgs({ approvalId: params.approvalId, + agentId: params.agentId, sessionKey, expectedSessionId: params.expectedSessionId, resultText, @@ -543,6 +550,7 @@ export async function sendExecApprovalFollowup( if (isDenied) { if ( isExecApprovalFollowupDirectDeliveryStale({ + agentId: params.agentId, sessionKey, expectedSessionId: params.expectedSessionId, sessionStore: params.sessionStore, @@ -562,6 +570,7 @@ export async function sendExecApprovalFollowup( if ( await sendDirectFollowupFallback({ approvalId: params.approvalId, + agentId: params.agentId, deliveryTarget, resultText, sessionError, @@ -578,6 +587,7 @@ export async function sendExecApprovalFollowup( if ( isExecApprovalFollowupDirectDeliveryStale({ + agentId: params.agentId, sessionKey, expectedSessionId: params.expectedSessionId, sessionStore: params.sessionStore, @@ -598,6 +608,7 @@ export async function sendExecApprovalFollowup( if ( await sendDirectFollowupFallback({ approvalId: params.approvalId, + agentId: params.agentId, deliveryTarget, resultText, sessionError, diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index dd52112468cb..394f22ec444e 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1186,6 +1186,7 @@ export async function processGatewayAllowlist( typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; const followupTarget = buildExecApprovalFollowupTarget({ approvalId, + agentId: params.agentId, sessionKey: params.notifySessionKey ?? params.sessionKey, expectedSessionId: params.sessionId, sessionStore: params.sessionStore, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index b6d4af65ebdc..7fb0dbe67cd1 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -472,6 +472,7 @@ export async function executeNodeHostCommand( } else { const followupTarget = execHostShared.buildExecApprovalFollowupTarget({ approvalId, + agentId: params.agentId, sessionKey: params.notifySessionKey ?? params.sessionKey, expectedSessionId: params.sessionId, sessionStore: params.sessionStore, diff --git a/src/agents/bash-tools.exec-host-shared.test.ts b/src/agents/bash-tools.exec-host-shared.test.ts index f99a99e4a44d..00a51d6158d5 100644 --- a/src/agents/bash-tools.exec-host-shared.test.ts +++ b/src/agents/bash-tools.exec-host-shared.test.ts @@ -348,6 +348,24 @@ describe("sendExecApprovalFollowupResult", () => { expect(firstExecApprovalFollowupCall()?.expectedSessionId).toBe("session-original"); }); + + it("forwards the prepared agent owner to the followup dispatch", async () => { + sendExecApprovalFollowup.mockResolvedValue(true); + + await sendExecApprovalFollowupResult( + { + approvalId: "approval-bare-owner", + agentId: "research", + sessionKey: "global", + }, + "Exec finished", + { sendExecApprovalFollowup, logWarn }, + ); + + expect(sendExecApprovalFollowup).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "research", sessionKey: "global" }), + ); + }); }); describe("isExecApprovalFollowupSessionRebound", () => { diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index d96b266bb539..fc4531a41b53 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -99,6 +99,7 @@ type RegisteredExecApprovalRequestContext = { /** Destination and context for async exec approval follow-up delivery. */ type ExecApprovalFollowupTarget = { approvalId: string; + agentId?: string; sessionKey?: string; /** Session UUID active when the approval was requested. Lets the followup be * dropped if `/new` or `/reset` rebinds the session key to a new session. */ @@ -359,6 +360,7 @@ export function buildExecApprovalFollowupTarget( ): ExecApprovalFollowupTarget { return { approvalId: params.approvalId, + ...(params.agentId ? { agentId: params.agentId } : {}), sessionKey: params.sessionKey, expectedSessionId: params.expectedSessionId, sessionStore: params.sessionStore, @@ -464,6 +466,7 @@ export async function sendExecApprovalFollowupResult( }); await send({ approvalId: target.approvalId, + ...(target.agentId ? { agentId: target.agentId } : {}), sessionKey: target.sessionKey, expectedSessionId: target.expectedSessionId, sessionStore: target.sessionStore, diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 0d0a2f4c5d9b..16eae2047b9f 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -4062,6 +4062,70 @@ describe("runCliAgent reliability", () => { } }); + it("persists a blocked bare-key turn under its fixed-store owner", async () => { + supervisorSpawnMock.mockClear(); + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_agent_run"), + runBeforeAgentRun: vi.fn(async () => ({ + pluginId: "policy-plugin", + decision: { + outcome: "block" as const, + message: "Blocked by policy.", + }, + })), + }; + setHookRunnerForTest(hookRunner); + const { dir, sessionFile } = createSessionFile(); + const storePath = path.join(dir, "shared-sessions.json"); + const sessionKey = "global"; + const context = makeClaudePreparedContext({ + sessionKey, + runId: "run-blocked-fixed-owner", + }); + context.preparedBackend.backend.sessionMode = "none"; + + try { + await expect( + runPreparedCliAgent({ + ...context, + params: { + ...context.params, + config: { + session: { store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }, + sessionFile, + storePath, + workspaceDir: dir, + prompt: "secret prompt", + }, + }), + ).resolves.toMatchObject({ meta: { livenessState: "blocked" } }); + + await expect( + loadTranscriptEvents({ + agentId: "ops", + sessionId: context.params.sessionId, + sessionKey, + storePath, + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "message", + message: expect.objectContaining({ role: "user" }), + }), + ]), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("persists before_agent_run CLI blocks through the canonical recorder", async () => { supervisorSpawnMock.mockClear(); const hookRunner = { diff --git a/src/agents/cli-runner/claude-live-session-policy.test.ts b/src/agents/cli-runner/claude-live-session-policy.test.ts index 002ebdf793f5..b427c26def18 100644 --- a/src/agents/cli-runner/claude-live-session-policy.test.ts +++ b/src/agents/cli-runner/claude-live-session-policy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { resolveClaudeLiveExecPermission } from "./claude-live-process.js"; import { acceptsClaudeLive, resolveClaudeLiveMode } from "./claude-live-session-policy.js"; import type { PreparedCliRunContext } from "./types.js"; @@ -40,4 +41,30 @@ describe("acceptsClaudeLive", () => { }), ).toBe(false); }); + + it("uses the configured fixed-store owner for an unscoped session key", () => { + const context = { + params: { + sessionKey: "global", + config: { + session: { store: "/stores/shared.sqlite" }, + tools: { exec: { security: "full", ask: "off" } }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "research" } }, + entries: { + ops: {}, + research: { tools: { exec: { security: "deny", ask: "always" } } }, + }, + }, + }, + }, + } as unknown as PreparedCliRunContext; + + expect(resolveClaudeLiveExecPermission(context)).toEqual({ + security: "deny", + ask: "always", + permissionMode: "default", + }); + }); }); diff --git a/src/agents/cli-runner/cli-run-transcript.ts b/src/agents/cli-runner/cli-run-transcript.ts index 944e1615785f..e222d0615460 100644 --- a/src/agents/cli-runner/cli-run-transcript.ts +++ b/src/agents/cli-runner/cli-run-transcript.ts @@ -1,10 +1,12 @@ import { resolveSessionStorePathCore } from "../../config/sessions/paths.js"; import { patchSessionEntryCore } from "../../config/sessions/session-accessor.js"; +import { resolvePersistedSessionStoreOwnerForTarget } from "../../config/sessions/session-store-owner.js"; import { appendExactAssistantMessageToSessionTranscript } from "../../config/sessions/transcript.js"; import { buildGenericCliContextEngineHostSupport } from "../../context-engine/host-compat.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveSessionAgentId } from "../agent-scope.js"; import { isHeartbeatLifecycleRunKind } from "../bootstrap-mode.js"; import type { CliOutput } from "../cli-output-contracts.js"; import { @@ -256,7 +258,27 @@ export async function persistCliRunBlock( try { const sessionKey = params.sessionKey?.trim() || params.sessionId; - const agentId = params.agentId ?? resolveAgentIdFromSessionKey(sessionKey); + const targetAgentId = params.sessionTarget?.agentId; + const targetStorePath = params.sessionTarget?.storePath; + const targetStoreOwner = resolvePersistedSessionStoreOwnerForTarget({ + config: params.config ?? {}, + sessionKey, + storePath: targetStorePath, + }); + const explicitAlternateStoreAgentId = + targetAgentId && + targetStorePath && + !parseAgentSessionKey(sessionKey)?.agentId && + targetStoreOwner.kind === "none" + ? targetAgentId + : undefined; + const agentId = + explicitAlternateStoreAgentId ?? + resolveSessionAgentId({ + agentId: targetAgentId ?? params.agentId, + config: params.config, + sessionKey, + }); let sessionManager = params.sessionManager; if (!sessionManager) { const sessionTarget = params.sessionTarget ?? { diff --git a/src/agents/cli-runner/mcp-grant-context.ts b/src/agents/cli-runner/mcp-grant-context.ts index df7f7cc8ea09..387461acf4b9 100644 --- a/src/agents/cli-runner/mcp-grant-context.ts +++ b/src/agents/cli-runner/mcp-grant-context.ts @@ -100,6 +100,12 @@ export function buildCliMcpGrantContext(params: { toolsAllow?: string[]; }): McpLoopbackRequestContext { const sessionKey = resolveCliMcpSessionKey(params.run, params.config, params.agentId); + const runtimePolicySessionKey = normalizeOptionalMcpContextValue( + params.run.runtimePolicySessionKey, + ); + const runtimePolicyAgentId = runtimePolicySessionKey + ? normalizeOptionalMcpContextValue(params.run.agentId) + : undefined; const clientCaps = uniqueStrings( (params.run.clientCaps ?? []).map((cap) => cap.trim()).filter(Boolean), ); @@ -127,7 +133,8 @@ export function buildCliMcpGrantContext(params: { grantedToolsAllow[0] === "message"; return { sessionKey, - runtimePolicySessionKey: normalizeOptionalMcpContextValue(params.run.runtimePolicySessionKey), + runtimePolicySessionKey, + ...(runtimePolicyAgentId ? { runtimePolicyAgentId } : {}), agentId: params.agentId, sessionId: normalizeOptionalMcpContextValue(params.run.sessionId), runId: normalizeOptionalMcpContextValue(params.run.runId), diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 2772f5ab8082..69ca9b3cd8f6 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -2761,9 +2761,11 @@ describe("prepareCliRunContext", () => { ); expect(mockBuildActiveImageGenerationTaskPromptContextForSession).toHaveBeenCalledWith( "agent:main:test", + "main", ); expect(mockBuildActiveVideoGenerationTaskPromptContextForSession).toHaveBeenCalledWith( "agent:main:test", + "main", ); }); @@ -3116,7 +3118,8 @@ describe("prepareCliRunContext", () => { context: { sessionKey: "agent:main:telegram:group:chat123", runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender", - agentId: "worker", + runtimePolicyAgentId: "worker", + agentId: "main", sessionId: "session-test", runId: "run-test-room-event-tools", workspaceDir: context.workspaceDir, @@ -3195,7 +3198,8 @@ describe("prepareCliRunContext", () => { requireExplicitMessageTarget: true, senderIsOwner: false, runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender", - agentId: "worker", + runtimePolicyAgentId: "worker", + agentId: "main", modelProvider: "anthropic", modelId: "test-model", execOverrides: { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index b5bb8c6b2dc0..7f52f4e4600d 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -412,9 +412,9 @@ export async function prepareCliRunContext( ): Promise { let params = inputParams.config ? inputParams : { ...inputParams, config: getRuntimeConfig() }; const runConfig = params.config!; - const selectedOwner = normalizeAgentId( - params.agentId?.trim() || - parseAgentSessionKey(params.sessionKey)?.agentId || + const sessionOwner = normalizeAgentId( + parseAgentSessionKey(params.sessionKey)?.agentId || + params.agentId?.trim() || LEGACY_IMPLICIT_AGENT_ID, ); // Direct CLI-runner callers predate roster-aware ownership. Adapt that SDK @@ -426,7 +426,7 @@ export async function prepareCliRunContext( ...runConfig, agents: { ...runConfig.agents, - entries: { [selectedOwner]: { default: true } }, + entries: { [sessionOwner]: { default: true } }, }, } satisfies OpenClawConfig); const started = Date.now(); @@ -450,7 +450,7 @@ export async function prepareCliRunContext( const workspaceResolution = resolveRunWorkspaceDir({ workspaceDir: params.workspaceDir, sessionKey: params.sessionKey, - agentId: params.agentId, + agentId: sessionOwner, config: workspaceConfig, }); const resolvedWorkspace = workspaceResolution.workspaceDir; @@ -563,7 +563,7 @@ export async function prepareCliRunContext( const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, - agentId: params.agentId, + agentId: sessionOwner, }); const agentContextTokens = resolveAgentConfig(params.config ?? {}, sessionAgentId)?.contextTokens; const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId); @@ -758,7 +758,7 @@ export async function prepareCliRunContext( sessionId: params.sessionId, sessionFile: params.sessionFile, sessionKey: params.sessionKey, - agentId: params.agentId, + agentId: sessionAgentId, config: params.config, }); return openClawHistoryMessages; @@ -1547,6 +1547,7 @@ export async function prepareCliRunContext( }) ?? systemPrompt; const mediaTaskSystemPromptAddition = resolveAttemptMediaTaskSystemPromptAddition({ sessionKey: params.sessionKey, + agentId: sessionAgentId, trigger: params.trigger, }); if (mediaTaskSystemPromptAddition) { @@ -1598,7 +1599,7 @@ export async function prepareCliRunContext( sessionId: params.sessionId, sessionFile: params.sessionFile, sessionKey: params.sessionKey, - agentId: params.agentId, + agentId: sessionAgentId, config: params.config, allowRawTranscriptReseed, rawTranscriptReseedReason, @@ -1699,7 +1700,7 @@ export async function prepareCliRunContext( const { sessionAgentId: contextEngineSessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: contextEngineConfig, - agentId: params.agentId, + agentId: sessionAgentId, }); // Context remains session-owned. Trusted helper runs may borrow a different // agentDir only for model/auth execution. @@ -1743,7 +1744,7 @@ export async function prepareCliRunContext( sessionId: params.sessionId, sessionFile: params.sessionFile, sessionKey: params.sessionKey, - agentId: params.agentId, + agentId: sessionAgentId, config: contextEngineConfig, }); const contextEngineTurnPrompt = params.transcriptPrompt ?? params.prompt; diff --git a/src/agents/code-mode-bridge.ts b/src/agents/code-mode-bridge.ts index bcd193837e15..e4422f168fd9 100644 --- a/src/agents/code-mode-bridge.ts +++ b/src/agents/code-mode-bridge.ts @@ -288,6 +288,7 @@ async function runAgentSpawnBridge(params: { let existing = codeModeSwarmDeps.getSwarmRunByLaunchReplayKey( idempotencyKey, requesterSessionKey, + params.ctx.agentId, ); if (existing) { if (existing.swarmLaunchRequestFingerprint !== requestFingerprint) { @@ -300,8 +301,11 @@ async function runAgentSpawnBridge(params: { // Cold-start restore idempotently re-enqueues this durable launch before agentWait parks. codeModeSwarmDeps.initSubagentRegistry(); existing = - codeModeSwarmDeps.getSwarmRunByLaunchReplayKey(idempotencyKey, requesterSessionKey) ?? - existing; + codeModeSwarmDeps.getSwarmRunByLaunchReplayKey( + idempotencyKey, + requesterSessionKey, + params.ctx.agentId, + ) ?? existing; if (existing.swarmLaunchPending === true && !existing.queuedLaunch) { throw new ToolInputError("agents.run persisted launch reservation cannot be recovered."); } @@ -349,6 +353,8 @@ async function runAgentWaitBridge(params: { return await codeModeSwarmDeps.waitForCollectorCompletion({ runId: runId.trim(), currentSessionKeys: new Set([rawSessionKey, requesterSessionKey]), + currentAgentId: params.ctx.agentId, + config: params.ctx.runtimeConfig ?? params.ctx.config, signal: params.signal, }); } diff --git a/src/agents/code-mode-swarm.test.ts b/src/agents/code-mode-swarm.test.ts index b8d171c72ff5..1fb491d8e9aa 100644 --- a/src/agents/code-mode-swarm.test.ts +++ b/src/agents/code-mode-swarm.test.ts @@ -351,13 +351,17 @@ describe("Code Mode swarm host bridge", () => { 1, `${replayId}:bridge:1`, "global", + undefined, ); expect(getSwarmRunByLaunchReplayKey).toHaveBeenNthCalledWith( 2, `${replayId}:bridge:1`, "global", + undefined, ); expect(waitForCollectorCompletion).toHaveBeenCalledWith({ + config: globalAliasContext.config, + currentAgentId: undefined, runId: "collector-1", currentSessionKeys: new Set(["main", "global"]), signal: undefined, diff --git a/src/agents/command/explicit-session-key.ts b/src/agents/command/explicit-session-key.ts new file mode 100644 index 000000000000..1b4b382534dd --- /dev/null +++ b/src/agents/command/explicit-session-key.ts @@ -0,0 +1,36 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + classifySessionKeyShape, + isUnscopedSessionKeySentinel, + scopeLegacySessionKeyToAgent, +} from "../../routing/session-key.js"; +import { resolveSessionAgentIds } from "../agent-scope.js"; + +export function resolveExplicitAgentCommandSessionKey(params: { + rawExplicitSessionKey?: string; + agentIdOverride?: string; + shouldScopeDefaultAgentKey?: boolean; + cfg: OpenClawConfig; +}): string | undefined { + if ( + isUnscopedSessionKeySentinel(params.rawExplicitSessionKey) && + !params.agentIdOverride && + !params.shouldScopeDefaultAgentKey + ) { + return params.rawExplicitSessionKey; + } + const unscopedOwnerAgentId = + classifySessionKeyShape(params.rawExplicitSessionKey) === "legacy_or_alias" && + (params.agentIdOverride || params.shouldScopeDefaultAgentKey) + ? resolveSessionAgentIds({ + config: params.cfg, + agentId: params.agentIdOverride, + sessionKey: params.rawExplicitSessionKey, + }).sessionAgentId + : undefined; + return scopeLegacySessionKeyToAgent({ + agentId: unscopedOwnerAgentId ?? params.agentIdOverride, + sessionKey: params.rawExplicitSessionKey, + mainKey: params.cfg.session?.mainKey, + }); +} diff --git a/src/agents/command/prepare.session-key.test.ts b/src/agents/command/prepare.session-key.test.ts new file mode 100644 index 000000000000..1f9e25bedeae --- /dev/null +++ b/src/agents/command/prepare.session-key.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { AgentSelectionRequiredError } from "../agent-scope.js"; +import { resolveExplicitAgentCommandSessionKey } from "./explicit-session-key.js"; + +const fixedStoreConfig = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, +} satisfies OpenClawConfig; + +describe("explicit agent command session keys", () => { + it("scopes a bare key through its persisted fixed-store owner", () => { + expect( + resolveExplicitAgentCommandSessionKey({ + rawExplicitSessionKey: "incident-42", + shouldScopeDefaultAgentKey: true, + cfg: fixedStoreConfig, + }), + ).toBe("agent:ops:incident-42"); + }); + + it("rejects an explicit agent that conflicts with the persisted owner", () => { + expect(() => + resolveExplicitAgentCommandSessionKey({ + rawExplicitSessionKey: "incident-42", + agentIdOverride: "research", + shouldScopeDefaultAgentKey: true, + cfg: fixedStoreConfig, + }), + ).toThrow(AgentSelectionRequiredError); + }); + + it("fails closed when the persisted owner has retired", () => { + expect(() => + resolveExplicitAgentCommandSessionKey({ + rawExplicitSessionKey: "incident-42", + shouldScopeDefaultAgentKey: true, + cfg: { + ...fixedStoreConfig, + agents: { + ...fixedStoreConfig.agents, + defaults: { sessionStore: { agentId: "retired" } }, + }, + }, + }), + ).toThrow(AgentSelectionRequiredError); + }); +}); diff --git a/src/agents/command/prepare.ts b/src/agents/command/prepare.ts index f831a6452d7c..8215045de59d 100644 --- a/src/agents/command/prepare.ts +++ b/src/agents/command/prepare.ts @@ -8,7 +8,6 @@ import { normalizeVerboseLevel, } from "../../auto-reply/thinking.js"; import { formatCliCommand } from "../../cli/command-format.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveAgentExplicitRecipientSession } from "../../infra/outbound/agent-delivery.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { normalizePluginsConfig } from "../../plugins/config-state.js"; @@ -21,7 +20,6 @@ import { isUnscopedSessionKeySentinel, normalizeAgentId, resolveAgentIdFromSessionKey, - scopeLegacySessionKeyToAgent, } from "../../routing/session-key.js"; import type { RuntimeEnv } from "../../runtime.js"; import { @@ -34,7 +32,6 @@ import { resolveAgentRuntimeConfig } from "../agent-runtime-config.js"; import { listAgentIds, resolveAgentDir, - resolveDefaultAgentId, resolveSessionAgentId, resolveAgentWorkspaceDir, } from "../agent-scope.js"; @@ -52,6 +49,7 @@ import { prependInternalEventContext, resolveInternalEventTranscriptBody, } from "./attempt-execution.shared.js"; +import { resolveExplicitAgentCommandSessionKey } from "./explicit-session-key.js"; import { loadAcpManagerRuntime } from "./runtime-loaders.js"; import { createAgentCommandSessionWorkingCopy } from "./session-helpers.js"; import { resolveSession } from "./session.js"; @@ -87,28 +85,6 @@ export function normalizeExplicitOverrideInput(raw: string, kind: "provider" | " return trimmed; } -function resolveExplicitAgentCommandSessionKey(params: { - rawExplicitSessionKey?: string; - agentIdOverride?: string; - shouldScopeDefaultAgentKey?: boolean; - cfg: OpenClawConfig; -}): string | undefined { - if ( - isUnscopedSessionKeySentinel(params.rawExplicitSessionKey) && - !params.agentIdOverride && - !params.shouldScopeDefaultAgentKey - ) { - return params.rawExplicitSessionKey; - } - return scopeLegacySessionKeyToAgent({ - agentId: - params.agentIdOverride ?? - (params.shouldScopeDefaultAgentKey ? resolveDefaultAgentId(params.cfg) : undefined), - sessionKey: params.rawExplicitSessionKey, - mainKey: params.cfg.session?.mainKey, - }); -} - export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: RuntimeEnv) { const isRawModelRun = opts.modelRun === true || opts.promptMode === "none"; const message = opts.message ?? ""; diff --git a/src/agents/command/session.resolve-session-key.test.ts b/src/agents/command/session.resolve-session-key.test.ts index 8423186e6201..e4cc862205c0 100644 --- a/src/agents/command/session.resolve-session-key.test.ts +++ b/src/agents/command/session.resolve-session-key.test.ts @@ -1,11 +1,13 @@ // Covers cross-store session-key resolution for multi-agent session stores. import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { retainLegacyDefaultAgentId } from "../../config/legacy.default-agent-owner.js"; +import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js"; import type { SessionEntry } from "../../config/sessions/types.js"; const hoisted = vi.hoisted(() => ({ listSessionEntriesMock: vi.fn< - (scope?: { storePath?: string; clone?: boolean }) => Array<{ + (scope?: { agentId?: string; storePath?: string; clone?: boolean }) => Array<{ entry: SessionEntry; sessionKey: string; }> @@ -14,27 +16,31 @@ const hoisted = vi.hoisted(() => ({ })); vi.mock("../../config/sessions/session-accessor.js", () => ({ - listSessionEntriesCore: (scope?: { storePath?: string; clone?: boolean }) => + listSessionEntriesCore: (scope?: { agentId?: string; storePath?: string; clone?: boolean }) => hoisted.listSessionEntriesMock(scope), })); vi.mock("../../config/sessions/paths.js", () => ({ - resolveSessionStorePathCore: (_store?: string, params?: { agentId?: string }) => - `/stores/${params?.agentId ?? "main"}.json`, + resolveSessionStorePathCore: (store?: string, params?: { agentId?: string }) => + store + ? store.replace("{agentId}", params?.agentId ?? "main") + : `/stores/${params?.agentId ?? "main"}.json`, })); vi.mock("../../config/sessions/main-session.js", () => ({ + canonicalizeMainSessionAlias: ({ sessionKey }: { sessionKey: string }) => sessionKey, resolveAgentIdFromSessionKey: () => "main", resolveExplicitAgentSessionKey: () => undefined, })); -vi.mock("../agent-scope.js", () => ({ +vi.mock("../agent-scope.js", async () => ({ + ...(await vi.importActual("../agent-scope.js")), listAgentIds: () => hoisted.listAgentIdsMock(), - resolveDefaultAgentId: () => "main", })); const { resolveSessionKeyForRequestCore, resolveStoredSessionKeyForSessionId } = await import("./session.js"); +const resolveSessionKeyForRequest = resolveSessionKeyForRequestCore; function mockSessionStores(storesByPath: Record>): void { hoisted.listSessionEntriesMock.mockImplementation((scope) => @@ -137,6 +143,413 @@ describe("resolveSessionKeyForRequest", () => { expect(hoisted.listSessionEntriesMock).toHaveBeenCalledTimes(1); }); + it("assigns unscoped shared-store rows to the persisted owner regardless of scan order", () => { + hoisted.listAgentIdsMock.mockReturnValue(["research", "ops"]); + const sharedStore = { + main: { sessionId: "ops-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + + const result = resolveSessionKeyForRequest({ + cfg: { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {}, ops: {} }, + }, + } satisfies OpenClawConfig, + sessionId: "ops-session", + }); + + expect(result.agentId).toBe("ops"); + expect(result.sessionKey).toBe("main"); + expect(result.sessionStore).toEqual(sharedStore); + expect(result.storePath).toBe("/stores/shared.sqlite"); + expect(hoisted.listSessionEntriesMock.mock.calls.map(([scope]) => scope?.agentId)).toEqual([ + "research", + "ops", + ]); + }); + + it("uses the persisted fixed-store owner for direct session-id lookup", () => { + hoisted.listAgentIdsMock.mockReturnValue(["research", "ops"]); + const sharedStore = { + main: { sessionId: "ops-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + + expect( + resolveStoredSessionKeyForSessionId({ + cfg: { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {}, ops: {} }, + }, + }, + sessionId: "ops-session", + }), + ).toMatchObject({ + agentId: "ops", + sessionKey: "main", + sessionStore: sharedStore, + storePath: "/stores/shared.sqlite", + }); + }); + + it("rejects an explicit agent that conflicts with an unscoped direct session-id match", () => { + const sharedStore = { + main: { sessionId: "ops-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {}, ops: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => + resolveStoredSessionKeyForSessionId({ + cfg, + sessionId: "ops-session", + agentId: "research", + }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + }); + + it("prefers the requested agent's scoped direct match over a newer foreign row", () => { + const sharedStore = { + "agent:ops:work": { sessionId: "shared-session", updatedAt: 20 }, + "agent:research:work": { sessionId: "shared-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + + expect( + resolveStoredSessionKeyForSessionId({ + cfg: { + session: { store: "/stores/shared.sqlite" }, + agents: { ownership: "explicit", entries: { research: {}, ops: {} } }, + }, + sessionId: "shared-session", + agentId: "research", + }), + ).toMatchObject({ + agentId: "research", + sessionKey: "agent:research:work", + sessionStore: sharedStore, + storePath: "/stores/shared.sqlite", + }); + }); + + it("rejects a direct session-id lookup with only foreign scoped matches", () => { + mockSessionStores({ + "/stores/shared.sqlite": { + "agent:ops:work": { sessionId: "ops-session", updatedAt: 20 }, + }, + }); + + expect(() => + resolveStoredSessionKeyForSessionId({ + cfg: { + session: { store: "/stores/shared.sqlite" }, + agents: { ownership: "explicit", entries: { research: {}, ops: {} } }, + }, + sessionId: "ops-session", + agentId: "research", + }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + }); + + it("resolves a scoped direct session-id match despite a retired fixed-store owner", () => { + const researchStore = { + "agent:research:work": { sessionId: "research-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": researchStore }); + + expect( + resolveStoredSessionKeyForSessionId({ + cfg: { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {} }, + }, + }, + sessionId: "research-session", + }), + ).toMatchObject({ + agentId: "research", + sessionKey: "agent:research:work", + sessionStore: researchStore, + storePath: "/stores/shared.sqlite", + }); + }); + + it("does not reassign a retired sole agent's unscoped row to its replacement", () => { + hoisted.listAgentIdsMock.mockReturnValue(["research"]); + mockSessionStores({ + "/stores/shared.sqlite": { + main: { sessionId: "retired-session", updatedAt: 10 }, + }, + }); + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => resolveSessionKeyForRequest({ cfg, sessionId: "retired-session" })).toThrowError( + expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" }), + ); + }); + + it("persists a legacy main fixed-store owner and fails closed after main is removed", () => { + hoisted.listAgentIdsMock.mockReturnValue(["research"]); + const sharedStore = { + main: { sessionId: "legacy-main-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + const migrated = migratePersistedImplicitMainRoster({ + session: { store: "/stores/shared.sqlite" }, + agents: { entries: { main: { default: true }, research: {} } }, + }).config as OpenClawConfig; + expect(migrated.agents?.defaults?.sessionStore?.agentId).toBe("main"); + const afterMainRemoval = { + ...migrated, + agents: { + ...migrated.agents, + ownership: "explicit" as const, + entries: { research: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => + resolveSessionKeyForRequest({ cfg: afterMainRemoval, sessionId: "legacy-main-session" }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + }); + + it("resolves an unscoped fixed-store row while its persisted owner is configured", () => { + hoisted.listAgentIdsMock.mockReturnValue(["ops"]); + const sharedStore = { + main: { sessionId: "ops-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + + const result = resolveSessionKeyForRequest({ + cfg: { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {} }, + }, + } satisfies OpenClawConfig, + sessionId: "ops-session", + }); + + expect(result.agentId).toBe("ops"); + expect(result.sessionKey).toBe("main"); + }); + + it("rejects an explicit agent that conflicts with an unscoped fixed-store owner", () => { + hoisted.listAgentIdsMock.mockReturnValue(["ops", "research"]); + const cfg = { + session: { scope: "global", store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => + resolveSessionKeyForRequest({ cfg, agentId: "research", sessionKey: "global" }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + expect(hoisted.listSessionEntriesMock).not.toHaveBeenCalled(); + + mockSessionStores({ "/stores/shared.sqlite": {} }); + expect( + resolveSessionKeyForRequest({ cfg, agentId: "ops", sessionKey: "global" }), + ).toMatchObject({ + agentId: "ops", + sessionKey: "global", + storePath: "/stores/shared.sqlite", + }); + }); + + it("fails closed for an ownerless unscoped row during a cross-agent shared-store scan", () => { + hoisted.listAgentIdsMock.mockReturnValue(["research", "ops"]); + const sharedStore = { + main: { sessionId: "ownerless-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + entries: { research: {}, ops: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => resolveSessionKeyForRequest({ cfg, sessionId: "ownerless-session" })).toThrowError( + expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" }), + ); + expect(hoisted.listSessionEntriesMock.mock.calls.map(([scope]) => scope?.agentId)).toEqual([ + "research", + "ops", + ]); + }); + + it("does not assign an unowned bare key from a session-id scan anchor", () => { + hoisted.listAgentIdsMock.mockReturnValue(["ops", "research"]); + mockSessionStores({ "/stores/ops.json": {}, "/stores/research.json": {} }); + const cfg = { + session: { store: "/stores/{agentId}.json" }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => + resolveSessionKeyForRequest({ cfg, sessionKey: "global", sessionId: "missing-session" }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + }); + + it("resolves an unowned bare key's session id to the matching agent store", () => { + hoisted.listAgentIdsMock.mockReturnValue(["ops", "research"]); + const researchStore = { + "agent:research:work": { sessionId: "research-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ + "/stores/ops.json": {}, + "/stores/research.json": researchStore, + }); + const cfg = { + session: { store: "/stores/{agentId}.json" }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect( + resolveSessionKeyForRequest({ + cfg, + sessionKey: "global", + sessionId: "research-session", + }), + ).toMatchObject({ + agentId: "research", + sessionKey: "agent:research:work", + sessionStore: researchStore, + storePath: "/stores/research.json", + }); + }); + + it("allows an agent-constrained lookup to own an unscoped shared-store row", () => { + hoisted.listAgentIdsMock.mockReturnValue(["research", "ops"]); + const sharedStore = { + main: { sessionId: "ops-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + + const result = resolveSessionKeyForRequest({ + cfg: { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + entries: { research: {}, ops: {} }, + }, + } satisfies OpenClawConfig, + sessionId: "ops-session", + agentId: "ops", + }); + + expect(result.agentId).toBe("ops"); + expect(result.sessionKey).toBe("main"); + expect(result.sessionStore).toEqual(sharedStore); + expect(result.storePath).toBe("/stores/shared.sqlite"); + expect(hoisted.listSessionEntriesMock).toHaveBeenCalledTimes(1); + expect(hoisted.listSessionEntriesMock).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "ops" }), + ); + }); + + it("rejects an agent-constrained session id owned by another agent", () => { + hoisted.listAgentIdsMock.mockReturnValue(["ops", "research"]); + const sharedStore = { + "agent:research:work": { sessionId: "duplicate-session", updatedAt: 20 }, + "agent:ops:work": { sessionId: "duplicate-session", updatedAt: 10 }, + } satisfies Record; + mockSessionStores({ "/stores/shared.sqlite": sharedStore }); + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect( + resolveSessionKeyForRequest({ cfg, agentId: "ops", sessionId: "duplicate-session" }), + ).toMatchObject({ agentId: "ops", sessionKey: "agent:ops:work" }); + mockSessionStores({ + "/stores/shared.sqlite": { + "agent:research:work": sharedStore["agent:research:work"], + }, + }); + expect(() => + resolveSessionKeyForRequest({ cfg, agentId: "ops", sessionId: "duplicate-session" }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + }); + + it("creates a missing session-id target under the retained owner", () => { + hoisted.listAgentIdsMock.mockReturnValue(["ops", "research"]); + mockSessionStores({}); + const cfg = retainLegacyDefaultAgentId( + { + session: { store: "/stores/{agentId}.json" }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }, + "ops", + ); + + const result = resolveSessionKeyForRequest({ cfg, sessionId: "new-session" }); + + expect(result.agentId).toBe("ops"); + expect(result.sessionKey).toBe("agent:ops:explicit:new-session"); + }); + + it("fails closed when creating a session-id target in an ownerless fleet", () => { + hoisted.listAgentIdsMock.mockReturnValue(["ops", "research"]); + mockSessionStores({}); + const cfg = { + session: { store: "/stores/{agentId}.json" }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => resolveSessionKeyForRequest({ cfg, sessionId: "new-session" })).toThrowError( + expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" }), + ); + }); + it("borrows session stores when requested", () => { // clone=false is used by callers that intend to mutate the selected store, // so the resolver must pass that option through every candidate load. diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index 7b867d674ef0..aa6be1c11a2e 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -2,6 +2,7 @@ * Resolves command session ids, keys, stores, and persisted thinking state. */ import crypto from "node:crypto"; +import path from "node:path"; import type { MsgContext } from "../../auto-reply/templating.js"; import { normalizeThinkLevel, @@ -9,6 +10,7 @@ import { type ThinkLevel, type VerboseLevel, } from "../../auto-reply/thinking.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; import { hasProviderOwnedSession } from "../../config/sessions/entry-freshness.js"; import { hasTerminalMainSessionTranscriptNewerThanRegistrySync, @@ -27,6 +29,10 @@ import { import { resolveChannelResetConfig, resolveSessionResetType } from "../../config/sessions/reset.js"; import { listSessionEntriesCore } from "../../config/sessions/session-accessor.js"; import { resolveSessionKey } from "../../config/sessions/session-key.js"; +import { + resolvePersistedSessionStoreOwner, + resolvePersistedSessionStoreOwnerForKey, +} from "../../config/sessions/session-store-owner.js"; import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -34,11 +40,16 @@ import { isUnscopedSessionKeySentinel, normalizeAgentId, normalizeMainKey, + parseAgentSessionKey, } from "../../routing/session-key.js"; import { isModelSelectionLocked } from "../../sessions/model-overrides.js"; import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js"; import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; -import { listAgentIds, resolveDefaultAgentId } from "../agent-scope.js"; +import { + AgentSelectionRequiredError, + listAgentIds, + resolveDefaultAgentId, +} from "../agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js"; import { clearAllCliSessions } from "../cli-session.js"; import { transitionMainSessionRecovery } from "../main-session-recovery/main-session-recovery-state.js"; @@ -57,6 +68,7 @@ type SessionResolution = { }; type SessionKeyResolution = { + agentId?: string; sessionKey?: string; sessionStore: Record; storePath: string; @@ -101,11 +113,42 @@ export function clearRotatedSessionMetadata(entry: SessionEntry): SessionEntry { } type SessionIdMatchSet = { - matches: Array<[string, SessionEntry]>; - primaryStoreMatches: Array<[string, SessionEntry]>; - storeByKey: Map; + candidates: SessionIdMatchCandidate[]; + ownerConflict: boolean; }; +type SessionIdMatchCandidate = { + sessionKey: string; + entry: SessionEntry; + resolution: SessionKeyResolution; + primary: boolean; +}; + +function selectSessionIdMatchCandidate( + candidates: SessionIdMatchCandidate[], + sessionId: string, +): SessionIdMatchCandidate | undefined { + const selection = resolveSessionIdMatchSelection( + candidates.map((candidate) => [candidate.sessionKey, candidate.entry]), + sessionId, + ); + if (selection.kind !== "selected") { + return undefined; + } + return candidates + .filter((candidate) => candidate.sessionKey === selection.sessionKey) + .toSorted((left, right) => { + const updatedAt = (right.entry.updatedAt ?? 0) - (left.entry.updatedAt ?? 0); + if (updatedAt !== 0) { + return updatedAt; + } + if (left.primary !== right.primary) { + return left.primary ? -1 : 1; + } + return (left.resolution.agentId ?? "").localeCompare(right.resolution.agentId ?? ""); + })[0]; +} + function loadCommandSessionStore(params: { agentId?: string; clone?: boolean; @@ -137,37 +180,96 @@ function collectSessionIdMatchesForRequest(opts: { searchOtherAgentStores: boolean; clone?: boolean; }): SessionIdMatchSet { - const matches: Array<[string, SessionEntry]> = []; - const primaryStoreMatches: Array<[string, SessionEntry]> = []; - const storeByKey = new Map(); + const candidates: SessionIdMatchCandidate[] = []; + let ownerConflict = false; + const configuredAgentIds = listAgentIds(opts.cfg).map(normalizeAgentId); + const compatibilityAgentId = tryResolveLegacyCompatibilityAgentId(opts.cfg); + const persistedStoreOwner = resolvePersistedSessionStoreOwner(opts.cfg); + const configuredStoreOwners = new Map>(); + for (const agentId of configuredAgentIds) { + const configuredStorePath = path.resolve( + resolveSessionStorePathCore(opts.cfg.session?.store, { agentId }), + ); + const owners = configuredStoreOwners.get(configuredStorePath) ?? new Set(); + owners.add(agentId); + configuredStoreOwners.set(configuredStorePath, owners); + } const addMatches = ( candidateStore: Record, candidateStorePath: string, + candidateAgentId: string | undefined, options?: { primary?: boolean }, ): void => { for (const [candidateKey, candidateEntry] of Object.entries(candidateStore)) { if (candidateEntry?.sessionId !== opts.sessionId) { continue; } - matches.push([candidateKey, candidateEntry]); - if (options?.primary) { - primaryStoreMatches.push([candidateKey, candidateEntry]); + const normalizedCandidateAgentId = candidateAgentId + ? normalizeAgentId(candidateAgentId) + : undefined; + const scopedCandidateAgentId = + normalizedCandidateAgentId && configuredAgentIds.includes(normalizedCandidateAgentId) + ? normalizedCandidateAgentId + : undefined; + const pathOwners = configuredStoreOwners.get(path.resolve(candidateStorePath)); + const pathOwnedAgentId = + pathOwners?.size === 1 ? pathOwners.values().next().value : undefined; + const parsedAgentId = parseAgentSessionKey(candidateKey)?.agentId; + const normalizedParsedAgentId = parsedAgentId ? normalizeAgentId(parsedAgentId) : undefined; + if (normalizedParsedAgentId && !configuredAgentIds.includes(normalizedParsedAgentId)) { + continue; } - storeByKey.set(candidateKey, { + const isLegacyUnscopedKey = classifySessionKeyShape(candidateKey) === "legacy_or_alias"; + // A persisted fixed-store owner is authoritative even after retirement: retired rows stay + // unavailable instead of being reassigned by path cardinality or scan order. + const legacyUnscopedOwner = isLegacyUnscopedKey + ? persistedStoreOwner.kind === "configured" + ? persistedStoreOwner.agentId + : persistedStoreOwner.kind === "retired" + ? undefined + : (pathOwnedAgentId ?? + (opts.searchOtherAgentStores ? undefined : scopedCandidateAgentId) ?? + compatibilityAgentId) + : undefined; + const matchedAgentId = + normalizedParsedAgentId ?? + (isLegacyUnscopedKey + ? legacyUnscopedOwner + : (scopedCandidateAgentId ?? compatibilityAgentId)); + if (isLegacyUnscopedKey && persistedStoreOwner.kind === "retired") { + ownerConflict = true; + continue; + } + if ( + !opts.searchOtherAgentStores && + scopedCandidateAgentId && + matchedAgentId && + normalizeAgentId(matchedAgentId) !== scopedCandidateAgentId + ) { + ownerConflict = true; + continue; + } + candidates.push({ sessionKey: candidateKey, - sessionStore: candidateStore, - storePath: candidateStorePath, + entry: candidateEntry, + primary: options?.primary === true, + resolution: { + ...(matchedAgentId ? { agentId: normalizeAgentId(matchedAgentId) } : {}), + sessionKey: candidateKey, + sessionStore: candidateStore, + storePath: candidateStorePath, + }, }); } }; - addMatches(opts.sessionStore, opts.storePath, { primary: true }); + addMatches(opts.sessionStore, opts.storePath, opts.storeAgentId, { primary: true }); if (!opts.searchOtherAgentStores) { - return { matches, primaryStoreMatches, storeByKey }; + return { candidates, ownerConflict }; } - for (const agentId of listAgentIds(opts.cfg)) { + for (const agentId of configuredAgentIds) { if (agentId === opts.storeAgentId) { continue; } @@ -179,10 +281,11 @@ function collectSessionIdMatchesForRequest(opts: { ...(opts.clone === false ? { clone: false } : {}), }), candidateStorePath, + agentId, ); } - return { matches, primaryStoreMatches, storeByKey }; + return { candidates, ownerConflict }; } /** @@ -196,9 +299,16 @@ export function resolveStoredSessionKeyForSessionId(opts: { agentId?: string; }): SessionKeyResolution { const sessionId = opts.sessionId.trim(); - const storeAgentId = opts.agentId?.trim() - ? normalizeAgentId(opts.agentId) - : resolveDefaultAgentId(opts.cfg); + const requestedAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined; + const persistedStoreOwner = resolvePersistedSessionStoreOwner(opts.cfg); + const storeAgentId = + requestedAgentId ?? + (persistedStoreOwner.kind === "configured" ? persistedStoreOwner.agentId : undefined) ?? + tryResolveLegacyCompatibilityAgentId(opts.cfg) ?? + resolveDefaultAgentId(opts.cfg, { + surface: "stored session lookup", + hint: "Pass an explicit agent id when looking up a session by id.", + }); const storePath = resolveSessionStorePathCore(opts.cfg.session?.store, { agentId: storeAgentId, }); @@ -210,30 +320,75 @@ export function resolveStoredSessionKeyForSessionId(opts: { return { sessionKey: undefined, sessionStore, storePath }; } - const selection = resolveSessionIdMatchSelection( - Object.entries(sessionStore).filter(([, entry]) => entry?.sessionId === sessionId), - sessionId, + const resolveMatchedAgentId = (sessionKey: string): string | undefined => { + const scopedAgentId = parseAgentSessionKey(sessionKey)?.agentId; + if (scopedAgentId) { + return normalizeAgentId(scopedAgentId); + } + const persistedRowOwner = resolvePersistedSessionStoreOwnerForKey(opts.cfg, sessionKey); + return persistedRowOwner.kind === "configured" + ? persistedRowOwner.agentId + : persistedRowOwner.kind === "retired" + ? undefined + : (requestedAgentId ?? tryResolveLegacyCompatibilityAgentId(opts.cfg)); + }; + const sessionIdMatches = Object.entries(sessionStore).filter( + ([, entry]) => entry?.sessionId === sessionId, ); + const selectionMatches = requestedAgentId + ? sessionIdMatches.filter( + ([sessionKey]) => resolveMatchedAgentId(sessionKey) === requestedAgentId, + ) + : sessionIdMatches; + if (requestedAgentId && selectionMatches.length === 0 && sessionIdMatches.length > 0) { + throw new AgentSelectionRequiredError(listAgentIds(opts.cfg), { + surface: `stored session id "${sessionId}"`, + hint: `The matching rows belong to a different agent than agent "${requestedAgentId}".`, + }); + } + const selection = resolveSessionIdMatchSelection(selectionMatches, sessionId); + if (selection.kind !== "selected") { + return { agentId: requestedAgentId, sessionKey: undefined, sessionStore, storePath }; + } + + const sessionKey = selection.sessionKey; + const persistedRowOwner = resolvePersistedSessionStoreOwnerForKey(opts.cfg, sessionKey); + const resolvedAgentId = resolveMatchedAgentId(sessionKey); + if (!resolvedAgentId) { + throw new AgentSelectionRequiredError(listAgentIds(opts.cfg), { + surface: `stored session key "${sessionKey}"`, + hint: + persistedRowOwner.kind === "retired" + ? `The shared fixed-store row belongs to retired agent "${persistedRowOwner.agentId}".` + : "Pass an explicit agent id when looking up an unscoped session by id.", + }); + } + if (requestedAgentId && requestedAgentId !== resolvedAgentId) { + throw new AgentSelectionRequiredError(listAgentIds(opts.cfg), { + surface: `stored session key "${sessionKey}"`, + hint: `The matching row belongs to agent "${resolvedAgentId}", not agent "${requestedAgentId}".`, + }); + } return { - sessionKey: selection.kind === "selected" ? selection.sessionKey : undefined, + agentId: resolvedAgentId, + sessionKey, sessionStore, storePath, }; } -/** Resolves the session key/store targeted by one command request. */ -export function resolveSessionKeyForRequestCore(opts: { +function resolveSessionKeyForRequestInternal(opts: { cfg: OpenClawConfig; to?: string; sessionId?: string; sessionKey?: string; agentId?: string; clone?: boolean; + createMissingSessionId: boolean; }): SessionKeyResolution { const sessionCfg = opts.cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; const mainKey = normalizeMainKey(sessionCfg?.mainKey); - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(opts.cfg)); const requestedAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined; const requestedSessionId = opts.sessionId?.trim() || undefined; const requestedSessionKey = opts.sessionKey?.trim() || undefined; @@ -250,11 +405,66 @@ export function resolveSessionKeyForRequestCore(opts: { agentId: requestedAgentId, }) : undefined); + const scopedSessionAgentId = parseAgentSessionKey(explicitSessionKey)?.agentId; + const explicitKeyStoreOwner = resolvePersistedSessionStoreOwnerForKey( + opts.cfg, + explicitSessionKey, + ); + if ( + explicitKeyStoreOwner.kind === "configured" && + requestedAgentId && + requestedAgentId !== explicitKeyStoreOwner.agentId + ) { + throw new AgentSelectionRequiredError(listAgentIds(opts.cfg), { + surface: `session key "${explicitSessionKey}"`, + hint: `The shared fixed-store row belongs to agent "${explicitKeyStoreOwner.agentId}", not --agent "${requestedAgentId}".`, + }); + } + if (explicitKeyStoreOwner.kind === "retired") { + throw new AgentSelectionRequiredError(listAgentIds(opts.cfg), { + surface: `session key "${explicitSessionKey}"`, + hint: `The shared fixed-store row belongs to retired agent "${explicitKeyStoreOwner.agentId}".`, + }); + } + const knownAgentId = + requestedAgentId ?? + scopedSessionAgentId ?? + (explicitKeyStoreOwner.kind === "configured" ? explicitKeyStoreOwner.agentId : undefined) ?? + tryResolveLegacyCompatibilityAgentId(opts.cfg); + const unownedBareSessionKey = Boolean( + requestedSessionId && + explicitSessionKey && + classifySessionKeyShape(explicitSessionKey) === "legacy_or_alias" && + !knownAgentId, + ); + // A session id is already an explicit target: seed only its store scan from a live roster owner. + // The anchor is not resolved ownership and must never escape through the returned resolution. + const sessionIdScanAnchor = requestedSessionId + ? (knownAgentId ?? listAgentIds(opts.cfg)[0]) + : undefined; + const defaultAgentId = knownAgentId + ? normalizeAgentId(knownAgentId) + : requestedSessionId + ? undefined + : normalizeAgentId( + resolveDefaultAgentId(opts.cfg, { + surface: "agent command session routing", + hint: "Pass --agent or an agent-prefixed --session-key.", + }), + ); const storeAgentId = explicitSessionKey - ? isUnscopedSessionKeySentinel(explicitSessionKey) - ? (requestedAgentId ?? defaultAgentId) - : resolveAgentIdFromSessionKey(explicitSessionKey, defaultAgentId) - : (requestedAgentId ?? defaultAgentId); + ? unownedBareSessionKey + ? sessionIdScanAnchor + : isUnscopedSessionKeySentinel(explicitSessionKey) + ? (requestedAgentId ?? defaultAgentId) + : resolveAgentIdFromSessionKey(explicitSessionKey, defaultAgentId) + : (requestedAgentId ?? defaultAgentId ?? sessionIdScanAnchor); + if (!storeAgentId) { + throw new AgentSelectionRequiredError(listAgentIds(opts.cfg), { + surface: "agent command session routing", + hint: "Pass --agent or an agent-prefixed --session-key.", + }); + } const storePath = resolveSessionStorePathCore(sessionCfg?.store, { agentId: storeAgentId, }); @@ -267,13 +477,16 @@ export function resolveSessionKeyForRequestCore(opts: { const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined; let sessionKey: string | undefined = - (explicitSessionKey + (!unownedBareSessionKey && explicitSessionKey ? canonicalizeMainSessionAlias({ cfg: opts.cfg, agentId: storeAgentId, sessionKey: explicitSessionKey, }) - : undefined) ?? (ctx ? resolveSessionKey(scope, ctx, mainKey, storeAgentId) : undefined); + : undefined) ?? + (!unownedBareSessionKey && ctx + ? resolveSessionKey(scope, ctx, mainKey, storeAgentId) + : undefined); // Entrypoint migration owners canonicalize legacy state before runtime reads. A missing target // row is not evidence that another agent's main session belongs to the configured default agent. @@ -284,10 +497,10 @@ export function resolveSessionKeyForRequestCore(opts: { // first. if ( requestedSessionId && - !explicitSessionKey && + (!explicitSessionKey || unownedBareSessionKey) && (!sessionKey || sessionStore[sessionKey]?.sessionId !== requestedSessionId) ) { - const { matches, primaryStoreMatches, storeByKey } = collectSessionIdMatchesForRequest({ + const { candidates, ownerConflict } = collectSessionIdMatchesForRequest({ cfg: opts.cfg, sessionStore, storePath, @@ -296,28 +509,73 @@ export function resolveSessionKeyForRequestCore(opts: { searchOtherAgentStores: requestedAgentId === undefined, ...(opts.clone === false ? { clone: false } : {}), }); - const preferredSelection = resolveSessionIdMatchSelection(matches, requestedSessionId); - const currentStoreSelection = - preferredSelection.kind === "selected" - ? preferredSelection - : resolveSessionIdMatchSelection(primaryStoreMatches, requestedSessionId); - if (currentStoreSelection.kind === "selected") { - const preferred = storeByKey.get(currentStoreSelection.sessionKey); - if (preferred) { - return preferred; - } - sessionKey = currentStoreSelection.sessionKey; + const selectedMatch = selectSessionIdMatchCandidate( + candidates.filter((candidate) => candidate.resolution.agentId !== undefined), + requestedSessionId, + ); + if (selectedMatch) { + return selectedMatch.resolution; + } + if (ownerConflict) { + throw new AgentSelectionRequiredError(listAgentIds(opts.cfg), { + surface: `session id "${requestedSessionId}"`, + hint: requestedAgentId + ? `The matching session belongs to a different agent than --agent "${requestedAgentId}".` + : "The matching unscoped session belongs to a retired fixed-store owner.", + }); } } - if (requestedSessionId && !sessionKey) { + if (requestedSessionId && !sessionKey && opts.createMissingSessionId) { + const explicitSessionAgentId = + requestedAgentId ?? + tryResolveLegacyCompatibilityAgentId(opts.cfg) ?? + resolveDefaultAgentId(opts.cfg, { + surface: "agent command session creation", + hint: "Pass --agent when creating a session from --session-id.", + }); sessionKey = buildExplicitSessionIdSessionKey({ sessionId: requestedSessionId, - agentId: opts.agentId, + agentId: explicitSessionAgentId, }); + return { + agentId: explicitSessionAgentId, + sessionKey, + sessionStore, + storePath, + }; } - return { sessionKey, sessionStore, storePath }; + return { agentId: storeAgentId, sessionKey, sessionStore, storePath }; +} + +/** Resolves an existing session-id row across agent stores without creating a fallback key. */ +export function resolveExistingSessionKeyForRequest(opts: { + cfg: OpenClawConfig; + sessionId: string; + agentId?: string; + clone?: boolean; +}): SessionKeyResolution { + return resolveSessionKeyForRequestInternal({ ...opts, createMissingSessionId: false }); +} + +/** Resolves the session key/store targeted by one command request. */ +function resolveSessionKeyForRequest(opts: { + cfg: OpenClawConfig; + to?: string; + sessionId?: string; + sessionKey?: string; + agentId?: string; + clone?: boolean; +}): SessionKeyResolution { + return resolveSessionKeyForRequestInternal({ ...opts, createMissingSessionId: true }); +} + +/** Core alias retained for runtime owners that bypass the public library facade. */ +export function resolveSessionKeyForRequestCore( + opts: Parameters[0], +): SessionKeyResolution { + return resolveSessionKeyForRequest(opts); } /** Resolves or creates the session used by one agent command request. */ @@ -330,7 +588,12 @@ export function resolveSession(opts: { clone?: boolean; }): SessionResolution { const sessionCfg = opts.cfg.session; - const { sessionKey, sessionStore, storePath } = resolveSessionKeyForRequestCore({ + const { + agentId: resolvedAgentId, + sessionKey, + sessionStore, + storePath, + } = resolveSessionKeyForRequestCore({ cfg: opts.cfg, to: opts.to, sessionId: opts.sessionId, @@ -341,9 +604,15 @@ export function resolveSession(opts: { const now = Date.now(); const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined; - const sessionAgentId = opts.agentId?.trim() - ? normalizeAgentId(opts.agentId) - : resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(opts.cfg)); + const sessionAgentId = + (opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined) ?? + resolvedAgentId ?? + parseAgentSessionKey(sessionKey)?.agentId ?? + tryResolveLegacyCompatibilityAgentId(opts.cfg) ?? + resolveDefaultAgentId(opts.cfg, { + surface: "agent command session ownership", + hint: "Pass --agent or an agent-prefixed --session-key.", + }); const resetType = resolveSessionResetType({ sessionKey }); const channelReset = resolveChannelResetConfig({ diff --git a/src/agents/embedded-agent-runner.resolvesessionagentids.test.ts b/src/agents/embedded-agent-runner.resolvesessionagentids.test.ts index aac6284db954..ada031dbccd7 100644 --- a/src/agents/embedded-agent-runner.resolvesessionagentids.test.ts +++ b/src/agents/embedded-agent-runner.resolvesessionagentids.test.ts @@ -1,37 +1,140 @@ // Covers resolving the active agent id from session keys and explicit config. import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { AgentSelectionRequiredError } from "./agent-scope-config.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; describe("resolveSessionAgentIds", () => { const cfg = { agents: { - list: [{ id: "main" }, { id: "beta", default: true }], + entries: { main: {}, beta: {} }, }, } as OpenClawConfig; - it("falls back to the configured default when sessionKey is missing", () => { - const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ - config: cfg, - }); - expect(defaultAgentId).toBe("beta"); - expect(sessionAgentId).toBe("beta"); + it("requires an owner when sessionKey is missing", () => { + expect(() => resolveSessionAgentIds({ config: cfg })).toThrow(AgentSelectionRequiredError); }); - it("falls back to the configured default when sessionKey is non-agent", () => { - const { sessionAgentId } = resolveSessionAgentIds({ - sessionKey: "quietchat:slash:123", - config: cfg, - }); - expect(sessionAgentId).toBe("beta"); + it("requires an owner when sessionKey is non-agent", () => { + expect(() => + resolveSessionAgentIds({ sessionKey: "quietchat:slash:123", config: cfg }), + ).toThrow(AgentSelectionRequiredError); }); - it("falls back to the configured default for global sessions", () => { - const { sessionAgentId } = resolveSessionAgentIds({ - sessionKey: "global", - config: cfg, - }); - expect(sessionAgentId).toBe("beta"); + it("requires an owner for global sessions", () => { + expect(() => resolveSessionAgentIds({ sessionKey: "global", config: cfg })).toThrow( + AgentSelectionRequiredError, + ); + }); + + it("uses a configured persisted owner for a fixed-store global session", () => { + expect( + resolveSessionAgentIds({ + sessionKey: "global", + config: { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "beta" } }, + entries: { main: {}, beta: {} }, + }, + }, + }).sessionAgentId, + ).toBe("beta"); + }); + + it("rejects a retired fixed-store owner for a global session", () => { + expect(() => + resolveSessionAgentIds({ + sessionKey: "global", + config: { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "retired" } }, + entries: { main: {}, beta: {} }, + }, + }, + }), + ).toThrow(AgentSelectionRequiredError); + }); + + it("rejects an explicit agent that conflicts with a configured fixed-store owner", () => { + expect(() => + resolveSessionAgentIds({ + agentId: "main", + sessionKey: "global", + config: { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "beta" } }, + entries: { main: {}, beta: {} }, + }, + }, + }), + ).toThrow(AgentSelectionRequiredError); + }); + + it("rejects a fallback agent that conflicts with a configured fixed-store owner", () => { + expect(() => + resolveSessionAgentIds({ + fallbackAgentId: "main", + sessionKey: "global", + config: { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "beta" } }, + entries: { main: {}, beta: {} }, + }, + }, + }), + ).toThrow(AgentSelectionRequiredError); + }); + + it("rejects an explicit agent when the unscoped fixed-store owner retired", () => { + expect(() => + resolveSessionAgentIds({ + agentId: "beta", + sessionKey: "global", + config: { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "retired" } }, + entries: { main: {}, beta: {} }, + }, + }, + }), + ).toThrow(AgentSelectionRequiredError); + }); + + it("keeps an agent-scoped key available when the fixed-store owner retired", () => { + expect( + resolveSessionAgentIds({ + agentId: "beta", + sessionKey: "agent:beta:main", + config: { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "retired" } }, + entries: { main: {}, beta: {} }, + }, + }, + }).sessionAgentId, + ).toBe("beta"); + }); + + it("rejects an explicit agent that conflicts with an agent-scoped key", () => { + expect(() => + resolveSessionAgentIds({ + agentId: "main", + sessionKey: "agent:beta:main", + config: cfg, + }), + ).toThrow(AgentSelectionRequiredError); }); it("keeps the agent id for provider-qualified agent sessions", () => { diff --git a/src/agents/embedded-agent-runner/compaction-checkpoint.ts b/src/agents/embedded-agent-runner/compaction-checkpoint.ts index 998ed957aa61..e683eda675a7 100644 --- a/src/agents/embedded-agent-runner/compaction-checkpoint.ts +++ b/src/agents/embedded-agent-runner/compaction-checkpoint.ts @@ -42,6 +42,7 @@ export async function persistCompactionCheckpoint(params: { }); const stored = await compactionCheckpointStore.persistCheckpoint({ cfg: params.config, + ...(params.sessionTarget?.agentId ? { agentId: params.sessionTarget.agentId } : {}), sessionKey: params.sessionKey, sessionId: params.sessionId, reason: resolveSessionCompactionCheckpointReason({ trigger: params.trigger }), diff --git a/src/agents/embedded-agent-runner/context-engine-maintenance.ts b/src/agents/embedded-agent-runner/context-engine-maintenance.ts index 2b80301d464a..018ca3cff0b3 100644 --- a/src/agents/embedded-agent-runner/context-engine-maintenance.ts +++ b/src/agents/embedded-agent-runner/context-engine-maintenance.ts @@ -382,11 +382,15 @@ async function runDeferredTurnMaintenanceWorker( const task = findTaskByRunIdForOwner({ runId: params.runId, callerOwnerKey: params.sessionKey, + callerAgentId: params.agentId, + config: params.config, }); if (task) { cancelTaskByIdForOwner({ taskId: task.taskId, callerOwnerKey: params.sessionKey, + callerAgentId: params.agentId, + config: params.config, endedAt: Date.now(), terminalSummary: "Deferred maintenance cancelled during shutdown.", }); @@ -454,11 +458,15 @@ function scheduleDeferredTurnMaintenance( updateTaskNotifyPolicyForOwner({ taskId: existingTask.taskId, callerOwnerKey: sessionKey, + callerAgentId: params.agentId, + config: params.config, notifyPolicy: "silent", }); cancelTaskByIdForOwner({ taskId: existingTask.taskId, callerOwnerKey: sessionKey, + callerAgentId: params.agentId, + config: params.config, endedAt: Date.now(), terminalSummary: "Superseded by refreshed deferred maintenance task.", }); @@ -486,6 +494,8 @@ function scheduleDeferredTurnMaintenance( cancelTaskByIdForOwner({ taskId: task.taskId, callerOwnerKey: sessionKey, + callerAgentId: params.agentId, + config: params.config, endedAt: Date.now(), terminalSummary: `Deferred maintenance could not be scheduled: ${errorMessage}`, }); diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index 246e930e9636..212b8066c231 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -3,6 +3,7 @@ import type { Model } from "../../llm/types.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { resolveDefaultAgentDir } from "../agent-scope.js"; import type { AuthProfileCredential } from "../auth-profiles/types.js"; +import { resolveLegacyInheritedAuthDir } from "../legacy-inherited-auth-dir.js"; import { resolveModelWorkspaceDir } from "../model-discovery-context.js"; import { modelKey } from "../model-ref-shared.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; @@ -94,7 +95,7 @@ function resolvePreparedAgentSnapshot( ...(agentId ? { agentId } : {}), agentDir: resolvedAgentDir, config: cfg ?? {}, - inheritedAuthDir: resolveDefaultAgentDir(cfg ?? {}), + inheritedAuthDir: resolveLegacyInheritedAuthDir(cfg ?? {}), }; const published = getPreparedModelRuntimeSnapshot({ ...base, @@ -215,7 +216,7 @@ export async function resolveModelAsync( ...(options?.agentId ? { agentId: options.agentId } : {}), agentDir: resolvedAgentDir, config: cfg ?? {}, - inheritedAuthDir: resolveDefaultAgentDir(cfg ?? {}), + inheritedAuthDir: resolveLegacyInheritedAuthDir(cfg ?? {}), ...(derivedWorkspaceDir ? { workspaceDir: derivedWorkspaceDir } : {}), }) : undefined); diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts index 56195f14eeed..f2e40f6f1930 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts @@ -171,6 +171,7 @@ describe("embedded run retry dispatch", () => { await expect(dispatchEmbeddedRunAttempt(input)).rejects.toBe(postCompactionAbortError); expect(mocks.settleRequesterAfterSessionSpawns).toHaveBeenCalledWith({ + requesterAgentId: "main", requesterSessionKey: "agent:main:session-1", requesterTurnRunId: "run-1", requesterYielded: yieldDetected, diff --git a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts index 3bc6bbf56bde..a3f86c65a121 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts @@ -114,6 +114,7 @@ export async function prepareEmbeddedAttemptBundleTools(params: { const bundleMcpRuntime = bundleMcpSessionRuntime ? await materializeBundleMcpToolsForRun({ runtime: bundleMcpSessionRuntime, + agentId: params.sessionAgentId, reservedToolNames: [ ...tools.map((tool) => tool.name), ...(clientTools?.map((tool) => tool.function.name) ?? []), diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts index 94bfb75c73f1..bec32879931e 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts @@ -554,6 +554,7 @@ describe("runEmbeddedAttemptSettledPhase", () => { expect(mocks.settleRequesterAfterSessionSpawns).toHaveBeenCalledWith({ requesterSessionKey: "agent:main", + requesterAgentId: "main", requesterTurnRunId: "run-1", requesterYielded: true, acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }], @@ -637,6 +638,7 @@ describe("runEmbeddedAttemptSettledPhase", () => { expect(mocks.settleRequesterAfterSessionSpawns).toHaveBeenCalledWith({ requesterSessionKey: "agent:main", + requesterAgentId: "main", requesterTurnRunId: "run-1", requesterYielded: false, acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }], diff --git a/src/agents/embedded-agent-runner/run/attempt-history.ts b/src/agents/embedded-agent-runner/run/attempt-history.ts index dd6e9e089853..d5963e9f91cb 100644 --- a/src/agents/embedded-agent-runner/run/attempt-history.ts +++ b/src/agents/embedded-agent-runner/run/attempt-history.ts @@ -463,12 +463,17 @@ export async function prepareEmbeddedAttemptHistory(input: { agentId: input.sessionAgentId, }); const sessionEntry = await loadAttemptSessionEntryAfterQuotaMaintenance({ + agentId: input.sessionAgentId, storePath, sessionKey: attempt.sessionKey, }); const suspension = sessionEntry?.quotaSuspension; if (sessionEntry && suspension?.state === "resuming") { - const subagents = listSessionEntriesReadOnly({ storePath, clone: false }) + const subagents = listSessionEntriesReadOnly({ + agentId: input.sessionAgentId, + storePath, + clone: false, + }) .map(({ entry }) => entry) .filter((entry) => entry.spawnedBy === sessionEntry.sessionId) .map((entry) => ({ @@ -483,7 +488,7 @@ export async function prepareEmbeddedAttemptHistory(input: { }), ); await updateSessionEntry( - { storePath, sessionKey: attempt.sessionKey }, + { agentId: input.sessionAgentId, storePath, sessionKey: attempt.sessionKey }, async (entry) => { if (entry.quotaSuspension?.state !== "resuming") { return null; @@ -503,6 +508,7 @@ export async function prepareEmbeddedAttemptHistory(input: { const activeSubagentPromptAddition = buildActiveSubagentSystemPromptAddition({ cfg: attempt.config, controllerSessionKey: attempt.sessionKey, + controllerAgentId: input.sessionAgentId, hasSessionsYield: input.capabilityToolNames.has("sessions_yield"), }); if (activeSubagentPromptAddition) { @@ -577,6 +583,7 @@ export async function prepareEmbeddedAttemptHistory(input: { contextEngine: input.activeContextEngine, sessionId: attempt.sessionId, sessionKey: attempt.sessionKey, + agentId: input.sessionAgentId, messages: activeSession.messages, tokenBudget: messageBudget, availableTools: new Set(input.capabilityToolNames), diff --git a/src/agents/embedded-agent-runner/run/attempt-normalization.test.ts b/src/agents/embedded-agent-runner/run/attempt-normalization.test.ts index 405e6e1a98fa..9180a3bd505c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-normalization.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-normalization.test.ts @@ -1,11 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { applyEmbeddedAttemptSessionIdentity } from "./attempt-session-identity.js"; -import { buildContextEngineCompactionSessionTarget } from "./session-bootstrap.js"; +import { loadAttemptSessionEntryAfterQuotaMaintenance } from "./attempt-transcript-helpers.js"; +import { + assertAgentHarnessRunAdmission, + buildContextEngineCompactionSessionTarget, + resetNoRealConversationTokenSnapshot, +} from "./session-bootstrap.js"; import { createEmbeddedRunSessionPromptState } from "./session-prompt-state.js"; const sessionAccessorMocks = vi.hoisted(() => ({ listSessionEntriesCore: vi.fn(() => []), loadSessionEntry: vi.fn(), + updateSessionEntry: vi.fn(async () => undefined), })); vi.mock("../../../config/sessions/session-accessor.js", () => sessionAccessorMocks); @@ -13,6 +19,7 @@ vi.mock("../../../config/sessions/session-accessor.js", () => sessionAccessorMoc beforeEach(() => { sessionAccessorMocks.listSessionEntriesCore.mockReset().mockReturnValue([]); sessionAccessorMocks.loadSessionEntry.mockReset(); + sessionAccessorMocks.updateSessionEntry.mockReset().mockResolvedValue(undefined); }); describe("buildContextEngineCompactionSessionTarget", () => { @@ -46,6 +53,51 @@ describe("buildContextEngineCompactionSessionTarget", () => { }); }); + it("uses the persisted fixed-store owner for a bare compaction key", () => { + expect( + buildContextEngineCompactionSessionTarget({ + config: { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: "/tmp/shared-sessions.json" }, + }, + sessionFile: "global", + sessionId: "ops-session", + sessionKey: "global", + }), + ).toMatchObject({ + agentId: "ops", + sessionKey: "global", + storePath: "/tmp/shared-sessions.json", + }); + }); + + it("rejects a partial target that conflicts with the fixed-store owner", () => { + expect(() => + buildContextEngineCompactionSessionTarget({ + config: { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: "/tmp/shared-sessions.json" }, + }, + sessionFile: "global", + sessionId: "ops-session", + sessionKey: "global", + sessionTarget: { + agentId: "research", + sessionId: "ops-session", + sessionKey: "global", + }, + }), + ).toThrow(/belongs to "ops"/u); + }); + it("preserves an adopted session id without inventing a session key", () => { expect( buildContextEngineCompactionSessionTarget({ @@ -65,6 +117,66 @@ describe("buildContextEngineCompactionSessionTarget", () => { }); }); +describe("fixed-store session bootstrap", () => { + const config = { + agents: { + ownership: "explicit" as const, + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: "/tmp/shared-sessions.json" }, + }; + + it("carries the persisted owner into token snapshot resets", async () => { + await resetNoRealConversationTokenSnapshot({ config, sessionKey: "global" }); + + expect(sessionAccessorMocks.updateSessionEntry).toHaveBeenCalledWith( + { + agentId: "ops", + sessionKey: "global", + storePath: "/tmp/shared-sessions.json", + }, + expect.any(Function), + expect.objectContaining({ skipMaintenance: true }), + ); + }); + + it("carries the persisted owner into harness admission", () => { + assertAgentHarnessRunAdmission({ + config, + sessionId: "ops-session", + sessionKey: "global", + } as never); + + expect(sessionAccessorMocks.loadSessionEntry).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "ops", + sessionKey: "global", + storePath: "/tmp/shared-sessions.json", + }), + ); + }); + + it("carries the resolved owner into quota-maintenance reads", async () => { + sessionAccessorMocks.loadSessionEntry.mockReturnValueOnce({ + sessionId: "ops-session", + updatedAt: 1, + }); + + await loadAttemptSessionEntryAfterQuotaMaintenance({ + agentId: "ops", + sessionKey: "global", + storePath: "/tmp/shared-sessions.json", + }); + + expect(sessionAccessorMocks.loadSessionEntry).toHaveBeenCalledWith({ + agentId: "ops", + sessionKey: "global", + storePath: "/tmp/shared-sessions.json", + }); + }); +}); + describe("createEmbeddedRunSessionPromptState", () => { it("keeps the admitted writer fence private across context-engine target adoption", () => { const state = createEmbeddedRunSessionPromptState({ diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-build.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-build.ts index e3752bd98bc1..d2b91b3878dd 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-build.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-build.ts @@ -220,6 +220,7 @@ export async function prepareEmbeddedAttemptPromptAssembly(input: { ? undefined : resolveAttemptMediaTaskSystemPromptAddition({ sessionKey: attempt.sessionKey, + agentId: input.sessionAgentId, trigger: attempt.trigger, }); if (mediaTaskSystemPromptAddition) { diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts index a45092a64fd4..3513f439f9c0 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts @@ -477,15 +477,16 @@ export function prependSystemPromptAddition(params: { // shifted the cacheable prefix turn-to-turn and broke prompt caching (#85203). export function resolveAttemptMediaTaskSystemPromptAddition(params: { sessionKey?: string; + agentId?: string; trigger?: EmbeddedRunAttemptParams["trigger"]; }): string | undefined { if (params.trigger !== "user" && params.trigger !== "manual") { return undefined; } return joinPresentTextSegments([ - buildActiveImageGenerationTaskPromptContextForSession(params.sessionKey), - buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey), - buildActiveMusicGenerationTaskPromptContextForSession(params.sessionKey), + buildActiveImageGenerationTaskPromptContextForSession(params.sessionKey, params.agentId), + buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey, params.agentId), + buildActiveMusicGenerationTaskPromptContextForSession(params.sessionKey, params.agentId), ]); } diff --git a/src/agents/embedded-agent-runner/run/attempt-settle.ts b/src/agents/embedded-agent-runner/run/attempt-settle.ts index 49ed91c0348e..787a7ad2b73a 100644 --- a/src/agents/embedded-agent-runner/run/attempt-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-settle.ts @@ -559,6 +559,7 @@ export async function runEmbeddedAttemptSettledPhase( if (attempt.sessionKey && result.acceptedSessionSpawns?.length) { settleRequesterAfterSessionSpawns({ requesterSessionKey: attempt.sessionKey, + requesterAgentId: input.setup.sessionAgentId, requesterTurnRunId: attempt.runId, requesterYielded: result.yieldDetected === true, acceptedSessionSpawns: result.acceptedSessionSpawns, diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts index 54016be4ee7d..38af49e3f476 100644 --- a/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts @@ -100,10 +100,12 @@ export function normalizeCompactionRecoveryTranscriptTail(params: { // Applies quota-resume TTL maintenance to only the active attempt session. export async function loadAttemptSessionEntryAfterQuotaMaintenance(params: { + agentId: string; storePath: string; sessionKey: string; }): Promise { const entry = loadSessionEntry({ + agentId: params.agentId, storePath: params.storePath, sessionKey: params.sessionKey, }); @@ -117,6 +119,7 @@ export async function loadAttemptSessionEntryAfterQuotaMaintenance(params: { } const updated = await updateSessionEntry( { + agentId: params.agentId, storePath: params.storePath, sessionKey: params.sessionKey, }, diff --git a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts index 075df8c6a6ec..427054df9b14 100644 --- a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts @@ -128,13 +128,13 @@ describe("resolveAttemptMediaTaskSystemPromptAddition", () => { expect( imageGenerationTaskStatusMocks.buildActiveImageGenerationTaskPromptContextForSession, - ).toHaveBeenCalledWith("agent:main:discord:direct:123"); + ).toHaveBeenCalledWith("agent:main:discord:direct:123", undefined); expect( videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession, - ).toHaveBeenCalledWith("agent:main:discord:direct:123"); + ).toHaveBeenCalledWith("agent:main:discord:direct:123", undefined); expect( musicGenerationTaskStatusMocks.buildActiveMusicGenerationTaskPromptContextForSession, - ).toHaveBeenCalledWith("agent:main:discord:direct:123"); + ).toHaveBeenCalledWith("agent:main:discord:direct:123", undefined); expect(result).toBe("Image task hint\n\nActive task hint\n\nMusic task hint"); }); diff --git a/src/agents/embedded-agent-runner/run/backend.ts b/src/agents/embedded-agent-runner/run/backend.ts index 3ad1f48ddb0b..a03f0a8156e8 100644 --- a/src/agents/embedded-agent-runner/run/backend.ts +++ b/src/agents/embedded-agent-runner/run/backend.ts @@ -25,6 +25,7 @@ export async function runEmbeddedAttemptWithBackend( // Settle before dispatch can replace the successful result with a late abort. settleRequesterAfterSessionSpawns({ requesterSessionKey: params.sessionKey, + requesterAgentId: params.agentId, requesterTurnRunId: params.runId, requesterYielded: result.yieldDetected === true, acceptedSessionSpawns: result.acceptedSessionSpawns, diff --git a/src/agents/embedded-agent-runner/run/session-bootstrap.ts b/src/agents/embedded-agent-runner/run/session-bootstrap.ts index ea4d8d78f335..dd9eaf38847e 100644 --- a/src/agents/embedded-agent-runner/run/session-bootstrap.ts +++ b/src/agents/embedded-agent-runner/run/session-bootstrap.ts @@ -11,17 +11,15 @@ import { loadSessionEntry, updateSessionEntry, } from "../../../config/sessions/session-accessor.js"; +import { resolvePersistedSessionStoreOwnerForTarget } from "../../../config/sessions/session-store-owner.js"; import type { InternalSessionEntry, SessionEntry } from "../../../config/sessions/types.js"; import type { ContextEngineSessionTarget } from "../../../context-engine/types.js"; import { emitAgentEventIfCurrent } from "../../../infra/agent-events.js"; import { getAgentRunContext } from "../../../infra/agent-run-registry.js"; import { formatErrorMessage } from "../../../infra/errors.js"; -import { - parseAgentSessionKey, - resolveAgentIdFromSessionKey, -} from "../../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../../routing/session-key.js"; import { resolvePreferredSessionKeyForSessionIdMatches } from "../../../sessions/session-id-resolution.js"; -import { resolveDefaultAgentId } from "../../agent-scope.js"; +import { resolveSessionAgentId } from "../../agent-scope.js"; import { resolveSessionKeyForRequestCore, resolveStoredSessionKeyForSessionId, @@ -102,11 +100,25 @@ export function buildContextEngineCompactionSessionTarget(params: { : marker ? markerSessionKey : (targetSessionKey ?? suppliedSessionKey); + const targetStoreOwner = resolvePersistedSessionStoreOwnerForTarget({ + config: params.config ?? {}, + sessionKey, + storePath: targetStorePath, + }); + const trustExplicitAlternateStoreAgent = Boolean( + targetAgentId && + targetStorePath && + !parseAgentSessionKey(sessionKey)?.agentId && + targetStoreOwner.kind === "none", + ); const agentId = - targetAgentId ?? + (trustExplicitAlternateStoreAgent ? targetAgentId : undefined) ?? marker?.agentId ?? - params.agentId ?? - resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(params.config ?? {})); + resolveSessionAgentId({ + agentId: targetAgentId ?? params.agentId, + config: params.config, + sessionKey, + }); const storePath = targetStorePath ?? marker?.storePath ?? @@ -142,12 +154,16 @@ export async function resetNoRealConversationTokenSnapshot(params: { if (!params.sessionKey) { return; } - const storePath = resolveSessionStorePathCore(params.config?.session?.store, { + const agentId = resolveSessionAgentId({ agentId: params.agentId, + config: params.config, + sessionKey: params.sessionKey, }); + const storePath = resolveSessionStorePathCore(params.config?.session?.store, { agentId }); try { await updateSessionEntry( { + agentId, storePath, sessionKey: params.sessionKey, }, @@ -224,9 +240,28 @@ export function assertAgentHarnessRunAdmission( if (!sessionKey) { return undefined; } - const admissionAgentId = params.agentId ?? resolveAgentIdFromSessionKey(sessionKey); + const targetAgentId = normalizeOptionalString(params.sessionTarget?.agentId); + const targetStorePath = normalizeOptionalString(params.sessionTarget?.storePath); + const targetStoreOwner = resolvePersistedSessionStoreOwnerForTarget({ + config: params.config ?? {}, + sessionKey, + storePath: targetStorePath, + }); + const trustExplicitAlternateStoreAgent = Boolean( + targetAgentId && + targetStorePath && + !parseAgentSessionKey(sessionKey)?.agentId && + targetStoreOwner.kind === "none", + ); + const admissionAgentId = trustExplicitAlternateStoreAgent + ? targetAgentId + : resolveSessionAgentId({ + agentId: targetAgentId ?? params.agentId, + config: params.config, + sessionKey, + }); const storePath = - normalizeOptionalString(params.sessionTarget?.storePath) ?? + targetStorePath ?? resolveSessionStorePathCore(params.config?.session?.store, { agentId: admissionAgentId }); const durableEntry = loadSessionEntry({ ...(admissionAgentId ? { agentId: admissionAgentId } : {}), diff --git a/src/agents/embedded-agent-runner/runs.force-clear-terminal.test.ts b/src/agents/embedded-agent-runner/runs.force-clear-terminal.test.ts index 844572953ab4..df535f2976a7 100644 --- a/src/agents/embedded-agent-runner/runs.force-clear-terminal.test.ts +++ b/src/agents/embedded-agent-runner/runs.force-clear-terminal.test.ts @@ -281,6 +281,42 @@ describe("force-clear terminal state persistence", () => { expect(entry?.runtimeMs).toBe(12_345); }); + it("persists a force-cleared bare row under its fixed-store owner", async () => { + storePath = testState?.statePath("shared-store.sqlite") ?? storePath; + const sessionKey = "global"; + const sessionId = "session-fixed-owner"; + const startedAt = Date.now() - 60_000; + setRuntimeConfigSnapshot({ + session: { store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }); + await upsertSessionEntryCore( + { agentId: "ops", sessionKey, storePath }, + { sessionId, updatedAt: startedAt, startedAt, status: "running" }, + ); + setActiveEmbeddedRun(sessionId, createRunHandle(), sessionKey); + + await expect( + abortAndDrainEmbeddedAgentRun({ + sessionId, + sessionKey, + forceClear: true, + reason: "stuck_recovery", + settleMs: 0, + }), + ).resolves.toMatchObject({ forceCleared: true }); + + expect(loadSessionEntry({ agentId: "ops", sessionKey, storePath })).toMatchObject({ + sessionId, + status: "killed", + abortedLastRun: true, + }); + }); + it("keeps the persisted killed state when the force-cleared owner finishes late", async () => { const sessionKey = "agent:main:force-clear-late-completion"; const sessionId = "session-force-clear-late-completion"; diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts index ac50d4d8a7cf..69792a02ad09 100644 --- a/src/agents/embedded-agent-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -40,7 +40,7 @@ import { } from "../../logging/diagnostic-run-activity.js"; import { logMessageQueuedWithBacklogPolicy } from "../../logging/diagnostic-runtime.js"; import { diagnosticLogger as diag, logSessionStateChange } from "../../logging/diagnostic.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { resolveSessionAgentId } from "../agent-scope.js"; import { ACTIVE_EMBEDDED_RUNS, ACTIVE_EMBEDDED_RUNS_BY_RUN_ID, @@ -967,6 +967,7 @@ export async function abortAndDrainEmbeddedAgentRun(params: { } type ForceClearSessionSnapshot = { + agentId: string; startedAt?: number; storePath: string; updatedAt: number; @@ -977,13 +978,14 @@ function tryLoadForceClearSessionSnapshot( ): ForceClearSessionSnapshot | undefined { try { const cfg = getRuntimeConfig(); - const agentId = resolveAgentIdFromSessionKey(sessionKey); + const agentId = resolveSessionAgentId({ config: cfg, sessionKey }); const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId }); - const entry = loadSessionEntry({ sessionKey, storePath }); + const entry = loadSessionEntry({ agentId, sessionKey, storePath }); if (!entry || entry.status !== "running") { return undefined; } return { + agentId, ...(entry.startedAt === undefined ? {} : { startedAt: entry.startedAt }), storePath, updatedAt: entry.updatedAt, @@ -998,6 +1000,7 @@ function tryLoadForceClearSessionSnapshot( /** Persists terminal state when a forced registry clear cannot emit normal lifecycle. */ async function persistForceClearedEmbeddedRunTerminalState(params: { + agentId: string; sessionId: string; sessionKey: string; startedAt?: number; @@ -1006,7 +1009,11 @@ async function persistForceClearedEmbeddedRunTerminalState(params: { }): Promise { try { await updateSessionEntry( - { sessionKey: params.sessionKey, storePath: params.storePath }, + { + agentId: params.agentId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }, (storedEntry) => { const entry = storedEntry as InternalSessionEntry; // A replacement can reuse the session id; bind this patch to both owners' exact snapshot. diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.start.ts b/src/agents/embedded-agent-subscribe.handlers.tools.start.ts index 4a7c1f100127..233a242a6dd9 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.start.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.start.ts @@ -50,6 +50,7 @@ function buildAskUserPromptPayload( toolCallId: string, sessionKey: string | undefined, runId: string, + agentId: string | undefined, args: unknown, ) { try { @@ -58,6 +59,7 @@ function buildAskUserPromptPayload( toolCallId, sessionKey, runId, + agentId, questions, timeoutSeconds, }); @@ -324,11 +326,22 @@ export function handleToolExecutionStart( ctx.state.liveEditDiffStateById.delete(evt.toolCallId); const askUserPromptReservation = startToolName === "ask_user" && ctx.params.onToolResult - ? buildAskUserPromptPayload(evt.toolCallId, ctx.params.sessionKey, ctx.params.runId, evt.args) + ? buildAskUserPromptPayload( + evt.toolCallId, + ctx.params.sessionKey, + ctx.params.runId, + ctx.params.agentId, + evt.args, + ) : undefined; const cancelAskUserPromptReservation = () => { if (askUserPromptReservation) { - cancelAskUserPromptDelivery(evt.toolCallId, ctx.params.sessionKey, ctx.params.runId); + cancelAskUserPromptDelivery( + evt.toolCallId, + ctx.params.sessionKey, + ctx.params.runId, + ctx.params.agentId, + ); } }; const continueAfterBlockReplyFlush = (): void | Promise => { diff --git a/src/agents/harness/compaction.ts b/src/agents/harness/compaction.ts index 38507d6aa199..1b57229dc665 100644 --- a/src/agents/harness/compaction.ts +++ b/src/agents/harness/compaction.ts @@ -186,7 +186,7 @@ async function resolveHarnessCompactApiKey(params: { }), ), config: compactParams.config, - agentId: params.agentId, + agentId: parseAgentSessionKey(params.sessionKey) ? undefined : params.agentId, sessionKey: params.sessionKey, agentHarnessId: params.pinnedHarnessId, }); diff --git a/src/agents/harness/context-engine-lifecycle.test.ts b/src/agents/harness/context-engine-lifecycle.test.ts index ef1bcb042c08..4daabc5532b3 100644 --- a/src/agents/harness/context-engine-lifecycle.test.ts +++ b/src/agents/harness/context-engine-lifecycle.test.ts @@ -154,7 +154,8 @@ describe("harness context engine lifecycle", () => { const result = await assembleHarnessContextEngine({ contextEngine: createContextEngine({ assemble }), sessionId: sessionParams.sessionId, - sessionKey: "agent:support:main", + sessionKey: "global", + agentId: "support", messages: [textMessage("user", "visible ask", 1)], availableTools, citationsMode: "on", @@ -168,7 +169,7 @@ describe("harness context engine lifecycle", () => { expect(prepare).toHaveBeenCalledWith( expect.objectContaining({ agentId: "support", - agentSessionKey: "agent:support:main", + agentSessionKey: "global", sandboxed: true, }), ); diff --git a/src/agents/harness/context-engine-lifecycle.ts b/src/agents/harness/context-engine-lifecycle.ts index 1d9ea267026a..027eb6bcf3be 100644 --- a/src/agents/harness/context-engine-lifecycle.ts +++ b/src/agents/harness/context-engine-lifecycle.ts @@ -164,6 +164,7 @@ export async function assembleHarnessContextEngine(params: { contextEngine?: HarnessContextEngine; sessionId: string; sessionKey?: string; + agentId?: string; messages: AgentMessage[]; tokenBudget?: number; availableTools?: Set; @@ -213,7 +214,7 @@ export async function assembleHarnessContextEngine(params: { { availableTools: new Set(params.availableTools), citationsMode: params.citationsMode, - agentId: resolveAgentIdFromSessionKey(params.sessionKey), + agentId: params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey), agentSessionKey: params.sessionKey, sandboxed: params.sandboxed, }, diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 7a11be617748..19708d8b491b 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -1962,6 +1962,18 @@ describe("selectAgentHarness", () => { }, identity: { sessionKey: "agent:worker:main" }, }, + { + label: "persisted fixed-store owner", + config: { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "worker" } }, + list: [{ id: "main" }, { id: "worker", params: { store: false } }], + }, + }, + identity: { sessionKey: "global" }, + }, ] as const)( "projects $label agent request params into harness support", ({ config, identity }) => { @@ -3239,7 +3251,7 @@ describe("selectAgentHarness", () => { await expect( maybeCompactAgentHarnessSession({ sessionId: "session-1", - sessionKey: "agent:main:main", + sessionKey: "agent:strict:main", sandboxSessionKey: "global", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp/workspace", diff --git a/src/agents/harness/support.ts b/src/agents/harness/support.ts index 1ad50c2ad56a..c33927ac9cbe 100644 --- a/src/agents/harness/support.ts +++ b/src/agents/harness/support.ts @@ -12,7 +12,7 @@ import type { ProviderRouteOverridePresence, } from "../../plugin-sdk/provider-model-types.js"; import { resolveProviderModelRoutes } from "../../plugins/provider-model-routes.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { resolveSessionAgentIds } from "../agent-scope.js"; import { hasAuthoredProviderRequestParams } from "../model-extra-params.js"; import { canonicalizeProviderModelId } from "../provider-model-route.js"; import type { AgentRuntimeAuthPlan } from "../runtime-plan/types.js"; @@ -95,8 +95,13 @@ export function buildAgentHarnessSupportContext(params: { }).get(modelId) : undefined; const agentId = - params.agentId ?? - (params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined); + params.config && (params.agentId?.trim() || params.sessionKey?.trim()) + ? resolveSessionAgentIds({ + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + }).sessionAgentId + : params.agentId; const hasConfiguredProviderRequestParams = hasAuthoredProviderRequestParams({ config: params.config, provider: params.provider, diff --git a/src/agents/heartbeat-system-prompt.test.ts b/src/agents/heartbeat-system-prompt.test.ts index d71952baa580..3b069f5083f6 100644 --- a/src/agents/heartbeat-system-prompt.test.ts +++ b/src/agents/heartbeat-system-prompt.test.ts @@ -87,6 +87,42 @@ describe("resolveHeartbeatPromptForSystemPrompt", () => { ).toBeUndefined(); }); + it("includes the heartbeat section for every agent enrolled by shared defaults", () => { + expect( + resolveHeartbeatPromptForSystemPrompt({ + config: { + agents: { + defaults: { heartbeat: { every: "30m" } }, + list: [{ id: "ops" }, { id: "research" }], + }, + }, + agentId: "research", + }), + ).toBeDefined(); + }); + + it("includes the heartbeat section only for explicitly enrolled agents", () => { + const config = { + agents: { + ownership: "explicit" as const, + list: [{ id: "ops" }, { id: "research", heartbeat: { every: "30m" } }], + }, + }; + + expect( + resolveHeartbeatPromptForSystemPrompt({ + config, + agentId: "research", + }), + ).toBeDefined(); + expect( + resolveHeartbeatPromptForSystemPrompt({ + config, + agentId: "ops", + }), + ).toBeUndefined(); + }); + it("honors default-agent overrides for the prompt text", () => { // Defaults establish cadence/shape, but the default agent can override the // final visible prompt text. @@ -127,7 +163,7 @@ describe("resolveHeartbeatPromptForSystemPrompt", () => { ).toContain("Recurring tasks are automations"); }); - it("does not inject the heartbeat section for non-default agents", () => { + it("includes the heartbeat section for explicitly enrolled non-default agents", () => { expect( resolveHeartbeatPromptForSystemPrompt({ config: { @@ -150,6 +186,6 @@ describe("resolveHeartbeatPromptForSystemPrompt", () => { agentId: "ops", defaultAgentId: "main", }), - ).toBeUndefined(); + ).toContain("Ops prompt"); }); }); diff --git a/src/agents/heartbeat-system-prompt.ts b/src/agents/heartbeat-system-prompt.ts index 50f66d891962..59597c0bb5ce 100644 --- a/src/agents/heartbeat-system-prompt.ts +++ b/src/agents/heartbeat-system-prompt.ts @@ -8,13 +8,29 @@ import { resolveHeartbeatPromptCore as resolveHeartbeatPromptText, } from "../auto-reply/heartbeat.js"; import { parseDurationMs } from "../cli/parse-duration.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; -import { listAgentEntries, resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; +import { listAgentEntries, resolveAgentConfig } from "./agent-scope.js"; type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; +function isHeartbeatSharedAcrossAgents(config: OpenClawConfig): boolean { + return ( + config.agents?.defaults?.heartbeat !== undefined && + normalizeOptionalString(config.agents.defaults.heartbeat.agentId) === undefined && + !listAgentEntries(config).some((entry) => Boolean(entry?.heartbeat)) + ); +} + +function tryResolveHeartbeatOwnerAgentId(config?: OpenClawConfig): string | undefined { + return ( + normalizeOptionalString(config?.agents?.defaults?.heartbeat?.agentId) ?? + tryResolveLegacyCompatibilityAgentId(config ?? {}) + ); +} + // System prompt heartbeat config inherits defaults, then per-agent overrides, // matching runtime scheduling without exposing disabled agents to the section. function resolveHeartbeatConfigForSystemPrompt( @@ -32,18 +48,29 @@ function resolveHeartbeatConfigForSystemPrompt( return { ...defaults, ...overrides }; } -// Explicit heartbeat config on any agent means only those agents are opted in; -// otherwise the default agent receives the standard heartbeat guidance. -function isHeartbeatEnabledByAgentPolicy(config: OpenClawConfig, agentId: string): boolean { +function isAgentExplicitlyEnrolledForHeartbeat(config: OpenClawConfig, agentId: string): boolean { const resolvedAgentId = normalizeAgentId(agentId); + return listAgentEntries(config).some( + (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry.id) === resolvedAgentId, + ); +} + +// Explicit heartbeat config on any agent means only those agents are opted in; +// shared defaults without an owner enroll every configured agent. +function isHeartbeatEnabledByAgentPolicy(config: OpenClawConfig, agentId: string): boolean { const agents = listAgentEntries(config); const hasExplicitHeartbeatAgents = agents.some((entry) => Boolean(entry?.heartbeat)); if (hasExplicitHeartbeatAgents) { - return agents.some( - (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry.id) === resolvedAgentId, - ); + return isAgentExplicitlyEnrolledForHeartbeat(config, agentId); } - return resolvedAgentId === resolveDefaultAgentId(config); + if (isHeartbeatSharedAcrossAgents(config)) { + return true; + } + const heartbeatOwnerAgentId = tryResolveHeartbeatOwnerAgentId(config); + return ( + heartbeatOwnerAgentId !== undefined && + normalizeAgentId(agentId) === normalizeAgentId(heartbeatOwnerAgentId) + ); } function isHeartbeatCadenceEnabled(heartbeat?: HeartbeatConfig): boolean { @@ -65,9 +92,21 @@ function shouldIncludeHeartbeatGuidanceForSystemPrompt(params: { agentId?: string; defaultAgentId?: string; }): boolean { - const defaultAgentId = params.defaultAgentId ?? resolveDefaultAgentId(params.config ?? {}); + const heartbeatSharedAcrossAgents = params.config + ? isHeartbeatSharedAcrossAgents(params.config) + : false; + const defaultAgentId = params.defaultAgentId ?? tryResolveHeartbeatOwnerAgentId(params.config); const agentId = params.agentId ?? defaultAgentId; - if (!agentId || normalizeAgentId(agentId) !== normalizeAgentId(defaultAgentId)) { + const explicitlyEnrolledAgent = + params.config && agentId + ? isAgentExplicitlyEnrolledForHeartbeat(params.config, agentId) + : false; + if ( + !agentId || + (!explicitlyEnrolledAgent && + !heartbeatSharedAcrossAgents && + normalizeAgentId(agentId) !== normalizeAgentId(defaultAgentId)) + ) { return false; } if (params.config && !isHeartbeatEnabledByAgentPolicy(params.config, agentId)) { @@ -84,7 +123,10 @@ export function resolveHeartbeatPromptForSystemPrompt(params: { defaultAgentId?: string; }): string | undefined { const agentId = - params.agentId ?? params.defaultAgentId ?? resolveDefaultAgentId(params.config ?? {}); + params.agentId ?? params.defaultAgentId ?? tryResolveHeartbeatOwnerAgentId(params.config); + if (!agentId) { + return undefined; + } const heartbeat = resolveHeartbeatConfigForSystemPrompt(params.config, agentId); if (!shouldIncludeHeartbeatGuidanceForSystemPrompt(params)) { return undefined; diff --git a/src/agents/identity-avatar.test.ts b/src/agents/identity-avatar.test.ts index 0ab18fcfafb3..281976915759 100644 --- a/src/agents/identity-avatar.test.ts +++ b/src/agents/identity-avatar.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { AVATAR_MAX_DATA_URL_CHARS } from "../shared/avatar-limits.js"; import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js"; import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "./identity-avatar.js"; @@ -311,29 +312,46 @@ describe("resolveAgentAvatar", () => { } }); - it("falls back to ui.assistant.avatar for non-default agents without their own avatar", async () => { - const root = await createTempAvatarRoot(); - const mainWorkspace = path.join(root, "main"); - const workerWorkspace = path.join(root, "worker"); - await writeFile(path.join(workerWorkspace, "ui-avatar.png")); - - const cfg: OpenClawConfig = { - ui: { assistant: { avatar: "ui-avatar.png" } }, - agents: { - list: [ - { id: "main", workspace: mainWorkspace }, - { id: "worker", workspace: workerWorkspace }, - ], + it("scopes ui.assistant.avatar to the sole or retained compatibility owner", () => { + const migratedCfg = retainLegacyDefaultAgentId( + { + ui: { assistant: { avatar: "https://example.com/ui-avatar.png" } }, + agents: { ownership: "explicit", list: [{ id: "research" }, { id: "ops" }] }, }, - }; + "ops", + ); - const workspaceReal = await fs.realpath(workerWorkspace); - const resolved = resolveAgentAvatar(cfg, "worker", { includeUiOverride: true }); - expect(resolved.kind).toBe("local"); - if (resolved.kind === "local") { - const resolvedReal = await fs.realpath(resolved.filePath); - expect(path.relative(workspaceReal, resolvedReal)).toBe("ui-avatar.png"); - } + expect(resolveAgentAvatar(migratedCfg, "ops", { includeUiOverride: true })).toMatchObject({ + kind: "remote", + url: "https://example.com/ui-avatar.png", + }); + expect(resolveAgentAvatar(migratedCfg, "research", { includeUiOverride: true })).toEqual({ + kind: "none", + reason: "missing", + }); + expect( + resolveAgentAvatar( + { + ui: { assistant: { avatar: "https://example.com/ui-avatar.png" } }, + agents: { ownership: "explicit", list: [{ id: "research" }, { id: "ops" }] }, + }, + "ops", + { includeUiOverride: true }, + ), + ).toEqual({ kind: "none", reason: "missing" }); + + const rawLegacyCfg: OpenClawConfig = { + ui: { assistant: { avatar: "https://example.com/raw-ui-avatar.png" } }, + agents: { list: [{ id: "research" }, { id: "ops", default: true }] }, + }; + expect(resolveAgentAvatar(rawLegacyCfg, "ops", { includeUiOverride: true })).toMatchObject({ + kind: "remote", + url: "https://example.com/raw-ui-avatar.png", + }); + expect(resolveAgentAvatar(rawLegacyCfg, "research", { includeUiOverride: true })).toEqual({ + kind: "none", + reason: "missing", + }); }); it("ui.assistant.avatar takes priority over IDENTITY.md avatar with includeUiOverride", async () => { diff --git a/src/agents/identity-avatar.ts b/src/agents/identity-avatar.ts index 2ca3be75c7a8..569ca12a329f 100644 --- a/src/agents/identity-avatar.ts +++ b/src/agents/identity-avatar.ts @@ -3,6 +3,7 @@ */ import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { @@ -11,7 +12,7 @@ import { isAvatarHttpUrl, isWindowsAbsolutePath, } from "../shared/avatar-policy.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; +import { resolveAgentWorkspaceDir } from "./agent-scope.js"; import { resolveLocalAgentAvatarPath } from "./identity-avatar-file.js"; import { loadAgentIdentityFromWorkspace } from "./identity-file.js"; import { resolveAgentIdentity } from "./identity.js"; @@ -39,12 +40,10 @@ function resolveAvatarSource( opts?: { includeUiOverride?: boolean }, ): string | null { const normalizedAgentId = normalizeAgentId(agentId); - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); const fromUiConfig = normalizeOptionalString(cfg.ui?.assistant?.avatar) ?? null; if (opts?.includeUiOverride) { - // UI override only wins for the default agent unless callers explicitly ask - // for it as a final fallback for non-default agents. - if (normalizedAgentId === defaultAgentId && fromUiConfig) { + // The shared UI avatar belongs only to the sole or retained compatibility owner. + if (normalizedAgentId === tryResolveLegacyCompatibilityAgentId(cfg) && fromUiConfig) { return fromUiConfig; } } @@ -59,7 +58,7 @@ function resolveAvatarSource( if (fromIdentity) { return fromIdentity; } - return opts?.includeUiOverride ? fromUiConfig : null; + return null; } function isSafeRelativeAvatarSource(source: string): boolean { diff --git a/src/agents/legacy-inherited-auth-dir.test.ts b/src/agents/legacy-inherited-auth-dir.test.ts new file mode 100644 index 000000000000..219978c8e0ad --- /dev/null +++ b/src/agents/legacy-inherited-auth-dir.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveLegacyInheritedAuthAgentId } from "./legacy-inherited-auth-dir.js"; + +describe("legacy inherited auth ownership", () => { + it("uses the raw legacy marker owner for direct config inputs", () => { + const cfg: OpenClawConfig = { + agents: { entries: { main: {}, ops: { default: true } } }, + }; + + expect(resolveLegacyInheritedAuthAgentId(cfg)).toBe("ops"); + }); +}); diff --git a/src/agents/legacy-inherited-auth-dir.ts b/src/agents/legacy-inherited-auth-dir.ts new file mode 100644 index 000000000000..b42a8ad12250 --- /dev/null +++ b/src/agents/legacy-inherited-auth-dir.ts @@ -0,0 +1,70 @@ +import path from "node:path"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { resolveAgentDir } from "./agent-scope-config.js"; + +export function resolveLegacyInheritedAuthAgentId(config: OpenClawConfig): string { + return ( + normalizeOptionalString(config.agents?.defaults?.authInheritance?.agentId) ?? + tryResolveLegacyCompatibilityAgentId(config) ?? + "main" + ); +} + +export function resolveLegacyInheritedAuthDir( + config: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string { + return resolveAgentDir(config, resolveLegacyInheritedAuthAgentId(config), env); +} + +export function pinLegacyInheritedAuthOwnerForRosterTransition( + sourceConfig: OpenClawConfig, + targetConfig: OpenClawConfig, +): OpenClawConfig { + const sourceOwner = resolveLegacyInheritedAuthAgentId(sourceConfig); + if (sourceOwner === resolveLegacyInheritedAuthAgentId(targetConfig)) { + return targetConfig; + } + return { + ...targetConfig, + agents: { + ...targetConfig.agents, + defaults: { + ...targetConfig.agents?.defaults, + authInheritance: { + ...targetConfig.agents?.defaults?.authInheritance, + agentId: sourceOwner, + }, + }, + }, + }; +} + +export function assertSafeLegacyInheritedAuthDirTransition( + sourceConfig: OpenClawConfig, + targetConfig: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): void { + const sourceOwner = resolveLegacyInheritedAuthAgentId(sourceConfig); + const sourceDir = resolveAgentDir(sourceConfig, sourceOwner, env); + const conventionalDir = path.join( + resolveStateDir(env), + "agents", + normalizeAgentId(sourceOwner), + "agent", + ); + const targetDir = resolveAgentDir(targetConfig, sourceOwner, env); + if (path.resolve(sourceDir) === path.resolve(conventionalDir) || targetDir === sourceDir) { + return; + } + throw Object.assign( + new Error( + `Config write refused: inherited auth for agent "${sourceOwner}" is stored in custom agentDir ${JSON.stringify(sourceDir)}, but this roster change removes or changes that directory. Relocate the credentials to ${JSON.stringify(conventionalDir)} or set agents.defaults.authInheritance explicitly for the destination owner, then retry.`, + ), + { code: "CONFIG_WRITE_REJECTED" }, + ); +} diff --git a/src/agents/local-model-lean.test.ts b/src/agents/local-model-lean.test.ts index c329999bd129..736ab5c9dabe 100644 --- a/src/agents/local-model-lean.test.ts +++ b/src/agents/local-model-lean.test.ts @@ -4,6 +4,7 @@ */ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; import { applyLocalModelLeanToolSearchDefaults, @@ -272,6 +273,23 @@ describe("local model lean tool filtering", () => { ).toEqual(["read", "exec"]); }); + it("uses the retained legacy owner when no session scope is provided", () => { + const cfg = retainLegacyDefaultAgentId( + { + agents: { + ownership: "explicit", + entries: { + ops: { experimental: { localModelLean: false } }, + gemma: { experimental: { localModelLean: true } }, + }, + }, + }, + "gemma", + ); + + expect(isLocalModelLeanEnabled({ config: cfg })).toBe(true); + }); + it("uses the agent from an agent session key", () => { const cfg: OpenClawConfig = { agents: { @@ -302,6 +320,25 @@ describe("local model lean tool filtering", () => { ).toEqual(["read", "exec"]); }); + it("uses the configured fixed-store owner for an unscoped session key", () => { + const cfg: OpenClawConfig = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "gemma" } }, + entries: { + ops: { experimental: { localModelLean: false } }, + gemma: { experimental: { localModelLean: true } }, + }, + }, + }; + + expect(isLocalModelLeanEnabled({ config: cfg, sessionKey: "global" })).toBe(true); + expect(() => + isLocalModelLeanEnabled({ config: cfg, agentId: "ops", sessionKey: "global" }), + ).toThrow(/belongs to "gemma"/); + }); + it("defaults lean runs to structured Tool Search controls", () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/agents/local-model-lean.ts b/src/agents/local-model-lean.ts index 776a767a8286..cea82a504ce3 100644 --- a/src/agents/local-model-lean.ts +++ b/src/agents/local-model-lean.ts @@ -6,7 +6,8 @@ import { messageToolOwnsVisibleReply } from "../auto-reply/source-reply-delivery-mode.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; -import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope-config.js"; +import { resolveAgentConfig } from "./agent-scope-config.js"; +import { resolveSessionAgentIds } from "./agent-scope.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js"; import { expandToolGroups, normalizeToolPolicyName } from "./tool-policy.js"; @@ -63,14 +64,17 @@ function resolveLocalModelLeanAgentId(params: { typeof params.agentId === "string" && params.agentId.trim() ? normalizeAgentId(params.agentId) : undefined; - if (explicitAgentId) { - return explicitAgentId; + if (params.config) { + return resolveSessionAgentIds({ + config: params.config, + agentId: explicitAgentId, + sessionKey: params.sessionKey, + }).sessionAgentId; } const parsedSessionAgentId = parseAgentSessionKey(params.sessionKey)?.agentId; - if (parsedSessionAgentId) { - return normalizeAgentId(parsedSessionAgentId); - } - return params.config ? resolveDefaultAgentId(params.config) : undefined; + return ( + explicitAgentId ?? (parsedSessionAgentId ? normalizeAgentId(parsedSessionAgentId) : undefined) + ); } /** Returns true when local-model lean mode is enabled for the selected agent. */ diff --git a/src/agents/main-session-recovery/main-session-restart-dispatch.ts b/src/agents/main-session-recovery/main-session-restart-dispatch.ts index 82494bb429d6..44616443a4ee 100644 --- a/src/agents/main-session-recovery/main-session-restart-dispatch.ts +++ b/src/agents/main-session-recovery/main-session-restart-dispatch.ts @@ -326,6 +326,7 @@ function scheduleRestartRecoveryReservationRollback( } export async function resumeMainSession(params: { + agentId: string; canonicalSessionKey?: string; cfg?: OpenClawConfig; entry: SessionEntry; @@ -455,6 +456,7 @@ export async function resumeMainSession(params: { : "skipped"; } const agentParams: AgentRunRequest = { + agentId: params.agentId, message: buildResumeMessage(sanitizedPendingText), sessionKey: dispatchSessionKey, expectedExistingSessionId: params.entry.sessionId, diff --git a/src/agents/main-session-recovery/main-session-restart-recovery-failure.ts b/src/agents/main-session-recovery/main-session-restart-recovery-failure.ts index 392cda344c63..ab578f933c51 100644 --- a/src/agents/main-session-recovery/main-session-restart-recovery-failure.ts +++ b/src/agents/main-session-recovery/main-session-restart-recovery-failure.ts @@ -8,7 +8,6 @@ import { import { appendAssistantMessageToSessionTranscript } from "../../config/sessions/transcript.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { GatewayRecoveryRuntime } from "../../gateway/server-instance-runtime.types.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import type { DeliveryContext } from "../../utils/delivery-context.shared.js"; import type { MainSessionRecoveryObservation } from "./main-session-recovery-state.js"; import { commitMainSessionRecovery } from "./main-session-recovery-store.js"; @@ -112,6 +111,7 @@ async function claimMainRestartRecoveryTombstone(params: { } export async function tombstoneMainRestartRecoveryWithNotice(params: { + agentId: string; cfg?: OpenClawConfig; entry: SessionEntry; gatewayRuntime: GatewayRecoveryRuntime; @@ -142,7 +142,7 @@ export async function tombstoneMainRestartRecoveryWithNotice(params: { } const now = Date.now(); const notice = await writeRestartRecoveryTombstoneNotice({ - agentId: resolveAgentIdFromSessionKey(params.sessionKey), + agentId: params.agentId, entry, expectedSessionState: buildRestartRecoveryExpectedState(entry, observation), sessionKey: params.sessionKey, diff --git a/src/agents/main-session-recovery/main-session-restart-recovery-store.ts b/src/agents/main-session-recovery/main-session-restart-recovery-store.ts index 320c352fb3cb..93be898d8c03 100644 --- a/src/agents/main-session-recovery/main-session-restart-recovery-store.ts +++ b/src/agents/main-session-recovery/main-session-restart-recovery-store.ts @@ -17,8 +17,10 @@ import { readSessionMessagesAsync } from "../../gateway/session-transcript-reade import { resolveGatewaySessionStoreTarget } from "../../gateway/session-utils.js"; import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js"; import { findDeliveryIntentOwner } from "../../infra/outbound/delivery-queue-storage.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; -import { resolveDefaultAgentId } from "../agent-scope-config.js"; +import { + LEGACY_IMPLICIT_AGENT_ID, + resolveAgentIdFromSessionKey, +} from "../../routing/session-key.js"; import { listActiveEmbeddedRunSessionIds, listActiveEmbeddedRunSessionKeys, @@ -181,13 +183,16 @@ export function loadExpectedRestartRecoveryTarget(params: { : undefined; } -function resolveRecoveryDispatchSessionKey(params: { +function resolveRestartRecoveryDispatchTarget(params: { cfg?: OpenClawConfig; sessionKey: string; storePath: string; -}): string | undefined { +}): { agentId: string; sessionKey: string } | undefined { if (!params.cfg) { - return params.sessionKey; + return { + agentId: resolveAgentIdFromSessionKey(params.sessionKey, LEGACY_IMPLICIT_AGENT_ID), + sessionKey: params.sessionKey, + }; } try { const target = resolveGatewaySessionStoreTarget({ @@ -196,7 +201,7 @@ function resolveRecoveryDispatchSessionKey(params: { }); return !params.cfg.session?.store || path.resolve(target.storePath) === path.resolve(params.storePath) - ? target.canonicalKey + ? { agentId: target.agentId, sessionKey: target.canonicalKey } : undefined; } catch (err) { mainSessionRecoveryLog.warn( @@ -281,10 +286,6 @@ export async function recoverStore(params: { return result; } let entry = loadedEntry; - const agentId = resolveAgentIdFromSessionKey( - sessionKey, - params.cfg ? resolveDefaultAgentId(params.cfg) : undefined, - ); if (!entry || entry.status !== "running" || entry.abortedLastRun !== true) { continue; } @@ -296,19 +297,20 @@ export async function recoverStore(params: { result.skipped++; continue; } - const resolvedDispatchSessionKey = resolveRecoveryDispatchSessionKey({ + const dispatchTarget = resolveRestartRecoveryDispatchTarget({ cfg: params.cfg, sessionKey, storePath: params.storePath, }); - if (!resolvedDispatchSessionKey) { + if (!dispatchTarget) { result.skipped++; continue; } + const agentId = dispatchTarget.agentId; const dispatchSessionKey = params.expectedClaim?.canonicalSessionKey ?? params.expectedTarget?.canonicalSessionKey ?? - resolvedDispatchSessionKey; + dispatchTarget.sessionKey; if ( hasCurrentProcessOwner({ activeSessionIds: resolveActiveSessionIds(), @@ -362,6 +364,7 @@ export async function recoverStore(params: { return result; } const tombstone = await tombstoneMainRestartRecoveryWithNotice({ + agentId, cfg: params.cfg, entry, gatewayRuntime: params.gatewayRuntime, @@ -414,6 +417,7 @@ export async function recoverStore(params: { return result; } const tombstone = await tombstoneMainRestartRecoveryWithNotice({ + agentId, cfg: params.cfg, entry, gatewayRuntime: params.gatewayRuntime, @@ -441,6 +445,7 @@ export async function recoverStore(params: { ) => { recordResumeResult( await resumeIfCurrent({ + agentId, canonicalSessionKey: dispatchSessionKey, cfg: params.cfg, entry, diff --git a/src/agents/main-session-recovery/main-session-restart-recovery.test.ts b/src/agents/main-session-recovery/main-session-restart-recovery.test.ts index b87278f51afd..1cae9d73d1de 100644 --- a/src/agents/main-session-recovery/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-recovery/main-session-restart-recovery.test.ts @@ -77,6 +77,7 @@ import { import * as recoveryOwnerRelease from "./main-session-recovery-owner-release.js"; import { claimMainSessionRecoveryOwner } from "./main-session-recovery-store.js"; import { resolveRestartRecoveryStorePaths } from "./main-session-restart-recovery-shared.js"; +import { recoverStore } from "./main-session-restart-recovery-store.js"; import { markRestartAbortedMainSessions, markStartupOrphanedMainSessionsForRecovery, @@ -573,6 +574,53 @@ describe("main-session-restart-recovery", () => { expect(recovery).toEqual({ recovered: 1, failed: 0, skipped: 0 }); }); + it("dispatches a bare fixed-store recovery under its persisted owner", async () => { + const storePath = path.join(tmpDir, "shared", "sessions.json"); + await writeStorePath(storePath, { + global: mainSessionEntry({ + pendingFinalDelivery: makePendingFinalDelivery(), + restartRecoveryForceSafeTools: true, + }), + }); + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { scope: "global", store: storePath }, + } satisfies OpenClawConfig; + + await expect( + recoverStore({ + cfg, + gatewayRuntime: mockRecoveryRuntime, + resumedSessionKeys: new Set(), + storePath, + }), + ).resolves.toEqual({ recovered: 1, failed: 0, skipped: 0 }); + expect(gatewayParams()).toMatchObject({ agentId: "ops", sessionKey: "global" }); + }); + + it("dispatches a config-less bare recovery under the legacy implicit owner", async () => { + const storePath = path.join(tmpDir, "legacy-shared", "sessions.json"); + await writeStorePath(storePath, { + global: mainSessionEntry({ + pendingFinalDelivery: makePendingFinalDelivery(), + restartRecoveryForceSafeTools: true, + }), + }); + + await expect( + recoverStore({ + gatewayRuntime: mockRecoveryRuntime, + resumedSessionKeys: new Set(), + storePath, + }), + ).resolves.toEqual({ recovered: 1, failed: 0, skipped: 0 }); + expect(gatewayParams()).toMatchObject({ agentId: "main", sessionKey: "global" }); + }); + it("persists abort-registry runs after their event context was cleared", async () => { const sessionsDir = await makeSessionsDir(); await writeMainSession({ diff --git a/src/agents/mcp-ui-resource.test.ts b/src/agents/mcp-ui-resource.test.ts index 5d58fe050201..4a4b2496b250 100644 --- a/src/agents/mcp-ui-resource.test.ts +++ b/src/agents/mcp-ui-resource.test.ts @@ -82,11 +82,43 @@ describe("MCP App UI resources", () => { runtime(async () => ({ contents: [] })), ), ).toBeUndefined(); - expect(getMcpAppViewLeaseForSession(result?.viewId ?? "", "agent:main:main")).toMatchObject({ + expect( + getMcpAppViewLeaseForSession(result?.viewId ?? "", "agent:main:main", "main"), + ).toMatchObject({ html: "demo", runtime: sessionRuntime, + agentId: "main", }); - expect(getMcpAppViewLeaseForSession(result?.viewId ?? "", "agent:other:main")).toBeUndefined(); + expect( + getMcpAppViewLeaseForSession(result?.viewId ?? "", "agent:other:main", "other"), + ).toBeUndefined(); + }); + + it("isolates live views by agent when bare session keys collide", async () => { + const sessionRuntime = runtime(async () => ({ + contents: [ + { + uri: "ui://demo/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "ops", + }, + ], + })); + sessionRuntime.sessionKey = "global"; + const result = await fetchMcpAppView({ + runtime: sessionRuntime, + agentId: "ops", + serverName: "demo", + toolName: "show", + uiResourceUri: "ui://demo/app", + toolInput: {}, + toolResult: { content: [] }, + }); + + expect(getMcpAppViewLeaseForSession(result?.viewId ?? "", "global", "ops")).toBeDefined(); + expect( + getMcpAppViewLeaseForSession(result?.viewId ?? "", "global", "research"), + ).toBeUndefined(); }); it("keeps valid Apps when optional listing metadata fails", async () => { diff --git a/src/agents/mcp-ui-resource.ts b/src/agents/mcp-ui-resource.ts index 93f82bb5df79..78c23332c6c1 100644 --- a/src/agents/mcp-ui-resource.ts +++ b/src/agents/mcp-ui-resource.ts @@ -3,6 +3,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { formatErrorMessage } from "../infra/errors.js"; import { logWarn } from "../logger.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { completeDeferredSessionMcpRuntimeRetirement } from "./agent-bundle-mcp-runtime.js"; import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; import { clearMcpAppModelContextForView } from "./mcp-app-model-context.js"; @@ -23,6 +24,7 @@ type McpAppPermissions = Partial< export type McpAppViewLease = { viewId: string; runtime: SessionMcpRuntime; + agentId: string; sessionId: string; serverName: string; toolName: string; @@ -213,6 +215,7 @@ async function resolveListingUiMeta( export async function fetchMcpAppView(params: { runtime: SessionMcpRuntime; + agentId?: string; serverName: string; toolName: string; uiResourceUri: string; @@ -237,6 +240,12 @@ export async function fetchMcpAppView(params: { let releaseRuntimeLease: (() => void) | undefined; try { assertBoundedViewDescriptor(params); + const agentId = params.agentId + ? normalizeAgentId(params.agentId) + : parseAgentSessionKey(params.runtime.sessionKey)?.agentId; + if (!agentId) { + throw new Error("MCP App view requires a resolved session owner"); + } if (!params.runtime.readResource || !params.uiResourceUri.startsWith("ui://")) { return undefined; } @@ -274,6 +283,7 @@ export async function fetchMcpAppView(params: { const view: McpAppViewLease = { viewId, runtime: params.runtime, + agentId, sessionId: params.runtime.sessionId, serverName: params.serverName, toolName: params.toolName, @@ -336,10 +346,13 @@ export function getMcpAppViewLease( export function getMcpAppViewLeaseForSession( viewId: string, sessionKey: string, + agentId: string, ): McpAppViewLease | undefined { pruneViewStore(); const view = getViewStore().get(viewId); - return view?.runtime.sessionKey === sessionKey ? view : undefined; + return view?.runtime.sessionKey === sessionKey && view.agentId === normalizeAgentId(agentId) + ? view + : undefined; } export function acquireMcpAppViewRequest( diff --git a/src/agents/media-generation-task-status-shared.test.ts b/src/agents/media-generation-task-status-shared.test.ts index fa5d486d8ed0..872860f5addf 100644 --- a/src/agents/media-generation-task-status-shared.test.ts +++ b/src/agents/media-generation-task-status-shared.test.ts @@ -10,7 +10,12 @@ const taskRuntimeInternalMocks = vi.hoisted(() => ({ listFreshTasksForOwnerKey: vi.fn(), })); +const configMocks = vi.hoisted(() => ({ + getRuntimeConfig: vi.fn(), +})); + vi.mock("../tasks/runtime-internal.js", () => taskRuntimeInternalMocks); +vi.mock("../config/config.js", () => configMocks); const videoTaskStatusOwner = createMediaGenerationTaskStatusOwner({ taskKind: "video_generation", @@ -45,6 +50,14 @@ function makeTask(overrides: Partial = {}): TaskRecord { beforeEach(() => { resetRecentMediaGenerationDuplicateGuardsForTests(); taskRuntimeInternalMocks.listFreshTasksForOwnerKey.mockReset(); + configMocks.getRuntimeConfig.mockReset().mockReturnValue({ + session: { scope: "global", store: "/tmp/shared-sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }); }); describe("media generation delivery-phase prompt guard", () => { @@ -76,6 +89,23 @@ describe("media generation delivery-phase prompt guard", () => { expect(videoTaskStatusOwner.findActiveTaskForSession("session/A")).toEqual(task); }); + it("keeps restored legacy bare tasks visible only to their persisted requester owner", () => { + const task = makeTask({ + requesterSessionKey: "global", + ownerKey: "global", + requesterAgentId: undefined, + agentId: "research", + progressSummary: "Generating video", + }); + taskRuntimeInternalMocks.listFreshTasksForOwnerKey.mockReturnValue([task]); + + expect(videoTaskStatusOwner.listActiveTasksForSession("global", "ops")).toEqual([task]); + expect(videoTaskStatusOwner.findActiveTaskForSession("global", { agentId: "ops" })).toEqual( + task, + ); + expect(videoTaskStatusOwner.listActiveTasksForSession("global", "research")).toEqual([]); + }); + it("blocks the same prompt while allowing a distinct prompt", () => { const task = makeTask({ task: "generate clip 01", diff --git a/src/agents/media-generation-task-status-shared.ts b/src/agents/media-generation-task-status-shared.ts index c66b2ce92910..b57d3a73b818 100644 --- a/src/agents/media-generation-task-status-shared.ts +++ b/src/agents/media-generation-task-status-shared.ts @@ -10,8 +10,11 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { getRuntimeConfig } from "../config/config.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { listFreshTasksForOwnerKey } from "../tasks/runtime-internal.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; +import { resolveSessionAgentId } from "./agent-scope.js"; import { buildSessionAsyncTaskStatusDetails } from "./session-async-task-status.js"; /** Marks media as ready while requester delivery is still being confirmed. */ @@ -33,6 +36,7 @@ export function buildMediaGenerationRequestKey(value: Record): function buildRecentMediaGenerationTaskKey(params: { sessionKey?: string; + agentId?: string; taskKind: string; sourcePrefix: string; }): string | undefined { @@ -42,7 +46,7 @@ function buildRecentMediaGenerationTaskKey(params: { if (!sessionKey || !taskKind || !sourcePrefix) { return undefined; } - return `${sessionKey}\0${taskKind}\0${sourcePrefix}`; + return `${params.agentId?.trim() ?? "unknown"}\0${sessionKey}\0${taskKind}\0${sourcePrefix}`; } function isRecentMediaGenerationTaskRecord(params: { @@ -87,6 +91,26 @@ function mediaGenerationTaskLabelMatches(task: TaskRecord, taskLabel: string): b return normalizeOptionalString(task.task) === taskLabel; } +function resolveMediaGenerationTaskRequesterAgentId(task: TaskRecord): string | undefined { + const explicit = normalizeOptionalString(task.requesterAgentId); + if (explicit) { + return explicit; + } + const ownerKey = normalizeOptionalString(task.ownerKey ?? task.requesterSessionKey); + const parsed = parseAgentSessionKey(ownerKey)?.agentId; + if (parsed) { + return parsed; + } + if (!ownerKey) { + return undefined; + } + try { + return resolveSessionAgentId({ config: getRuntimeConfig(), sessionKey: ownerKey }); + } catch { + return undefined; + } +} + function isTaskStillBlockingDuplicateGuard(task: TaskRecord): boolean { return task.status === "queued" || task.status === "running"; } @@ -125,6 +149,7 @@ function recentMediaGenerationTaskStartMatches( function findPersistedTaskForRecentMediaGenerationStart(params: { sessionKey: string; + agentId?: string; cachedTask: TaskRecord; taskKind: string; sourcePrefix: string; @@ -134,7 +159,8 @@ function findPersistedTaskForRecentMediaGenerationStart(params: { task.runtime !== "cli" || task.scopeKind !== "session" || task.taskKind !== params.taskKind || - !mediaGenerationSourceMatches(task, params.sourcePrefix) + !mediaGenerationSourceMatches(task, params.sourcePrefix) || + (params.agentId && resolveMediaGenerationTaskRequesterAgentId(task) !== params.agentId) ) { return false; } @@ -148,6 +174,7 @@ function findPersistedTaskForRecentMediaGenerationStart(params: { /** Records a just-started media task so duplicate guards work before persistence. */ export function recordRecentMediaGenerationTaskStartForSession(params: { sessionKey?: string; + agentId?: string; taskKind: string; sourcePrefix: string; taskId: string; @@ -179,6 +206,7 @@ export function recordRecentMediaGenerationTaskStartForSession(params: { ? `${params.sourcePrefix}:${params.providerId.trim()}` : params.sourcePrefix, requesterSessionKey: sessionKey, + requesterAgentId: params.agentId, ownerKey: sessionKey, scopeKind: "session", ...(params.runId ? { runId: params.runId } : {}), @@ -210,6 +238,7 @@ export function recordRecentMediaGenerationTaskStartForSession(params: { /** Finds a recent started media task from memory or persisted task state. */ function findRecentStartedMediaGenerationTaskForSession(params: { sessionKey?: string; + agentId?: string; taskKind: string; sourcePrefix: string; taskLabel?: string; @@ -237,6 +266,7 @@ function findRecentStartedMediaGenerationTaskForSession(params: { const task = entry.task; const persistedTask = findPersistedTaskForRecentMediaGenerationStart({ sessionKey, + agentId: params.agentId, cachedTask: task, taskKind: params.taskKind, sourcePrefix: params.sourcePrefix, @@ -306,6 +336,7 @@ function getMediaGenerationTaskProviderId( /** Finds the highest-priority active media generation task for a session. */ function findActiveMediaGenerationTaskForSession(params: { sessionKey?: string; + agentId?: string; taskKind: string; sourcePrefix: string; taskLabel?: string; @@ -317,6 +348,7 @@ function findActiveMediaGenerationTaskForSession(params: { /** Lists active media generation tasks for a session, preferring running tasks. */ function listActiveMediaGenerationTasksForSession(params: { sessionKey?: string; + agentId?: string; taskKind: string; sourcePrefix: string; taskLabel?: string; @@ -337,6 +369,9 @@ function listActiveMediaGenerationTasksForSession(params: { ) { return false; } + if (params.agentId && resolveMediaGenerationTaskRequesterAgentId(task) !== params.agentId) { + return false; + } if (sourcePrefix && !mediaGenerationSourceMatches(task, sourcePrefix)) { return false; } @@ -360,6 +395,7 @@ function listActiveMediaGenerationTasksForSession(params: { /** Finds a task that should block duplicate media generation for a session. */ function findDuplicateGuardMediaGenerationTaskForSession(params: { sessionKey?: string; + agentId?: string; taskKind: string; sourcePrefix: string; taskLabel?: string; @@ -370,6 +406,7 @@ function findDuplicateGuardMediaGenerationTaskForSession(params: { findRecentStartedMediaGenerationTaskForSession(params) ?? findActiveMediaGenerationTaskForSession({ sessionKey: params.sessionKey, + agentId: params.agentId, taskKind: params.taskKind, sourcePrefix: params.sourcePrefix, taskLabel: params.taskLabel, @@ -464,6 +501,7 @@ function buildMediaGenerationTaskStatusListText(params: { /** Builds prompt context warning an agent about an active media generation task. */ function buildActiveMediaGenerationTaskPromptContextForSession(params: { sessionKey?: string; + agentId?: string; taskKind: string; sourcePrefix: string; nounLabel: string; @@ -472,6 +510,7 @@ function buildActiveMediaGenerationTaskPromptContextForSession(params: { }): string | undefined { const task = findActiveMediaGenerationTaskForSession({ sessionKey: params.sessionKey, + agentId: params.agentId, taskKind: params.taskKind, sourcePrefix: params.sourcePrefix, excludeDeliveringCompletion: true, @@ -506,26 +545,32 @@ export function createMediaGenerationTaskStatusOwner(params: { toolName: params.toolName, }; return { - findActiveTaskForSession(this: void, sessionKey?: string, request?: { prompt?: string }) { + findActiveTaskForSession( + this: void, + sessionKey?: string, + request?: { prompt?: string; agentId?: string }, + ) { return findActiveMediaGenerationTaskForSession({ ...taskIdentity, sessionKey, taskLabel: request?.prompt, + agentId: request?.agentId, }); }, - listActiveTasksForSession(this: void, sessionKey?: string) { - return listActiveMediaGenerationTasksForSession({ ...taskIdentity, sessionKey }); + listActiveTasksForSession(this: void, sessionKey?: string, agentId?: string) { + return listActiveMediaGenerationTasksForSession({ ...taskIdentity, sessionKey, agentId }); }, findDuplicateGuardTaskForSession( this: void, sessionKey?: string, - request?: { prompt?: string; requestKey?: string }, + request?: { prompt?: string; requestKey?: string; agentId?: string }, ) { return findDuplicateGuardMediaGenerationTaskForSession({ ...taskIdentity, sessionKey, taskLabel: request?.prompt, requestKey: request?.requestKey, + agentId: request?.agentId, maxAgeMs: RECENT_MEDIA_GENERATION_TASK_START_CACHE_MS, }); }, @@ -550,11 +595,12 @@ export function createMediaGenerationTaskStatusOwner(params: { completionLabel: params.promptCompletionLabel, }); }, - buildActiveTaskPromptContextForSession(this: void, sessionKey?: string) { + buildActiveTaskPromptContextForSession(this: void, sessionKey?: string, agentId?: string) { return buildActiveMediaGenerationTaskPromptContextForSession({ ...taskIdentity, ...taskPresentation, sessionKey, + agentId, completionLabel: params.promptCompletionLabel, }); }, diff --git a/src/agents/model-runtime-policy.test.ts b/src/agents/model-runtime-policy.test.ts index 3028ab061a83..711029873862 100644 --- a/src/agents/model-runtime-policy.test.ts +++ b/src/agents/model-runtime-policy.test.ts @@ -122,6 +122,8 @@ describe("resolveModelRuntimePolicy", () => { it("honors provider wildcard agent model runtime policy entries", () => { const config = { agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, defaults: { models: { "vllm/*": { agentRuntime: { id: "openclaw" } }, @@ -523,6 +525,52 @@ describe("resolveModelRuntimePolicy", () => { }); }); + it("uses the persisted owner model runtime policy for a bare session key", () => { + const config = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { + sessionStore: { agentId: "research" }, + models: { + "vllm/qwen-local": { agentRuntime: { id: "codex" } }, + }, + }, + list: [ + { id: "ops" }, + { + id: "research", + models: { + "vllm/qwen-local": { agentRuntime: { id: "openclaw" } }, + }, + }, + ], + }, + } as OpenClawConfig; + + expect( + resolveModelRuntimePolicy({ + config, + provider: "vllm", + modelId: "qwen-local", + sessionKey: "global", + }), + ).toEqual({ + policy: { id: "openclaw" }, + source: "model", + matchedProvider: "vllm", + }); + expect(() => + resolveModelRuntimePolicy({ + config, + provider: "vllm", + modelId: "qwen-local", + agentId: "ops", + sessionKey: "global", + }), + ).toThrow(/belongs to "research"/); + }); + it("fails closed for duplicate provider-prefixed bare-model policies", () => { const config = { agents: { diff --git a/src/agents/model-runtime-policy.ts b/src/agents/model-runtime-policy.ts index 4d0163278d24..b48dd9d335a3 100644 --- a/src/agents/model-runtime-policy.ts +++ b/src/agents/model-runtime-policy.ts @@ -6,6 +6,7 @@ */ import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; import type { AgentRuntimePolicyConfig } from "../config/types.agents-shared.js"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types.models.js"; @@ -152,14 +153,17 @@ function resolveAgentModelEntryRuntimePolicy(params: { if (!params.config || (!modelId && params.matchKind !== "provider-wildcard")) { return {}; } - const { sessionAgentId } = resolveSessionAgentIds({ - config: params.config, - agentId: params.agentId, - sessionKey: params.sessionKey, - }); - const agentEntry = listAgentEntries(params.config).find( - (entry) => normalizeAgentId(entry.id) === sessionAgentId, - ); + const hasSessionScope = Boolean(params.agentId?.trim() || params.sessionKey?.trim()); + const sessionAgentId = hasSessionScope + ? resolveSessionAgentIds({ + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + }).sessionAgentId + : tryResolveLegacyCompatibilityAgentId(params.config); + const agentEntry = sessionAgentId + ? listAgentEntries(params.config).find((entry) => normalizeAgentId(entry.id) === sessionAgentId) + : undefined; const modelMaps: Array | undefined> = [ agentEntry?.models, params.config.agents?.defaults?.models, diff --git a/src/agents/openai-routing.test.ts b/src/agents/openai-routing.test.ts index b3c4c69d914a..387e2dc384b0 100644 --- a/src/agents/openai-routing.test.ts +++ b/src/agents/openai-routing.test.ts @@ -172,6 +172,40 @@ describe("OpenAI runtime routing policy", () => { ).toBe("openai"); }); + it("uses the configured fixed-store owner for agent-scoped request parameters", () => { + const config = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "research" } }, + entries: { + ops: {}, + research: { params: { store: false } }, + }, + }, + } satisfies OpenClawConfig; + + expect( + resolveOpenAIImplicitAgentRuntime({ + provider: "openai", + modelId: "gpt-5.5", + config, + sessionKey: "global", + env: {}, + }), + ).toBe("openclaw"); + expect(() => + resolveOpenAIImplicitAgentRuntime({ + provider: "openai", + modelId: "gpt-5.5", + config, + agentId: "ops", + sessionKey: "global", + env: {}, + }), + ).toThrow(/belongs to "research"/); + }); + it("honors explicit model runtime policy before the OpenAI base URL default", () => { const customCodexConfig = { agents: { diff --git a/src/agents/openai-routing.ts b/src/agents/openai-routing.ts index 5473cfccf945..a604445ff383 100644 --- a/src/agents/openai-routing.ts +++ b/src/agents/openai-routing.ts @@ -6,12 +6,12 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ProviderRouteOverridePresence } from "../plugin-sdk/provider-model-types.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId, resolveAgentScopedRuntimeOverride, } from "./agent-runtime-id.js"; +import { resolveSessionAgentIds } from "./agent-scope.js"; import { hasAuthoredProviderRequestParams } from "./model-extra-params.js"; import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; import { resolveOpenAIModelRoutes } from "./openai-model-routes.js"; @@ -51,8 +51,13 @@ export function resolveOpenAIImplicitAgentRuntime(params: { } const modelId = params.modelId; const agentId = - params.agentId ?? - (params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined); + params.config && (params.agentId?.trim() || params.sessionKey?.trim()) + ? resolveSessionAgentIds({ + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + }).sessionAgentId + : params.agentId; const hasConfiguredProviderRequestParams = hasAuthoredProviderRequestParams({ config: params.config, provider: params.provider ?? OPENAI_PROVIDER_ID, diff --git a/src/agents/openclaw-tools.agents.test.ts b/src/agents/openclaw-tools.agents.test.ts index ccfd07a2aa94..f4db16f537a3 100644 --- a/src/agents/openclaw-tools.agents.test.ts +++ b/src/agents/openclaw-tools.agents.test.ts @@ -31,7 +31,7 @@ describe("agents_list", () => { function createTool() { return createAgentsListTool({ - agentSessionKey: "main", + agentSessionKey: "agent:main:main", }); } diff --git a/src/agents/openclaw-tools.media-yield.test.ts b/src/agents/openclaw-tools.media-yield.test.ts new file mode 100644 index 000000000000..3b4abb65985a --- /dev/null +++ b/src/agents/openclaw-tools.media-yield.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { warn } = vi.hoisted(() => ({ warn: vi.fn() })); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ warn }), +})); + +import { createMediaGenerationAsyncStartCallback } from "./openclaw-tools.media-yield.js"; + +describe("createMediaGenerationAsyncStartCallback", () => { + afterEach(() => { + vi.useRealTimers(); + warn.mockClear(); + }); + + it("contains synchronous onYield failures", async () => { + vi.useFakeTimers(); + const callback = createMediaGenerationAsyncStartCallback({ + onYield: () => { + throw new Error("yield failed synchronously"); + }, + }); + + expect(callback).toBeTypeOf("function"); + callback?.("media generation started"); + expect(() => vi.runAllTimers()).not.toThrow(); + await Promise.resolve(); + + expect(warn).toHaveBeenCalledExactlyOnceWith( + "Failed to yield foreground media generation turn", + { error: "yield failed synchronously" }, + ); + }); +}); diff --git a/src/agents/openclaw-tools.media-yield.ts b/src/agents/openclaw-tools.media-yield.ts new file mode 100644 index 000000000000..2bc759dfffbe --- /dev/null +++ b/src/agents/openclaw-tools.media-yield.ts @@ -0,0 +1,23 @@ +import { formatErrorMessage } from "../infra/errors.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; + +const log = createSubsystemLogger("agents/tools/media-generation-yield"); + +export function createMediaGenerationAsyncStartCallback(params: { + sessionKey?: string; + onYield?: (message: string) => Promise | void; +}): ((message: string) => void) | undefined { + if (!params.onYield || (params.sessionKey && isCronRunSessionKey(params.sessionKey))) { + return undefined; + } + return (message) => { + setImmediate(() => { + void (async () => params.onYield?.(message))().catch((error: unknown) => { + log.warn("Failed to yield foreground media generation turn", { + error: formatErrorMessage(error), + }); + }); + }); + }; +} diff --git a/src/agents/openclaw-tools.registration.test.ts b/src/agents/openclaw-tools.registration.test.ts index b0d80e22b669..6d9edaa47052 100644 --- a/src/agents/openclaw-tools.registration.test.ts +++ b/src/agents/openclaw-tools.registration.test.ts @@ -405,7 +405,7 @@ function createSwarmToolNames(options: NonNullable tool.name); } @@ -421,7 +421,7 @@ describe("Swarm registration", () => { it("uses the effective requester agent override for the agents_wait gate", () => { const base = { - agentSessionKey: "agent:main:main", + agentSessionKey: "agent:worker:main", requesterAgentIdOverride: "worker", }; expect( @@ -430,10 +430,7 @@ describe("Swarm registration", () => { config: { tools: { swarm: false }, agents: { - list: [ - { id: "main", default: true }, - { id: "worker", tools: { swarm: true } }, - ], + list: [{ id: "main" }, { id: "worker", tools: { swarm: true } }], }, }, }), @@ -444,10 +441,7 @@ describe("Swarm registration", () => { config: { tools: { swarm: true }, agents: { - list: [ - { id: "main", default: true }, - { id: "worker", tools: { swarm: false } }, - ], + list: [{ id: "main" }, { id: "worker", tools: { swarm: false } }], }, }, }), @@ -554,6 +548,7 @@ describe("sessions_yield completion ownership", () => { expect(result.details).toMatchObject({ status: "yielded" }); expect(markRequesterTurnYielded).toHaveBeenCalledExactlyOnceWith({ + requesterAgentId: "main", requesterSessionKey: expectedSessionKey, requesterTurnRunId: "run-requester", }); diff --git a/src/agents/openclaw-tools.requester-yield.ts b/src/agents/openclaw-tools.requester-yield.ts new file mode 100644 index 000000000000..25a3dc80995b --- /dev/null +++ b/src/agents/openclaw-tools.requester-yield.ts @@ -0,0 +1,17 @@ +export function createRequesterYieldCallback(params: { + requesterSessionKey?: string; + requesterAgentId: string; + requesterTurnRunId?: string; +}): (() => Promise) | undefined { + if (!params.requesterSessionKey || !params.requesterTurnRunId) { + return undefined; + } + return async () => { + const { markRequesterTurnYielded } = await import("./subagents/registry/subagent-registry.js"); + markRequesterTurnYielded({ + requesterSessionKey: params.requesterSessionKey as string, + requesterAgentId: params.requesterAgentId, + requesterTurnRunId: params.requesterTurnRunId as string, + }); + }; +} diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 070d283484d7..7212ed56fcde 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -613,6 +613,68 @@ describe("session_status tool", () => { ); }); + it("uses the persisted fixed-store owner for a bare current session", async () => { + resetSessionStore({ + global: { + sessionId: "ops-global", + updatedAt: 10, + }, + }); + mockConfig = { + session: { mainKey: "main", scope: "global", store: "/tmp/shared-sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: {}, + sessionStore: { agentId: "ops" }, + }, + entries: { ops: {}, research: {} }, + }, + tools: { agentToAgent: { enabled: false } }, + }; + + const result = await createSessionStatusTool({ + agentSessionKey: "global", + config: mockConfig as never, + }).execute("owned-global", {}); + + expect(result.details).toMatchObject({ ok: true, sessionKey: "global" }); + expect(getSessionStateVersionMock).toHaveBeenCalledWith("global", "ops"); + }); + + it("does not treat another agent's fixed-store bare key as self", async () => { + resetSessionStore({ + global: { + sessionId: "ops-global", + updatedAt: 10, + }, + }); + mockConfig = { + session: { mainKey: "main", scope: "global", store: "/tmp/shared-sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: {}, + sessionStore: { agentId: "ops" }, + }, + entries: { ops: {}, research: {} }, + }, + tools: { agentToAgent: { enabled: false } }, + }; + + const tool = createSessionStatusTool({ + agentSessionKey: "agent:research:main", + requesterAgentIdOverride: "research", + config: mockConfig as never, + }); + + await expect(tool.execute("foreign-global", { sessionKey: "global" })).rejects.toThrow( + "Agent-to-agent status is disabled", + ); + }); + it("returns read-only state changes and the signal-log head", async () => { resetSessionStore({ main: { @@ -834,7 +896,7 @@ describe("session_status tool", () => { const result = await tool.execute("call-current-child", { sessionKey: "current" }); const details = result.details as { ok?: boolean; sessionKey?: string }; expect(details.ok).toBe(true); - expect(details.sessionKey).toBe("main"); + expect(details.sessionKey).toBe("agent:support:main"); }); it.each([ @@ -1758,7 +1820,7 @@ describe("session_status tool", () => { expect(text).not.toContain("all done"); }); - it("resolves a literal current sessionId in session_status", async () => { + it("resolves current as the requester alias before a colliding session id", async () => { resetSessionStore({ main: { sessionId: "s-main", @@ -1788,7 +1850,7 @@ describe("session_status tool", () => { const result = await tool.execute("call-current-literal-id", { sessionKey: "current" }); const details = result.details as { ok?: boolean; sessionKey?: string }; expect(details.ok).toBe(true); - expect(details.sessionKey).toBe("agent:main:other"); + expect(details.sessionKey).toBe("main"); }); it("keeps sessionKey=current bound to the requester subagent session", async () => { @@ -2075,6 +2137,51 @@ describe("session_status tool", () => { expect(details.sessionKey).toBe("agent:main:main"); }); + it("defers fixed-store ownership until a requester-owned sessionId resolves", async () => { + const sessionId = "research-session-id"; + resetSessionStore({ + "agent:research:incident": { + sessionId, + updatedAt: 10, + }, + }); + mockConfig = { + session: { mainKey: "main", scope: "global", store: "/tmp/shared-sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: {}, + sessionStore: { agentId: "ops" }, + }, + entries: { ops: {}, research: {} }, + }, + tools: { + agentToAgent: { enabled: false }, + sessions: { visibility: "all" }, + }, + }; + callGatewayMock.mockImplementation(async (requestValue: unknown) => { + const request = requestValue as { method?: string; params?: Record }; + if (request.method === "sessions.resolve") { + if (request.params?.key) { + return {}; + } + expect(request.params?.agentId).toBeUndefined(); + return { agentId: "research", key: "agent:research:incident" }; + } + return {}; + }); + + const result = await createSessionStatusTool({ + agentSessionKey: "agent:research:requester", + requesterAgentIdOverride: "research", + config: mockConfig as never, + }).execute("research-session-id", { sessionKey: sessionId }); + + expect(result.details).toMatchObject({ ok: true, sessionKey: "agent:research:incident" }); + }); + it("resolves duplicate sessionId inputs deterministically", async () => { resetSessionStore({ "agent:main:main": { @@ -2365,8 +2472,7 @@ describe("session_status tool", () => { }), ).rejects.toThrow(expectedError); - expect(loadSessionStoreMock).toHaveBeenCalledTimes(1); - expect(loadSessionStoreMock).toHaveBeenCalledWith("/tmp/main/sessions.json"); + expect(loadSessionStoreMock).not.toHaveBeenCalled(); expect(updateSessionStoreMock).not.toHaveBeenCalled(); expect(callGatewayMock).toHaveBeenCalledTimes(3); expect(callGatewayMock).toHaveBeenNthCalledWith(1, { @@ -2380,6 +2486,7 @@ describe("session_status tool", () => { expect(callGatewayMock).toHaveBeenNthCalledWith(2, { method: "sessions.resolve", params: { + agentId: "main", key: sessionId, spawnedBy: "agent:main:subagent:child", }, diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 17408ce51944..c5a1c9d3fb1f 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -2232,6 +2232,7 @@ describe("sessions tools", () => { sessions: [ { key: targetKey, + agentId: "main", deliveryContext: { channel: "whatsapp", to: "123@g.us", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts index 8d82508c7f0b..c8ab4f27bbea 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts @@ -65,7 +65,7 @@ async function spawn(params: { ...(params.sandbox ? { sandbox: params.sandbox } : {}), }, { - agentSessionKey: params.requesterSessionKey ?? "main", + agentSessionKey: params.requesterSessionKey ?? "agent:main:main", agentChannel: params.requesterChannel ?? "mobilechat", }, ); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 42ffd3f164bd..b182bf5cd0a6 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -11,8 +11,6 @@ import type { ConversationReadInvocationOrigin } from "../channels/plugins/conve import { selectApplicableRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isEmbeddedMode } from "../infra/embedded-mode.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state.js"; import { getActiveRuntimeWebToolsMetadataFromState } from "../secrets/runtime-web-tools-state.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; @@ -34,6 +32,7 @@ import { resolveImageToolFactoryAvailable, resolveOptionalMediaToolFactoryPlan, } from "./openclaw-tools.media-factory-plan.js"; +import { createMediaGenerationAsyncStartCallback } from "./openclaw-tools.media-yield.js"; import type { ModelAwareToolContext } from "./openclaw-tools.model-context.js"; import { applyNodesToolWorkspaceGuard } from "./openclaw-tools.nodes-workspace-guard.js"; import { @@ -41,6 +40,7 @@ import { shouldIncludeAskUserToolForOpenClawTools, shouldIncludeUpdatePlanToolForOpenClawTools, } from "./openclaw-tools.registration.js"; +import { createRequesterYieldCallback } from "./openclaw-tools.requester-yield.js"; import { createOpenClawSwarmToolGroups } from "./openclaw-tools.swarm.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import type { SpawnedToolContext } from "./spawned-context.js"; @@ -96,8 +96,6 @@ import { createVideoGenerateTool } from "./tools/video-generate-tool.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js"; import { resolveWorkspaceRoot } from "./workspace-dir.js"; -const mediaGenerationYieldLog = createSubsystemLogger("agents/tools/media-generation-yield"); - export { filterToolsByClientCaps } from "./openclaw-tools.client-caps.js"; export function createOpenClawTools( options?: { @@ -283,21 +281,10 @@ export function createOpenClawTools( trimmedRunSessionKey && isCronRunSessionKey(trimmedRunSessionKey) ? trimmedRunSessionKey : options?.agentSessionKey; - const yieldMediaGenerationTurn = options?.onYield; - const mediaGenerationAsyncStartCallback = - !yieldMediaGenerationTurn || - (mediaGenerationAgentSessionKey && isCronRunSessionKey(mediaGenerationAgentSessionKey)) - ? undefined - : (message: string) => { - // Commit the start before yielding; handle teardown failures outside the owner turn. - setImmediate(() => { - void (async () => yieldMediaGenerationTurn(message))().catch((error: unknown) => { - mediaGenerationYieldLog.warn("Failed to yield foreground media generation turn", { - error: formatErrorMessage(error), - }); - }); - }); - }; + const mediaGenerationAsyncStartCallback = createMediaGenerationAsyncStartCallback({ + sessionKey: mediaGenerationAgentSessionKey, + onYield: options?.onYield, + }); const taskKey = normalizeOptionalString(options?.runSessionKey ?? options?.agentSessionKey); const requesterSessionKey = trimmedRunSessionKey || options?.agentSessionKey; const requesterTurnRunId = options?.runId; @@ -333,6 +320,7 @@ export function createOpenClawTools( agentDir: options?.agentDir, authProfileStore: options?.authProfileStore, agentSessionKey: mediaGenerationAgentSessionKey, + requesterAgentId: sessionAgentId, requesterOrigin: deliveryContext ?? undefined, workspaceDir, preparedModelRuntime: options?.preparedModelRuntime, @@ -422,6 +410,7 @@ export function createOpenClawTools( options?.recordToolPrepStage?.("openclaw-tools:message-tool"); const nodesToolBase = createNodesTool({ agentSessionKey: options?.agentSessionKey, + agentId: sessionAgentId, agentChannel: options?.agentChannel, agentAccountId: options?.agentAccountId, currentChannelId: options?.currentChannelId, @@ -469,6 +458,7 @@ export function createOpenClawTools( const tools: AnyAgentTool[] = [ createDashboardTool({ agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, + agentId: sessionAgentId, }), ...(embedded ? [] @@ -489,6 +479,7 @@ export function createOpenClawTools( createCronTool({ // Use the durable runSessionKey; cleanup-retired policy keys leave cron jobs dangling. agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, + agentId: sessionAgentId, agentAccountId: gatewayCallerAccountId, config: options?.config, currentDeliveryContext: { @@ -507,11 +498,13 @@ export function createOpenClawTools( createSessionsTool({ agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, agentSessionId: options?.sessionId, + requesterAgentIdOverride: sessionAgentId, sandboxed: options?.sandboxed, config: resolvedConfig, }), createScreenTool({ agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, + agentId: sessionAgentId, }), ...(options?.sandboxed ? [] @@ -560,7 +553,7 @@ export function createOpenClawTools( ]), createAgentsListTool({ agentSessionKey: options?.agentSessionKey, - requesterAgentIdOverride: options?.requesterAgentIdOverride, + requesterAgentIdOverride: sessionAgentId, }), createGetGoalTool({ agentSessionKey: options?.agentSessionKey, @@ -606,12 +599,14 @@ export function createOpenClawTools( : []), createSessionsListTool({ agentSessionKey: options?.agentSessionKey, + requesterAgentIdOverride: sessionAgentId, sandboxed: options?.sandboxed, config: resolvedConfig, callGateway: effectiveCallGateway, }), createSessionsHistoryTool({ agentSessionKey: options?.agentSessionKey, + requesterAgentIdOverride: sessionAgentId, sandboxed: options?.sandboxed, config: resolvedConfig, callGateway: effectiveCallGateway, @@ -652,6 +647,7 @@ export function createOpenClawTools( // stamp for materialized agent roots (opts.callGateway === undefined // is the gate in ensureConfiguredAgentMainSession). createSessionsSendTool({ + agentId: sessionAgentId, agentSessionKey: options?.agentSessionKey, agentChannel: options?.agentChannel, sandboxed: options?.sandboxed, @@ -690,22 +686,21 @@ export function createOpenClawTools( ...swarmToolGroups.agentsWait, createSessionsYieldTool({ sessionId: options?.sessionId, - onBeforeYield: - requesterSessionKey && requesterTurnRunId - ? async () => { - const { markRequesterTurnYielded } = - await import("./subagents/registry/subagent-registry.js"); - markRequesterTurnYielded({ requesterSessionKey, requesterTurnRunId }); - } - : undefined, + onBeforeYield: createRequesterYieldCallback({ + requesterSessionKey, + requesterAgentId: sessionAgentId, + requesterTurnRunId, + }), onYield: options?.onYield, }), createSubagentsTool({ agentSessionKey: options?.agentSessionKey, + agentId: sessionAgentId, config: resolvedConfig, }), createSessionStatusTool({ agentSessionKey: options?.agentSessionKey, + requesterAgentIdOverride: sessionAgentId, runSessionKey: options?.runSessionKey, config: resolvedConfig, sandboxed: options?.sandboxed, diff --git a/src/agents/openclaw-tools.tts-config.test.ts b/src/agents/openclaw-tools.tts-config.test.ts index ff4f8ce59166..2d5197d4ce46 100644 --- a/src/agents/openclaw-tools.tts-config.test.ts +++ b/src/agents/openclaw-tools.tts-config.test.ts @@ -408,18 +408,20 @@ describe("createOpenClawTools cron context wiring", () => { disablePluginTools: true, }); - expect(mocks.createCronToolOptions).toHaveBeenCalledWith({ - agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", - agentAccountId: "bot-a", - creatorToolAllowlist: undefined, - currentDeliveryContext: { - channel: "matrix", - to: "room:!AbCdEf1234567890:example.org", - accountId: "bot-a", - threadId: "$RootEvent:Example.Org", - }, - runId: undefined, - }); + expect(mocks.createCronToolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", + agentAccountId: "bot-a", + creatorToolAllowlist: undefined, + currentDeliveryContext: { + channel: "matrix", + to: "room:!AbCdEf1234567890:example.org", + accountId: "bot-a", + threadId: "$RootEvent:Example.Org", + }, + runId: undefined, + }), + ); }); it("uses agent route context when auto-threading context is unavailable", async () => { @@ -433,18 +435,20 @@ describe("createOpenClawTools cron context wiring", () => { disablePluginTools: true, }); - expect(mocks.createCronToolOptions).toHaveBeenCalledWith({ - agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", - agentAccountId: "bot-a", - creatorToolAllowlist: undefined, - currentDeliveryContext: { - channel: "matrix", - to: "room:!FallbackRoom:Example.Org", - accountId: "bot-a", - threadId: "$FallbackThread:Example.Org", - }, - runId: undefined, - }); + expect(mocks.createCronToolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + agentSessionKey: "agent:main:matrix:channel:!abcdef1234567890:example.org", + agentAccountId: "bot-a", + creatorToolAllowlist: undefined, + currentDeliveryContext: { + channel: "matrix", + to: "room:!FallbackRoom:Example.Org", + accountId: "bot-a", + threadId: "$FallbackThread:Example.Org", + }, + runId: undefined, + }), + ); }); it("passes self-remove scope into the cron tool", async () => { @@ -455,15 +459,17 @@ describe("createOpenClawTools cron context wiring", () => { disablePluginTools: true, }); - expect(mocks.createCronToolOptions).toHaveBeenCalledWith({ - agentSessionKey: "agent:main:cron:job-current", - currentDeliveryContext: { - channel: undefined, - to: undefined, - accountId: undefined, - threadId: undefined, - }, - selfRemoveOnlyJobId: "job-current", - }); + expect(mocks.createCronToolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + agentSessionKey: "agent:main:cron:job-current", + currentDeliveryContext: { + channel: undefined, + to: undefined, + accountId: undefined, + threadId: undefined, + }, + selfRemoveOnlyJobId: "job-current", + }), + ); }); }); diff --git a/src/agents/prepared-model-catalog.test.ts b/src/agents/prepared-model-catalog.test.ts index 05c4373dd690..ef8111ff3826 100644 --- a/src/agents/prepared-model-catalog.test.ts +++ b/src/agents/prepared-model-catalog.test.ts @@ -25,6 +25,7 @@ vi.mock("./agent-scope.js", () => ({ resolveAgentWorkspaceDir: () => "/tmp/prepared-model-catalog-workspace", resolveDefaultAgentDir: () => "/tmp/prepared-model-catalog-agent", resolveDefaultAgentId: () => "main", + tryResolveLegacyCompatibilityAgentId: () => "main", })); vi.mock("./prepared-model-runtime.js", () => { diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts index 49b5fc2b9cf7..02c296050419 100644 --- a/src/agents/prepared-model-catalog.ts +++ b/src/agents/prepared-model-catalog.ts @@ -5,9 +5,10 @@ import { listAgentIds, resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentDir, resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, } from "./agent-scope.js"; +import { resolveLegacyInheritedAuthDir } from "./legacy-inherited-auth-dir.js"; import type { ModelCatalogEntry, ModelCatalogSnapshot } from "./model-catalog.types.js"; import { resolvePublishedModelCatalogOwner } from "./prepared-model-catalog-owner.js"; import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalog.errors.js"; @@ -82,12 +83,12 @@ function resolveInputs(params: LoadPreparedModelCatalogParams = {}): { } { const config = params.config ?? getRuntimeConfig(); const explicitOrDefaultAgentId = - params.agentId ?? (params.agentDir === undefined ? resolveDefaultAgentId(config) : undefined); + params.agentId ?? + (params.agentDir === undefined + ? (tryResolveLegacyCompatibilityAgentId(config) ?? resolveDefaultAgentId(config)) + : undefined); const agentDir = - params.agentDir ?? - (explicitOrDefaultAgentId - ? resolveAgentDir(config, explicitOrDefaultAgentId) - : resolveDefaultAgentDir(config, params.env)); + params.agentDir ?? resolveAgentDir(config, explicitOrDefaultAgentId as string, params.env); const matchingAgentIds = params.agentDir === undefined ? [] @@ -95,12 +96,7 @@ function resolveInputs(params: LoadPreparedModelCatalogParams = {}): { (candidateAgentId) => resolveAgentDir(config, candidateAgentId) === agentDir, ); const agentId = - explicitOrDefaultAgentId ?? - (params.agentDir === undefined - ? resolveDefaultAgentId(config) - : matchingAgentIds.length === 1 - ? matchingAgentIds[0] - : undefined); + explicitOrDefaultAgentId ?? (matchingAgentIds.length === 1 ? matchingAgentIds[0] : undefined); const explicitWorkspaceDir = params.workspaceDir === undefined ? undefined : params.workspaceDir; const activationWorkspaceDir = explicitWorkspaceDir ?? (agentId ? resolveAgentWorkspaceDir(config, agentId) : undefined); @@ -109,7 +105,7 @@ function resolveInputs(params: LoadPreparedModelCatalogParams = {}): { agentDir, config, ...(params.env ? { env: params.env } : {}), - inheritedAuthDir: resolveDefaultAgentDir(config, params.env), + inheritedAuthDir: resolveLegacyInheritedAuthDir(config, params.env), ...(explicitWorkspaceDir ? { workspaceDir: explicitWorkspaceDir } : {}), ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), }; diff --git a/src/agents/prepared-model-registry.test.ts b/src/agents/prepared-model-registry.test.ts index 826659d1b9e0..b1eb6a5e723c 100644 --- a/src/agents/prepared-model-registry.test.ts +++ b/src/agents/prepared-model-registry.test.ts @@ -22,6 +22,11 @@ vi.mock("./agent-scope.js", () => ({ resolveAgentWorkspaceDir: (_config: unknown, agentId: string) => `/workspaces/${agentId}`, resolveDefaultAgentDir: () => "/agents/main", resolveDefaultAgentId: () => "main", + tryResolveLegacyCompatibilityAgentId: () => undefined, +})); + +vi.mock("./legacy-inherited-auth-dir.js", () => ({ + resolveLegacyInheritedAuthDir: () => "/agents/main", })); vi.mock("./agent-model-discovery.js", () => ({ diff --git a/src/agents/prepared-model-registry.ts b/src/agents/prepared-model-registry.ts index 809bb6d9dcef..6cd869604a1a 100644 --- a/src/agents/prepared-model-registry.ts +++ b/src/agents/prepared-model-registry.ts @@ -6,9 +6,10 @@ import { normalizeDiscoveredAgentModel } from "./agent-model-discovery.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentDir, resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, } from "./agent-scope.js"; +import { resolveLegacyInheritedAuthDir } from "./legacy-inherited-auth-dir.js"; import { acquireReadOnlyPreparedModelRuntime, prepareModelRuntimeSnapshot, @@ -120,14 +121,17 @@ function resolveInput( config: OpenClawConfig, options: LoadPreparedAgentModelRegistryOptions = {}, ): PreparedModelRuntimeInput { - const agentId = options.agentId ?? resolveDefaultAgentId(config); + const agentId = + options.agentId ?? + tryResolveLegacyCompatibilityAgentId(config) ?? + resolveDefaultAgentId(config); const agentDir = options.agentDir ?? resolveAgentDir(config, agentId); const workspaceDir = options.workspaceDir ?? resolveAgentWorkspaceDir(config, agentId); return { agentId, agentDir, config, - inheritedAuthDir: resolveDefaultAgentDir(config), + inheritedAuthDir: resolveLegacyInheritedAuthDir(config), ...(usesCredentialFreeRegistry(options) ? { skipCredentials: true } : {}), ...(workspaceDir ? { workspaceDir } : {}), }; diff --git a/src/agents/prepared-model-runtime.inbound-registry.test.ts b/src/agents/prepared-model-runtime.inbound-registry.test.ts index 4c2cd3101e8f..8dbe5ab98200 100644 --- a/src/agents/prepared-model-runtime.inbound-registry.test.ts +++ b/src/agents/prepared-model-runtime.inbound-registry.test.ts @@ -1,6 +1,7 @@ import "./prepared-model-runtime.test-harness.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { acquireAgentRunPreparedModelRuntime, @@ -102,7 +103,7 @@ describe("prepared reply dispatch runtime", () => { it("resolves the configured inbound registry across a launch-workspace override", async () => { mocks.configuredAgentIds = ["default"]; - const config = {}; + const config = retainLegacyDefaultAgentId({ agents: { entries: { default: {} } } }, "default"); await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true, catalogMode: "static", diff --git a/src/agents/prepared-model-runtime.lifecycle.test.ts b/src/agents/prepared-model-runtime.lifecycle.test.ts index 38f26f2a7418..139490d9ad7f 100644 --- a/src/agents/prepared-model-runtime.lifecycle.test.ts +++ b/src/agents/prepared-model-runtime.lifecycle.test.ts @@ -1,5 +1,6 @@ import "./prepared-model-runtime.test-harness.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { acquireAgentRunPreparedModelRuntime, acquireReadOnlyPreparedModelRuntime, @@ -132,7 +133,10 @@ describe("prepared model runtime snapshots", () => { it("does not let a read-only draft replace a configured gateway owner", async () => { mocks.configuredAgentIds = ["default"]; - const configured = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + const configured = retainLegacyDefaultAgentId( + { agents: { defaults: { model: "openai/gpt-5.5" }, entries: { default: {} } } }, + "default", + ); await refreshPreparedModelRuntimeSnapshots(configured, { gatewayLifecycle: true, defaultWorkspaceDir: "/tmp/gateway-launch-workspace", @@ -550,7 +554,7 @@ describe("prepared model runtime snapshots", () => { it("reuses the configured owner at canonical gateway run admission", async () => { mocks.configuredAgentIds = ["default"]; - const config = {}; + const config = retainLegacyDefaultAgentId({ agents: { entries: { default: {} } } }, "default"); await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true, defaultWorkspaceDir: "/tmp/gateway-launch-workspace", @@ -561,7 +565,6 @@ describe("prepared model runtime snapshots", () => { config, agentDir: "/tmp/unused-agent", inheritedAuthDir: "/tmp/unused-agent", - workspaceDir: "/tmp/gateway-launch-workspace", }); expect(lease.snapshot.workspaceDir).toBe("/tmp/gateway-launch-workspace"); @@ -572,7 +575,6 @@ describe("prepared model runtime snapshots", () => { config, agentDir: "/tmp/unused-agent", inheritedAuthDir: "/tmp/unused-agent", - workspaceDir: "/tmp/gateway-launch-workspace", }), ).resolves.toBe(lease.snapshot); expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); diff --git a/src/agents/prepared-model-runtime.owner-selection.test.ts b/src/agents/prepared-model-runtime.owner-selection.test.ts index dc8fe12e15c0..d08f457008b5 100644 --- a/src/agents/prepared-model-runtime.owner-selection.test.ts +++ b/src/agents/prepared-model-runtime.owner-selection.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { acquireAgentRunPreparedModelRuntime, @@ -81,7 +82,7 @@ describe("prepared model runtime owner selection", () => { it("finds the configured gateway owner when request config omits its launch workspace", async () => { mocks.configuredAgentIds = ["default"]; - const config = {}; + const config = retainLegacyDefaultAgentId({ agents: { entries: { default: {} } } }, "default"); await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true, @@ -101,7 +102,10 @@ describe("prepared model runtime owner selection", () => { // and that flag is part of the owner key. A request that omits it matches no // owner, and standalone activation stays refused while the lifecycle is active. mocks.configuredAgentIds = ["default"]; - const config = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + const config = retainLegacyDefaultAgentId( + { agents: { defaults: { model: "openai/gpt-5.5" }, entries: { default: {} } } }, + "default", + ); await refreshPreparedModelRuntimeSnapshots(config, { allowGatewaySubagentBinding: true, catalogMode: "static", diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index d9ec8f5cd393..c9c6be8a320d 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isReservedSystemAgentId } from "../system-agent/agent-id.js"; @@ -8,14 +9,13 @@ import { resolveAgentDir, resolveRunModelFallbacksOverride, resolveAgentWorkspaceDir, - resolveDefaultAgentDir, - resolveDefaultAgentId, } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { requiresAgentHarnessPluginSelection, resolveSelectedAgentHarnessRuntime, } from "./harness/runtime-plugin-load-plan.js"; +import { resolveLegacyInheritedAuthDir } from "./legacy-inherited-auth-dir.js"; import { resolveModelCandidateChain } from "./model-fallback-candidates.js"; import { resolveDefaultModelForAgent } from "./model-selection-config.js"; import { @@ -215,7 +215,7 @@ export function normalizePreparedModelRuntimeInput( ...rest } = input; const inheritedAuthDir = normalizeOptionalDir( - input.inheritedAuthDir ?? resolveDefaultAgentDir(input.config, input.env), + input.inheritedAuthDir ?? resolveLegacyInheritedAuthDir(input.config, input.env), ); const workspaceDir = normalizeOptionalDir(input.workspaceDir); const env = input.env ? Object.freeze({ ...input.env }) : undefined; @@ -336,10 +336,10 @@ export function listConfiguredOwnerInputs( defaultWorkspaceDir?: string, allowGatewaySubagentBinding?: boolean, ): PreparedModelRuntimeInput[] { - const inheritedAuthDir = resolveDefaultAgentDir(config); - const defaultAgentId = resolveDefaultAgentId(config); + const compatibilityAgentId = tryResolveLegacyCompatibilityAgentId(config); + const inheritedAuthDir = resolveLegacyInheritedAuthDir(config); return listAgentIds(config).map((agentId) => { - const preserveWorkspaceDirOnRefresh = agentId === defaultAgentId && defaultWorkspaceDir; + const preserveWorkspaceDirOnRefresh = agentId === compatibilityAgentId && defaultWorkspaceDir; const input: PreparedModelRuntimeInput = { agentId, agentDir: resolveAgentDir(config, agentId), diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index 621ed4ab21c6..01fd2e07eeba 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -120,13 +120,19 @@ vi.mock("../plugins/synthetic-auth.runtime.js", () => ({ resolveRuntimeSyntheticAuthProviderRefs: () => [], })); +vi.mock("./legacy-inherited-auth-dir.js", () => ({ + resolveLegacyInheritedAuthDir: () => "/tmp/prepared-static-agent", +})); + vi.mock("./agent-scope.js", () => ({ listAgentEntries: (config: { agents?: { list?: unknown[] } }) => config.agents?.list ?? [], listAgentIds: () => ["default"], resolveAgentDir: () => "/tmp/prepared-static-agent", resolveAgentWorkspaceDir: () => "/tmp/prepared-static-workspace", + tryResolveConfiguredAgentWorkspaceDir: () => "/tmp/prepared-static-workspace", resolveDefaultAgentDir: () => "/tmp/prepared-static-agent", resolveDefaultAgentId: () => "default", + tryResolveSoleAgentId: () => "default", resolveAgentEffectiveModelPrimary: () => undefined, resolveRunModelFallbacksOverride: () => undefined, resolveSessionAgentIds: ({ agentId }: { agentId?: string }) => ({ diff --git a/src/agents/prepared-model-runtime.test-harness.ts b/src/agents/prepared-model-runtime.test-harness.ts index 1dee6c406061..ff992ad36138 100644 --- a/src/agents/prepared-model-runtime.test-harness.ts +++ b/src/agents/prepared-model-runtime.test-harness.ts @@ -11,6 +11,22 @@ type CreateStaticCatalogResolver = type StaticCatalogResolver = ReturnType; const preparedModelRuntimeMocks = vi.hoisted(() => ({ + pluginMetadataSnapshot: { + plugins: [], + pluginIds: [], + index: { plugins: [] }, + manifestRegistry: { plugins: [], diagnostics: [] }, + owners: { + channels: new Map(), + channelConfigs: new Map(), + providers: new Map(), + modelCatalogProviders: new Map(), + cliBackends: new Map(), + setupProviders: new Map(), + commandAliases: new Map(), + contracts: new Map(), + }, + }, preparedAuthStore: undefined as import("./auth-profiles/types.js").AuthProfileStore | undefined, preparedAuthMaterializations: [] as import("./auth-profiles/runtime-materializations.js").RuntimeAuthMaterialization[], @@ -62,6 +78,12 @@ const preparedModelRuntimeMocks = vi.hoisted(() => ({ >(), })); +vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ + isPluginMetadataSnapshotCompatible: () => true, + loadPluginMetadataSnapshot: () => preparedModelRuntimeMocks.pluginMetadataSnapshot, + resolvePluginMetadataSnapshot: () => preparedModelRuntimeMocks.pluginMetadataSnapshot, +})); + vi.mock("./model-catalog.js", () => ({ buildPreparedModelCatalogSnapshot: (...args: Parameters) => preparedModelRuntimeMocks.buildPreparedModelCatalogSnapshot(...args), @@ -103,6 +125,7 @@ vi.mock("./agent-scope.js", () => ({ resolveAgentWorkspaceDir: (_config: unknown, agentId: string) => preparedModelRuntimeMocks.configuredWorkspaces.get(agentId) ?? (agentId === "default" ? "/tmp/unused-workspace" : `/tmp/workspace-${agentId}`), + tryResolveConfiguredAgentWorkspaceDir: () => "/tmp/unused-workspace", resolveDefaultAgentDir: () => "/tmp/unused-agent", resolveDefaultAgentId: () => "default", resolveAgentConfig: (config: { agents?: { list?: Array<{ id?: string }> } }, agentId: string) => @@ -116,6 +139,10 @@ vi.mock("./agent-scope.js", () => ({ }), })); +vi.mock("./legacy-inherited-auth-dir.js", () => ({ + resolveLegacyInheritedAuthDir: () => "/tmp/unused-agent", +})); + vi.mock("./auth-profiles/runtime-materializations.js", () => ({ getPreparedRuntimeAuthMaterializations: () => preparedModelRuntimeMocks.preparedAuthMaterializations, diff --git a/src/agents/run-session-target.test.ts b/src/agents/run-session-target.test.ts index 3a960f33fe72..d308e313d137 100644 --- a/src/agents/run-session-target.test.ts +++ b/src/agents/run-session-target.test.ts @@ -106,6 +106,81 @@ describe("agent run session target", () => { ).resolves.toMatchObject({ sessionId, sessionKey, storePath }); }); + it("resolves an existing bare row through its persisted fixed-store owner", async () => { + const storePath = path.join(tempDir, "fixed-owner", "sessions.json"); + const sessionId = "fixed-owner-session"; + await upsertSessionEntryCore( + { agentId: "ops", sessionKey: "global", storePath }, + { sessionId, updatedAt: 1 }, + ); + + await expect( + resolveAgentRunSessionTarget({ + config: { + session: { store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }, + sessionId, + }), + ).resolves.toMatchObject({ + agentId: "ops", + sessionId, + sessionKey: "global", + storePath, + }); + }); + + it("keeps a scoped live row available when the fixed-store owner is retired", async () => { + const storePath = path.join(tempDir, "retired-owner", "sessions.json"); + const sessionId = "research-session"; + const sessionKey = "agent:research:work"; + await upsertSessionEntryCore( + { agentId: "research", sessionKey, storePath }, + { sessionId, updatedAt: 1 }, + ); + + await expect( + resolveAgentRunSessionTarget({ + config: { + session: { store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {}, support: {} }, + }, + }, + sessionId, + }), + ).resolves.toMatchObject({ agentId: "research", sessionId, sessionKey, storePath }); + }); + + it("finds an agent-scoped row by id in an ownerless explicit fleet", async () => { + const storePath = path.join(tempDir, "ownerless", "sessions.json"); + const sessionId = "research-session"; + const sessionKey = "agent:research:work"; + await upsertSessionEntryCore( + { agentId: "research", sessionKey, storePath }, + { sessionId, updatedAt: 1 }, + ); + + await expect( + resolveAgentRunSessionTarget({ + config: { + session: { store: storePath }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }, + sessionId, + }), + ).resolves.toMatchObject({ agentId: "research", sessionId, sessionKey, storePath }); + }); + it("uses the active runtime store when compatibility projection omits config", async () => { const storePath = path.join(tempDir, "runtime-config", "sessions.json"); const sessionId = "7ef14ab2-4801-40e1-9c56-83f9250c1706"; @@ -139,6 +214,26 @@ describe("agent run session target", () => { }); }); + it("does not create a key for an unknown session id during cross-agent lookup", async () => { + const storePath = path.join(tempDir, "missing-cross-agent", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + config: { + session: { store: storePath }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }, + sessionId: "unknown-cross-agent-session", + }), + ).rejects.toMatchObject({ + code: "session-key-missing", + name: "AgentRunSessionTargetResolutionError", + }); + }); + it("round-trips a plain compatibility key through sessionFile", async () => { const storePath = path.join(tempDir, "fallback", "sessions.json"); diff --git a/src/agents/run-session-target.ts b/src/agents/run-session-target.ts index b2665f747cc1..7ac36a771862 100644 --- a/src/agents/run-session-target.ts +++ b/src/agents/run-session-target.ts @@ -9,14 +9,15 @@ import { resolveSessionTranscriptRuntimeTarget, type SessionTranscriptRuntimeTarget, } from "../config/sessions/session-accessor.js"; +import { resolvePersistedSessionStoreOwnerForTarget } from "../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - parseAgentSessionKey, - resolveAgentIdFromSessionKey, - toAgentStoreSessionKey, -} from "../routing/session-key.js"; +import { parseAgentSessionKey, toAgentStoreSessionKey } from "../routing/session-key.js"; import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; -import { resolveDefaultAgentId } from "./agent-scope.js"; +import { resolveSessionAgentId } from "./agent-scope.js"; +import { + resolveExistingSessionKeyForRequest, + resolveStoredSessionKeyForSessionId, +} from "./command/session.js"; /** Identifies a run transcript target without naming the current storage artifact. */ export type AgentRunSessionTarget = { @@ -129,23 +130,70 @@ export async function resolveAgentRunSessionTarget(params: { ) { throw new Error("Legacy SQLite transcript marker session key is ambiguous"); } - const lookupAgentId = agentId ?? resolveDefaultAgentId(config); + const preliminarySessionKey = + targetSessionKey ?? suppliedSessionKey ?? compatibilitySessionKey ?? markerSessionKey; + const preliminaryCompatibilityKeyAgentId = parseAgentSessionKey(compatibilitySessionKey)?.agentId; + if ( + !targetSessionKey && + !suppliedSessionKey && + preliminarySessionKey === compatibilitySessionKey && + preliminaryCompatibilityKeyAgentId && + agentId && + preliminaryCompatibilityKeyAgentId !== agentId + ) { + throw new Error("Compatibility session key conflicts with the supplied agent identity"); + } + const targetStoreOwner = resolvePersistedSessionStoreOwnerForTarget({ + config, + sessionKey: preliminarySessionKey, + storePath: targetStorePath, + }); + const trustExplicitAlternateStoreAgent = Boolean( + targetAgentId && + targetStorePath && + !parseAgentSessionKey(preliminarySessionKey)?.agentId && + targetStoreOwner.kind === "none", + ); + const shouldResolveConfiguredStoreRow = + params.missingSessionKey === "resolve-existing" && + !preliminarySessionKey && + !targetStorePath && + !legacyMarker; + const configuredStoreResolution = shouldResolveConfiguredStoreRow + ? agentId + ? resolveStoredSessionKeyForSessionId({ + cfg: config, + sessionId, + agentId, + }) + : resolveExistingSessionKeyForRequest({ cfg: config, sessionId, clone: false }) + : undefined; + const lookupAgentId = + (hasCompleteTypedTarget || trustExplicitAlternateStoreAgent ? targetAgentId : undefined) ?? + legacyMarker?.agentId ?? + configuredStoreResolution?.agentId ?? + resolveSessionAgentId({ + agentId: targetAgentId ?? params.agentId, + config, + sessionKey: + preliminarySessionKey ?? (params.missingSessionKey === "create" ? sessionId : undefined), + }); const lookupStorePath = targetStorePath ?? legacyMarker?.storePath ?? + configuredStoreResolution?.storePath ?? resolveSessionStorePathCore(config.session?.store, { agentId: lookupAgentId }); const storedSessionKey = - params.missingSessionKey === "resolve-existing" && - !targetSessionKey && - !suppliedSessionKey && - !compatibilitySessionKey && - !markerSessionKey + configuredStoreResolution?.sessionKey ?? + (params.missingSessionKey === "resolve-existing" && + !preliminarySessionKey && + !shouldResolveConfiguredStoreRow ? resolveTranscriptSessionKeyBySessionId({ agentId: lookupAgentId, sessionId, storePath: lookupStorePath, }) - : undefined; + : undefined); const createdSessionKey = params.missingSessionKey === "create" ? toAgentStoreSessionKey({ agentId: lookupAgentId, requestKey: sessionId }) @@ -200,7 +248,15 @@ export async function resolveAgentRunSessionTarget(params: { throw new AgentRunSessionTargetResolutionError(sessionId); } const effectiveAgentId = - agentId ?? resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(config)); + (hasCompleteTypedTarget || trustExplicitAlternateStoreAgent ? targetAgentId : undefined) ?? + legacyMarker?.agentId ?? + configuredStoreResolution?.agentId ?? + resolveSessionAgentId({ + agentId: targetAgentId ?? params.agentId, + config, + fallbackAgentId: lookupAgentId, + sessionKey, + }); if (sessionTarget && sessionKey) { const storePath = targetStorePath ?? diff --git a/src/agents/run-wait.ts b/src/agents/run-wait.ts index ea2a65a6459d..fad04fe7a4d2 100644 --- a/src/agents/run-wait.ts +++ b/src/agents/run-wait.ts @@ -332,6 +332,7 @@ export function hasUpdatedAssistantReplySnapshot( /** Read the latest non-tool assistant message for a session. */ export async function readLatestAssistantReplySnapshot(params: { sessionKey: string; + agentId?: string; limit?: number; // Waited reply paths stop at transcript artifacts so they do not resurrect // an older assistant message as a fresh post-run reply. @@ -342,7 +343,11 @@ export async function readLatestAssistantReplySnapshot(params: { messages: Array; }>({ method: "chat.history", - params: { sessionKey: params.sessionKey, limit: params.limit ?? 50 }, + params: { + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + limit: params.limit ?? 50, + }, }); return resolveLatestAssistantReplySnapshot( stripToolMessages(Array.isArray(history?.messages) ? history.messages : []), @@ -353,12 +358,14 @@ export async function readLatestAssistantReplySnapshot(params: { /** Read only the latest assistant text for call sites that do not need fingerprints. */ export async function readLatestAssistantReply(params: { sessionKey: string; + agentId?: string; limit?: number; callGateway?: GatewayCaller; }): Promise { return ( await readLatestAssistantReplySnapshot({ sessionKey: params.sessionKey, + agentId: params.agentId, limit: params.limit, callGateway: params.callGateway, }) @@ -407,6 +414,7 @@ export async function waitForAgentRun(params: { export async function waitForAgentRunAndReadUpdatedAssistantReply(params: { runId: string; sessionKey: string; + agentId?: string; timeoutMs: number; limit?: number; baseline?: AssistantReplySnapshot; @@ -423,6 +431,7 @@ export async function waitForAgentRunAndReadUpdatedAssistantReply(params: { const latestReply = await readLatestAssistantReplySnapshot({ sessionKey: params.sessionKey, + agentId: params.agentId, limit: params.limit, stopAtTranscriptArtifact: true, callGateway: params.callGateway, diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index cab3a87fb4d5..2e3346a7ca72 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -101,6 +101,7 @@ async function ensureSandboxWorkspaceLayout(params: { resolveSandboxWorkspaceLayoutPaths({ cfg, rawSessionKey, + agentId: params.agentId, workspaceDir: params.workspaceDir, }); diff --git a/src/agents/sandbox/runtime-status.ts b/src/agents/sandbox/runtime-status.ts index c67843b13b98..396f092c7c76 100644 --- a/src/agents/sandbox/runtime-status.ts +++ b/src/agents/sandbox/runtime-status.ts @@ -60,9 +60,14 @@ export function resolveSandboxRuntimeStatus(params: { cfg?: OpenClawConfig; sessionKey?: string; agentId?: string; + /** Independent execution identity used for sandbox mode and policy classification. */ + classificationSessionKey?: string; + classificationAgentId?: string; }): { agentId: string; sessionKey: string; + classificationAgentId: string; + classificationSessionKey: string; mainSessionKey: string; mode: SandboxConfig["mode"]; sandboxed: boolean; @@ -74,23 +79,35 @@ export function resolveSandboxRuntimeStatus(params: { config: params.cfg, agentId: params.agentId, }); + const classificationSessionKey = params.classificationSessionKey?.trim() || sessionKey; + const classificationAgentId = resolveSessionAgentId({ + sessionKey: classificationSessionKey, + config: params.cfg, + agentId: params.classificationAgentId, + }); const cfg = params.cfg; - const sandboxCfg = resolveSandboxConfigForAgent(cfg, agentId); - const mainSessionKey = resolveMainSessionKeyForSandbox({ cfg, agentId }); - const sandboxed = sessionKey + const sandboxCfg = resolveSandboxConfigForAgent(cfg, classificationAgentId); + const mainSessionKey = resolveMainSessionKeyForSandbox({ cfg, agentId: classificationAgentId }); + const sandboxed = classificationSessionKey ? shouldSandboxSession( sandboxCfg, - resolveComparableSessionKeyForSandbox({ cfg, agentId, sessionKey }), + resolveComparableSessionKeyForSandbox({ + cfg, + agentId: classificationAgentId, + sessionKey: classificationSessionKey, + }), mainSessionKey, ) : false; return { agentId, sessionKey, + classificationAgentId, + classificationSessionKey, mainSessionKey, mode: sandboxCfg.mode, sandboxed, - toolPolicy: resolveSandboxToolPolicyForAgent(cfg, agentId), + toolPolicy: resolveSandboxToolPolicyForAgent(cfg, classificationAgentId), }; } diff --git a/src/agents/sandbox/shared.test.ts b/src/agents/sandbox/shared.test.ts index cbf151eb4f66..4c234e519830 100644 --- a/src/agents/sandbox/shared.test.ts +++ b/src/agents/sandbox/shared.test.ts @@ -79,4 +79,19 @@ describe("resolveSandboxWorkspaceLayoutPaths", () => { expect(createLayout("shared", workspaceA).scopeKey).toBe("shared"); expect(createLayout("shared", workspaceB).scopeKey).toBe("shared"); }); + + it("uses the prepared agent owner for a bare agent-scoped session key", () => { + const layout = resolveSandboxWorkspaceLayoutPaths({ + cfg: { + scope: "agent", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + }, + rawSessionKey: "global", + agentId: "research", + workspaceDir: workspaceA, + }); + + expect(layout.scopeKey).toMatch(/^agent:research:workspace:[a-f0-9]{32}$/); + }); }); diff --git a/src/agents/sandbox/shared.ts b/src/agents/sandbox/shared.ts index 715dd3bf7e80..c780537c43f1 100644 --- a/src/agents/sandbox/shared.ts +++ b/src/agents/sandbox/shared.ts @@ -62,6 +62,7 @@ function resolveSandboxScopeKey( scope: "session" | "agent" | "shared", sessionKey: string, workspaceDir: string, + agentId?: string, ) { const trimmed = sessionKey.trim() || "main"; if (scope === "shared") { @@ -73,8 +74,10 @@ function resolveSandboxScopeKey( if (scope === "session") { return `${trimmed}${workspaceSuffix}`; } - const agentId = resolveAgentIdFromSessionKey(trimmed); - return `agent:${agentId}${workspaceSuffix}`; + const resolvedAgentId = agentId + ? normalizeAgentId(agentId) + : resolveAgentIdFromSessionKey(trimmed); + return `agent:${resolvedAgentId}${workspaceSuffix}`; } /** Extracts the agent id represented by a sandbox scope key, when one exists. */ @@ -94,6 +97,7 @@ export function resolveSandboxAgentId(scopeKey: string): string | undefined { export function resolveSandboxWorkspaceLayoutPaths(params: { cfg: Pick; rawSessionKey: string; + agentId?: string; workspaceDir?: string; }) { const agentWorkspaceDir = resolveUserPath( @@ -104,6 +108,7 @@ export function resolveSandboxWorkspaceLayoutPaths(params: { params.cfg.scope, params.rawSessionKey, agentWorkspaceDir, + params.agentId, ); const sandboxWorkspaceDir = params.cfg.scope === "shared" diff --git a/src/agents/sandbox/tool-policy.test.ts b/src/agents/sandbox/tool-policy.test.ts index 56dc7022a81e..d9d6408113ae 100644 --- a/src/agents/sandbox/tool-policy.test.ts +++ b/src/agents/sandbox/tool-policy.test.ts @@ -224,6 +224,104 @@ describe("sandbox/tool-policy", () => { ).toBe(true); }); + it("classifies a borrowed runtime key under its own sandbox agent", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { + main: {}, + worker: { + sandbox: { mode: "non-main", scope: "agent" }, + tools: { sandbox: { tools: { deny: ["sessions_list"] } } }, + }, + }, + }, + } satisfies OpenClawConfig; + + const runtime = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: "agent:main:main", + agentId: "main", + classificationSessionKey: "agent:worker:discord:default:direct:peer-42", + classificationAgentId: "worker", + }); + + expect(runtime).toMatchObject({ + agentId: "main", + sessionKey: "agent:main:main", + classificationAgentId: "worker", + classificationSessionKey: "agent:worker:discord:default:direct:peer-42", + sandboxed: true, + }); + expect(runtime.toolPolicy.deny).toContain("sessions_list"); + }); + + it("recognizes the classification agent's main session in non-main mode", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { + main: {}, + worker: { sandbox: { mode: "non-main", scope: "agent" } }, + }, + }, + } satisfies OpenClawConfig; + + const runtime = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: "agent:main:main", + agentId: "main", + classificationSessionKey: "agent:worker:main", + classificationAgentId: "worker", + }); + + expect(runtime.agentId).toBe("main"); + expect(runtime.classificationAgentId).toBe("worker"); + expect(runtime.sandboxed).toBe(false); + }); + + it("rejects a classification agent that conflicts with its session key", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { main: {}, worker: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => + resolveSandboxRuntimeStatus({ + cfg, + sessionKey: "agent:main:main", + agentId: "main", + classificationSessionKey: "agent:worker:main", + classificationAgentId: "main", + }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + }); + + it("keeps the session identity as the sandbox classification when none is supplied", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { main: { sandbox: { mode: "non-main", scope: "agent" } } }, + }, + } satisfies OpenClawConfig; + + const runtime = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: "agent:main:telegram:default:direct:42", + agentId: "main", + }); + + expect(runtime).toMatchObject({ + agentId: "main", + sessionKey: "agent:main:telegram:default:direct:42", + classificationAgentId: "main", + classificationSessionKey: "agent:main:telegram:default:direct:42", + sandboxed: true, + }); + }); + it("keeps the agent main session sandboxed in all mode", () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/agents/session-agent-binding.test.ts b/src/agents/session-agent-binding.test.ts new file mode 100644 index 000000000000..3ad4b847c42e --- /dev/null +++ b/src/agents/session-agent-binding.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveBoundAgentIdForSession } from "./session-agent-binding.js"; + +describe("resolveBoundAgentIdForSession", () => { + it("binds a bare global key to the persisted fixed-store owner", () => { + const config = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { scope: "global", store: "/tmp/openclaw-shared-sessions.sqlite" }, + } satisfies OpenClawConfig; + + expect(resolveBoundAgentIdForSession({ config, sessionKey: "global" })).toBe("ops"); + }); +}); diff --git a/src/agents/session-agent-binding.ts b/src/agents/session-agent-binding.ts index 144c861d3f83..fc45e974da64 100644 --- a/src/agents/session-agent-binding.ts +++ b/src/agents/session-agent-binding.ts @@ -3,17 +3,9 @@ * * Derives the trusted active agent from explicit agent ids, agent session keys, or configured main-session aliases. */ -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "@openclaw/normalization-core/string-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - parseAgentSessionKey, - normalizeAgentId, - normalizeMainKey, -} from "../routing/session-key.js"; -import { resolveDefaultAgentId } from "./agent-scope.js"; +import { resolveSessionAgentId } from "./agent-scope.js"; /** * Resolve the trusted active agent bound to a host-owned session reference. @@ -23,25 +15,12 @@ export function resolveBoundAgentIdForSession(params: { sessionKey?: string; agentId?: string; }): string | undefined { - const explicitAgentId = normalizeOptionalString(params.agentId); - if (explicitAgentId) { - return normalizeAgentId(explicitAgentId); - } - - const normalizedSessionKey = normalizeOptionalString(params.sessionKey); - if (!normalizedSessionKey) { + if (!normalizeOptionalString(params.agentId) && !normalizeOptionalString(params.sessionKey)) { return undefined; } - - const parsed = parseAgentSessionKey(normalizedSessionKey); - if (parsed?.agentId) { - return normalizeAgentId(parsed.agentId); - } - - const loweredSessionKey = normalizeLowercaseStringOrEmpty(normalizedSessionKey); - const mainKey = normalizeMainKey(params.config?.session?.mainKey); - if (loweredSessionKey === "main" || loweredSessionKey === mainKey) { - return resolveDefaultAgentId(params.config ?? {}); - } - return undefined; + return resolveSessionAgentId({ + config: params.config, + sessionKey: params.sessionKey, + agentId: params.agentId, + }); } diff --git a/src/agents/spawn-plan.ts b/src/agents/spawn-plan.ts index 6a5afc931544..3b988a6b58bc 100644 --- a/src/agents/spawn-plan.ts +++ b/src/agents/spawn-plan.ts @@ -309,6 +309,7 @@ export function resolveSpawnAdmission(params: { } const callerDepth = getSubagentDepthFromSessionStore(params.requesterSessionKey, { cfg: params.cfg, + agentId: params.requesterAgentId, }); const maxSpawnDepth = params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; @@ -330,8 +331,10 @@ export function resolveSpawnAdmission(params: { maxSpawnDepth, collect: false, activeChildren: - countActiveRunsForSession(params.requesterSessionKey, { collect: false }) + - (params.additionalActiveChildren ?? 0), + countActiveRunsForSession(params.requesterSessionKey, { + collect: false, + requesterAgentId: params.requesterAgentId, + }) + (params.additionalActiveChildren ?? 0), maxActiveChildren: params.cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT, diff --git a/src/agents/subagent-requester-owner.test.ts b/src/agents/subagent-requester-owner.test.ts new file mode 100644 index 000000000000..8a86a32994de --- /dev/null +++ b/src/agents/subagent-requester-owner.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + backfillSubagentRequesterAgentIds, + resolveSubagentRequesterAgentId, +} from "./subagent-requester-owner.js"; +import { createSubagentRunRecord } from "./subagent-test-fixtures.test-helpers.js"; +import { + countActiveRunsForSessionFromRuns, + listRunsForRequesterFromRuns, +} from "./subagents/registry/subagent-registry-queries.js"; +import { markRequesterTurnYieldedInRuns } from "./subagents/registry/subagent-registry-requester-yield.js"; + +describe("resolveSubagentRequesterAgentId", () => { + it("attributes a legacy bare requester row only to the persisted fixed-store owner", () => { + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect(resolveSubagentRequesterAgentId(cfg, { requesterSessionKey: "global" })).toBe("ops"); + expect( + resolveSubagentRequesterAgentId(cfg, { + requesterSessionKey: "global", + requesterAgentId: "research", + }), + ).toBe("research"); + }); + + it("materializes legacy ownership before requester selectors run", () => { + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + const entry = createSubagentRunRecord({ + runId: "legacy-run", + childSessionKey: "agent:worker:subagent:legacy", + controllerSessionKey: "global", + requesterSessionKey: "global", + requesterDisplayKey: "global", + requesterTurnRunId: "requester-turn", + expectsCompletionMessage: true, + task: "legacy task", + cleanup: "keep", + createdAt: 1, + startedAt: 2, + }); + delete entry.requesterAgentId; + const runs = new Map([[entry.runId, entry]]); + + expect(backfillSubagentRequesterAgentIds(cfg, runs.values())).toBe(1); + expect(entry.requesterAgentId).toBe("ops"); + expect(listRunsForRequesterFromRuns(runs, "global", { requesterAgentId: "ops" })).toEqual([ + entry, + ]); + expect(listRunsForRequesterFromRuns(runs, "global", { requesterAgentId: "research" })).toEqual( + [], + ); + expect(countActiveRunsForSessionFromRuns(runs, "global", { requesterAgentId: "ops" })).toBe(1); + expect( + markRequesterTurnYieldedInRuns({ + requesterSessionKey: "global", + requesterAgentId: "ops", + requesterTurnRunId: "requester-turn", + runs, + persistOrThrow: () => undefined, + }), + ).toBe(1); + }); +}); diff --git a/src/agents/subagent-requester-owner.ts b/src/agents/subagent-requester-owner.ts new file mode 100644 index 000000000000..fd229df020db --- /dev/null +++ b/src/agents/subagent-requester-owner.ts @@ -0,0 +1,44 @@ +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; + +/** Resolves the durable requester owner for legacy rows that predate requesterAgentId. */ +export function resolveSubagentRequesterAgentId( + cfg: OpenClawConfig, + entry: { requesterSessionKey: string; requesterAgentId?: string }, +): string | undefined { + if (entry.requesterAgentId) { + return entry.requesterAgentId; + } + const parsedAgentId = parseAgentSessionKey(entry.requesterSessionKey)?.agentId; + if (parsedAgentId) { + return parsedAgentId; + } + const persisted = resolvePersistedSessionStoreOwnerForKey(cfg, entry.requesterSessionKey); + return persisted.kind === "configured" + ? persisted.agentId + : persisted.kind === "none" + ? tryResolveLegacyCompatibilityAgentId(cfg) + : undefined; +} + +/** Materializes the compatibility owner once so every registry selector sees the same tuple. */ +export function backfillSubagentRequesterAgentIds( + cfg: OpenClawConfig, + entries: Iterable<{ requesterSessionKey: string; requesterAgentId?: string }>, +): number { + let changed = 0; + for (const entry of entries) { + if (entry.requesterAgentId) { + continue; + } + const requesterAgentId = resolveSubagentRequesterAgentId(cfg, entry); + if (!requesterAgentId) { + continue; + } + entry.requesterAgentId = requesterAgentId; + changed += 1; + } + return changed; +} diff --git a/src/agents/subagent-requester-store-key.test.ts b/src/agents/subagent-requester-store-key.test.ts new file mode 100644 index 000000000000..0adce6ccbf1d --- /dev/null +++ b/src/agents/subagent-requester-store-key.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveRequesterStoreKey } from "./subagents/announce/subagent-requester-store-key.js"; + +describe("resolveRequesterStoreKey", () => { + it("scopes a custom main alias to the persisted fixed-store owner", () => { + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { mainKey: "work", store: "/tmp/openclaw-shared-sessions.sqlite" }, + } satisfies OpenClawConfig; + + expect(resolveRequesterStoreKey(cfg, "work")).toBe("agent:ops:work"); + }); + + it("scopes a bare key to the explicit requester owner in an ownerless fleet", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect(resolveRequesterStoreKey(cfg, "incident-42", "research")).toBe( + "agent:research:incident-42", + ); + }); +}); diff --git a/src/agents/subagents/announce/subagent-announce-delivery.runtime.ts b/src/agents/subagents/announce/subagent-announce-delivery.runtime.ts index a66881050073..c65e4873277c 100644 --- a/src/agents/subagents/announce/subagent-announce-delivery.runtime.ts +++ b/src/agents/subagents/announce/subagent-announce-delivery.runtime.ts @@ -5,10 +5,7 @@ * message routing, queue settings, hooks, and embedded-run state. */ export { getRuntimeConfig } from "../../../config/config.js"; -export { - resolveAgentIdFromSessionKey, - resolveSessionStorePathCore, -} from "../../../config/sessions.js"; +export { resolveSessionStorePathCore } from "../../../config/sessions.js"; export { loadSessionEntryReadOnly as loadSessionEntry } from "../../../config/sessions/session-accessor.js"; export { callGateway } from "../../../gateway/call.js"; export { dispatchGatewayMethodInProcess } from "../../../gateway/server-plugins.js"; diff --git a/src/agents/subagents/announce/subagent-announce-delivery.test-support.ts b/src/agents/subagents/announce/subagent-announce-delivery.test-support.ts index cba2b8a0b2c8..a7fae3b7c965 100644 --- a/src/agents/subagents/announce/subagent-announce-delivery.test-support.ts +++ b/src/agents/subagents/announce/subagent-announce-delivery.test-support.ts @@ -8,7 +8,10 @@ type DeliveryDeps = { callGateway: typeof import("./subagent-announce-delivery.runtime.js").callGateway; dispatchGatewayMethodInProcess: typeof import("./subagent-announce-delivery.runtime.js").dispatchGatewayMethodInProcess; getRuntimeConfig: typeof import("./subagent-announce-delivery.runtime.js").getRuntimeConfig; - getRequesterSessionActivity: (requesterSessionKey: string) => { + getRequesterSessionActivity: ( + requesterSessionKey: string, + requesterAgentId?: string, + ) => { sessionId?: string; isActive: boolean; }; diff --git a/src/agents/subagents/announce/subagent-announce-delivery.test.ts b/src/agents/subagents/announce/subagent-announce-delivery.test.ts index b5278199db03..bf873bd6179a 100644 --- a/src/agents/subagents/announce/subagent-announce-delivery.test.ts +++ b/src/agents/subagents/announce/subagent-announce-delivery.test.ts @@ -36,7 +36,11 @@ import { dispatchGatewayMethodInProcess as runtimeDispatchGatewayMethodInProcess, sendMessage as runtimeSendMessage, } from "./subagent-announce-delivery.runtime.js"; -import { testing, deliverSubagentAnnouncement } from "./subagent-announce-delivery.test-support.js"; +import { + testing, + deliverSubagentAnnouncement, + loadRequesterSessionEntry, +} from "./subagent-announce-delivery.test-support.js"; import { resolveAnnounceOrigin, resolveSubagentCompletionOrigin, @@ -313,6 +317,9 @@ async function deliverDiscordDirectMessageCompletion(params: { sendMessage?: typeof runtimeSendMessage; internalEvents?: AgentInternalEvent[]; isActive?: boolean; + requesterSessionKey?: string; + requesterAgentId?: string; + runtimeConfig?: Record; queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome; sourceSessionKey?: string; sourceTool?: string; @@ -325,13 +332,14 @@ async function deliverDiscordDirectMessageCompletion(params: { to: "dm:U123", accountId: "acct-1", }; + const requesterSessionKey = params.requesterSessionKey ?? "agent:main:discord:dm:U123"; testing.setDepsForTest({ callGateway: params.callGateway, getRequesterSessionActivity: () => ({ sessionId: "requester-session-dm", isActive: params.isActive === true, }), - getRuntimeConfig: () => ({}) as never, + getRuntimeConfig: () => (params.runtimeConfig ?? {}) as never, sendMessage: params.sendMessage ?? runtimeSendMessage, ...(params.queueEmbeddedAgentMessageWithOutcome ? { queueEmbeddedAgentMessageWithOutcome: params.queueEmbeddedAgentMessageWithOutcome } @@ -339,8 +347,9 @@ async function deliverDiscordDirectMessageCompletion(params: { }); return deliverSubagentAnnouncement({ - requesterSessionKey: "agent:main:discord:dm:U123", - targetRequesterSessionKey: "agent:main:discord:dm:U123", + requesterSessionKey, + requesterAgentId: params.requesterAgentId, + targetRequesterSessionKey: requesterSessionKey, triggerMessage: "child done", steerMessage: "child done", requesterOrigin: origin, @@ -656,6 +665,32 @@ describe("resolveSubagentCompletionOrigin", () => { }); describe("deliverSubagentAnnouncement active requester steering", () => { + it("loads a custom main alias through its canonical requester key", () => { + const loadSessionEntry = vi.fn(() => ({ sessionId: "research-main", updatedAt: 1 })); + testing.setDepsForTest({ + getRuntimeConfig: () => + ({ + session: { mainKey: "work", store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }) as never, + loadSessionEntry, + }); + + expect(loadRequesterSessionEntry("work", "research")).toMatchObject({ + canonicalKey: "agent:research:work", + entry: { sessionId: "research-main" }, + }); + expect(loadSessionEntry).toHaveBeenCalledWith({ + agentId: "research", + clone: false, + sessionKey: "agent:research:work", + storePath: "/stores/shared.sqlite", + }); + }); + async function deliverSteeredAnnouncement(params: { mode?: "followup" | "collect" | "interrupt"; announceTimeoutMs?: number; @@ -763,6 +798,263 @@ describe("deliverSubagentAnnouncement active requester steering", () => { }, ); + it("uses the requester agent when bare session keys collide", async () => { + const cfg = { + session: { scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + } as never; + const getRequesterSessionActivity = vi.fn( + (_requesterSessionKey: string, requesterAgentId?: string) => ({ + sessionId: requesterAgentId === "research" ? "research-session" : "ops-session", + isActive: true, + }), + ); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); + testing.setDepsForTest({ + getRuntimeConfig: () => cfg, + getRequesterSessionActivity, + loadRequesterSessionEntry: (sessionKey: string) => ({ + cfg, + entry: undefined, + canonicalKey: sessionKey, + }), + queueEmbeddedAgentMessageWithOutcome, + }); + + const result = await deliverSubagentAnnouncement({ + requesterSessionKey: "global", + requesterAgentId: "research", + targetRequesterSessionKey: "global", + triggerMessage: "child done", + steerMessage: "child done", + requesterIsSubagent: false, + expectsCompletionMessage: false, + directIdempotencyKey: "announce-bare-key-agent-owner", + }); + + expectDeliveryPath(result, "steered"); + expect(getRequesterSessionActivity).toHaveBeenCalledWith("global", "research"); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledWith( + "research-session", + "child done", + expect.objectContaining({ steeringMode: "all" }), + ); + }); + + it("fails closed for a restored bare requester key without an owner", async () => { + const cfg = { + session: { scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + } as never; + const getRequesterSessionActivity = vi.fn(() => ({ + sessionId: "ops-session", + isActive: true, + })); + const loadSessionEntry = vi.fn(() => ({ sessionId: "ops-session", updatedAt: 1 })); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); + testing.setDepsForTest({ + getRuntimeConfig: () => cfg, + getRequesterSessionActivity, + loadSessionEntry, + queueEmbeddedAgentMessageWithOutcome, + callGateway: vi.fn(async () => { + throw new Error("requester owner unavailable"); + }), + }); + + const result = await deliverSubagentAnnouncement({ + requesterSessionKey: "global", + targetRequesterSessionKey: "global", + triggerMessage: "child done", + steerMessage: "child done", + requesterIsSubagent: false, + expectsCompletionMessage: false, + directIdempotencyKey: "announce-ownerless-restored-entry", + }); + + expect(result.delivered).toBe(false); + expect(getRequesterSessionActivity).not.toHaveBeenCalled(); + expect(loadSessionEntry).not.toHaveBeenCalled(); + expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled(); + }); + + it("uses the persisted fixed-store owner for a restored bare requester key", async () => { + const cfg = { + session: { scope: "global", store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + list: [{ id: "ops" }, { id: "research" }], + }, + } as never; + const getRequesterSessionActivity = vi.fn(() => ({ + sessionId: "ops-session", + isActive: true, + })); + const loadSessionEntry = vi.fn(() => ({ sessionId: "ops-session", updatedAt: 1 })); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); + testing.setDepsForTest({ + getRuntimeConfig: () => cfg, + getRequesterSessionActivity, + loadSessionEntry, + queueEmbeddedAgentMessageWithOutcome, + }); + + const result = await deliverSubagentAnnouncement({ + requesterSessionKey: "global", + targetRequesterSessionKey: "global", + triggerMessage: "child done", + steerMessage: "child done", + requesterIsSubagent: false, + expectsCompletionMessage: false, + directIdempotencyKey: "announce-retained-restored-entry", + }); + + expectDeliveryPath(result, "steered"); + expect(getRequesterSessionActivity).toHaveBeenCalledWith("global", "ops"); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledWith( + "ops-session", + "child done", + expect.objectContaining({ steeringMode: "all" }), + ); + }); + + it("loads a persisted custom bare requester under its durable storage key", async () => { + const cfg = { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as never; + const getRequesterSessionActivity = vi.fn(() => ({ + sessionId: "ops-incident-session", + isActive: true, + })); + const loadSessionEntry = vi.fn(() => ({ + sessionId: "ops-incident-session", + updatedAt: 1, + })); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); + testing.setDepsForTest({ + getRuntimeConfig: () => cfg, + getRequesterSessionActivity, + loadSessionEntry, + queueEmbeddedAgentMessageWithOutcome, + }); + + const result = await deliverSubagentAnnouncement({ + requesterSessionKey: "incident-42", + targetRequesterSessionKey: "incident-42", + triggerMessage: "child done", + steerMessage: "child done", + requesterIsSubagent: false, + expectsCompletionMessage: false, + directIdempotencyKey: "announce-persisted-bare-requester", + }); + + expectDeliveryPath(result, "steered"); + expect(loadSessionEntry).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "ops", sessionKey: "incident-42" }), + ); + expect(getRequesterSessionActivity).toHaveBeenCalledWith("incident-42", "ops"); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledWith( + "ops-incident-session", + "child done", + expect.objectContaining({ steeringMode: "all" }), + ); + }); + + it("rejects a restored bare requester whose explicit agent conflicts with the store owner", async () => { + const cfg = { + session: { scope: "global", store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + list: [{ id: "ops" }, { id: "research" }], + }, + } as never; + const getRequesterSessionActivity = vi.fn(() => ({ + sessionId: "ops-session", + isActive: true, + })); + const loadSessionEntry = vi.fn(() => ({ sessionId: "ops-session", updatedAt: 1 })); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); + testing.setDepsForTest({ + getRuntimeConfig: () => cfg, + getRequesterSessionActivity, + loadSessionEntry, + queueEmbeddedAgentMessageWithOutcome, + callGateway: vi.fn(async () => { + throw new Error("requester owner conflict"); + }), + }); + + const result = await deliverSubagentAnnouncement({ + requesterSessionKey: "global", + requesterAgentId: "research", + targetRequesterSessionKey: "global", + triggerMessage: "child done", + steerMessage: "child done", + requesterIsSubagent: false, + expectsCompletionMessage: false, + directIdempotencyKey: "announce-conflicting-restored-entry", + }); + + expect(result.delivered).toBe(false); + expect(getRequesterSessionActivity).not.toHaveBeenCalled(); + expect(loadSessionEntry).not.toHaveBeenCalled(); + expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled(); + }); + + it("fails closed for a restored bare requester key with a retired store owner", async () => { + const cfg = { + session: { scope: "global", store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "retired" } }, + list: [{ id: "ops" }, { id: "research" }], + }, + } as never; + const getRequesterSessionActivity = vi.fn(() => ({ + sessionId: "ops-session", + isActive: true, + })); + const loadSessionEntry = vi.fn(() => ({ sessionId: "ops-session", updatedAt: 1 })); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); + testing.setDepsForTest({ + getRuntimeConfig: () => cfg, + getRequesterSessionActivity, + loadSessionEntry, + queueEmbeddedAgentMessageWithOutcome, + callGateway: vi.fn(async () => { + throw new Error("requester owner unavailable"); + }), + }); + + const result = await deliverSubagentAnnouncement({ + requesterSessionKey: "global", + targetRequesterSessionKey: "global", + triggerMessage: "child done", + steerMessage: "child done", + requesterIsSubagent: false, + expectsCompletionMessage: false, + directIdempotencyKey: "announce-retired-restored-entry", + }); + + expect(result.delivered).toBe(false); + expect(getRequesterSessionActivity).not.toHaveBeenCalled(); + expect(loadSessionEntry).not.toHaveBeenCalled(); + expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled(); + }); + it("preserves best-effort steering for active runtimes without transcript wait support", async () => { const queueEmbeddedAgentMessageWithOutcome = vi .fn() @@ -1352,6 +1644,38 @@ describe("deliverSubagentAnnouncement completion delivery", () => { expect(sendMessage).toHaveBeenCalledTimes(1); }); + it("uses the caller owner for direct completion delivery to a bare requester key", async () => { + const callGateway = createPayloadGatewayMock(); + const sendMessage = createSendMessageMock(); + + const result = await deliverDiscordDirectMessageCompletion({ + callGateway, + sendMessage, + requesterSessionKey: "global", + requesterAgentId: "research", + runtimeConfig: { + session: { scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }, + internalEvents: taskCompletionEvents({ childSessionId: "child-session-id" }), + }); + + expectDeliveryPath(result, "direct"); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + requesterSessionKey: "global", + agentId: "research", + mirror: expect.objectContaining({ + sessionKey: "global", + agentId: "research", + }), + }), + ); + }); + it("sanitizes and bounds text before direct completion fallback delivery", async () => { const callGateway = createPayloadGatewayMock(); const sendMessage = createSendMessageMock(); diff --git a/src/agents/subagents/announce/subagent-announce-delivery.ts b/src/agents/subagents/announce/subagent-announce-delivery.ts index 9002834d6c2f..a6ae78bbf8dc 100644 --- a/src/agents/subagents/announce/subagent-announce-delivery.ts +++ b/src/agents/subagents/announce/subagent-announce-delivery.ts @@ -8,6 +8,8 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s import { normalizeUniqueTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { completionRequiresMessageToolDelivery } from "../../../auto-reply/reply/completion-delivery-policy.js"; import { sanitizePendingFinalDeliveryText } from "../../../auto-reply/reply/pending-final-delivery.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../../config/legacy.default-agent-owner.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { isFastTestRuntimeEnv } from "../../../infra/env.js"; import { isOutboundDeliveryError } from "../../../infra/outbound/deliver-types.js"; @@ -18,6 +20,11 @@ import { releaseSessionDeliveryClaim, } from "../../../infra/session-delivery-queue.js"; import { stringifyRouteThreadId } from "../../../plugin-sdk/channel-route.js"; +import { + normalizeAgentId, + normalizeMainKey, + parseAgentSessionKey, +} from "../../../routing/session-key.js"; import { defaultRuntime } from "../../../runtime.js"; import { isAgentMediatedCompletionSourceTool, @@ -33,7 +40,6 @@ import { normalizeMessageChannel, } from "../../../utils/message-channel.js"; import { sanitizeAgentRunTerminalReplyText } from "../../agent-run-terminal-reply.js"; -import { resolveDefaultAgentId } from "../../agent-scope-config.js"; import { getAgentCommandDeliveryFailure, getGatewayAgentResult, @@ -72,7 +78,6 @@ import { loadSessionEntry, queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, - resolveAgentIdFromSessionKey, resolveExternalBestEffortDeliveryTarget, resolveQueueSettings, resolveSessionStorePathCore, @@ -92,10 +97,45 @@ import { import { resolveRequesterStoreKey } from "./subagent-requester-store-key.js"; const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000; + +function tryResolveRequesterAgentId( + cfg: OpenClawConfig, + requesterSessionKey: string, + explicitAgentId?: string, +): string | undefined { + const requestedAgentId = explicitAgentId?.trim() ? normalizeAgentId(explicitAgentId) : undefined; + const parsedAgentId = parseAgentSessionKey(requesterSessionKey)?.agentId; + if (requestedAgentId && parsedAgentId && requestedAgentId !== parsedAgentId) { + return undefined; + } + const persistedStoreOwner = resolvePersistedSessionStoreOwnerForKey(cfg, requesterSessionKey); + if (persistedStoreOwner.kind === "retired") { + return undefined; + } + if ( + requestedAgentId && + persistedStoreOwner.kind === "configured" && + requestedAgentId !== persistedStoreOwner.agentId + ) { + return undefined; + } + const resolvedAgentId = requestedAgentId ?? parsedAgentId; + if (resolvedAgentId) { + return resolvedAgentId; + } + return ( + (persistedStoreOwner.kind === "configured" ? persistedStoreOwner.agentId : undefined) ?? + tryResolveLegacyCompatibilityAgentId(cfg) + ); +} + type SubagentAnnounceDeliveryDeps = { dispatchGatewayMethodInProcess: typeof dispatchGatewayMethodInProcess; getRuntimeConfig: typeof getRuntimeConfig; - getRequesterSessionActivity: (requesterSessionKey: string) => { + getRequesterSessionActivity: ( + requesterSessionKey: string, + requesterAgentId?: string, + ) => { sessionId?: string; isActive: boolean; }; @@ -113,10 +153,20 @@ type SubagentAnnounceDeliveryDeps = { const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = { dispatchGatewayMethodInProcess, getRuntimeConfig, - getRequesterSessionActivity: (requesterSessionKey: string) => { - const sessionId = - resolveActiveEmbeddedRunSessionId(requesterSessionKey) ?? - loadRequesterSessionEntry(requesterSessionKey).entry?.sessionId; + getRequesterSessionActivity: (requesterSessionKey: string, requesterAgentId?: string) => { + const cfg = getRuntimeConfig(); + const resolvedAgentId = tryResolveRequesterAgentId(cfg, requesterSessionKey, requesterAgentId); + if (!resolvedAgentId) { + return { isActive: false }; + } + const storedSessionId = loadRequesterSessionEntry(requesterSessionKey, resolvedAgentId).entry + ?.sessionId; + // Unscoped active-run keys are ambiguous across agents. An explicit owner + // must use its logical store entry instead of accepting another agent's run. + const activeSessionId = parseAgentSessionKey(requesterSessionKey) + ? resolveActiveEmbeddedRunSessionId(requesterSessionKey) + : undefined; + const sessionId = activeSessionId ?? storedSessionId; return { sessionId, isActive: Boolean(sessionId && isEmbeddedAgentRunActive(sessionId)), @@ -173,12 +223,23 @@ function formatQueueWakeFailureError( return summary ? `${fallback}: ${summary}` : fallback; } -function resolveRequesterSessionActivity(requesterSessionKey: string) { - const activity = subagentAnnounceDeliveryDeps.getRequesterSessionActivity(requesterSessionKey); +function resolveRequesterSessionActivity(requesterSessionKey: string, requesterAgentId?: string) { + const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); + const resolvedAgentId = tryResolveRequesterAgentId(cfg, requesterSessionKey, requesterAgentId); + if (!resolvedAgentId) { + return { isActive: false }; + } + const activity = subagentAnnounceDeliveryDeps.getRequesterSessionActivity( + requesterSessionKey, + resolvedAgentId, + ); if (activity.sessionId || activity.isActive) { return activity; } - const { entry } = loadRequesterSessionEntry(requesterSessionKey); + const { entry } = subagentAnnounceDeliveryDeps.loadRequesterSessionEntry( + requesterSessionKey, + resolvedAgentId, + ); const sessionId = entry?.sessionId; return { sessionId, @@ -571,26 +632,38 @@ export async function runAnnounceDeliveryWithRetry(params: { return await params.run(); } -export function loadRequesterSessionEntry(requesterSessionKey: string) { +export function loadRequesterSessionEntry(requesterSessionKey: string, explicitAgentId?: string) { const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); - const canonicalKey = resolveRequesterStoreKey(cfg, requesterSessionKey); - const agentId = resolveAgentIdFromSessionKey(canonicalKey, resolveDefaultAgentId(cfg)); + const rawStorageKey = requesterSessionKey.trim(); + const canonicalKey = resolveRequesterStoreKey(cfg, requesterSessionKey, explicitAgentId); + const configuredMainKey = normalizeMainKey(cfg.session?.mainKey); + const storageKey = + rawStorageKey === "main" || rawStorageKey === configuredMainKey ? canonicalKey : rawStorageKey; + const agentId = tryResolveRequesterAgentId(cfg, rawStorageKey, explicitAgentId); + if (!agentId) { + return { cfg, entry: undefined, canonicalKey }; + } const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId }); const entry = subagentAnnounceDeliveryDeps.loadSessionEntry({ storePath, - sessionKey: canonicalKey, + sessionKey: storageKey, + agentId, clone: false, }); return { cfg, entry, canonicalKey }; } -export function loadSessionEntryByKey(sessionKey: string) { +export function loadSessionEntryByKey(sessionKey: string, explicitAgentId?: string) { const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); - const agentId = resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(cfg)); + const agentId = tryResolveRequesterAgentId(cfg, sessionKey, explicitAgentId); + if (!agentId) { + return undefined; + } const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId }); return subagentAnnounceDeliveryDeps.loadSessionEntry({ storePath, sessionKey, + agentId, clone: false, }); } @@ -598,6 +671,7 @@ export function loadSessionEntryByKey(sessionKey: string) { async function maybeSteerSubagentAnnounce(params: { deliveryTimeoutMs?: number; requesterSessionKey: string; + requesterAgentId?: string; steerMessage: string; signal?: AbortSignal; isSourceSessionEffectsAllowed?: () => boolean; @@ -608,9 +682,24 @@ async function maybeSteerSubagentAnnounce(params: { if (params.signal?.aborted) { return { status: "none" }; } - const { cfg, entry } = loadRequesterSessionEntry(params.requesterSessionKey); - const canonicalKey = resolveRequesterStoreKey(cfg, params.requesterSessionKey); - const { sessionId, isActive } = resolveRequesterSessionActivity(canonicalKey); + const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); + const requesterAgentId = tryResolveRequesterAgentId( + cfg, + params.requesterSessionKey, + params.requesterAgentId, + ); + if (!requesterAgentId) { + return { status: "none" }; + } + const { entry } = subagentAnnounceDeliveryDeps.loadRequesterSessionEntry( + params.requesterSessionKey, + requesterAgentId, + ); + const canonicalKey = resolveRequesterStoreKey(cfg, params.requesterSessionKey, requesterAgentId); + const { sessionId, isActive } = resolveRequesterSessionActivity( + params.requesterSessionKey, + requesterAgentId, + ); if (subagentAnnounceDeliveryDeps.isRequesterSessionAbandoned(canonicalKey, sessionId)) { return { status: "none" }; } @@ -656,7 +745,10 @@ async function maybeSteerSubagentAnnounce(params: { if (queueOutcome.reason === "stale_run") { return { status: "none" }; } - const currentActivity = resolveRequesterSessionActivity(canonicalKey); + const currentActivity = resolveRequesterSessionActivity( + params.requesterSessionKey, + requesterAgentId, + ); return { status: currentActivity.isActive ? "dropped" : "none" }; } @@ -728,6 +820,7 @@ function hasFailedSubagentNoOutputCompletion(events: readonly AgentInternalEvent async function deliverCompletionDirect(params: { cfg: OpenClawConfig; requesterSessionKey: string; + requesterAgentId?: string; directIdempotencyKey: string; deliveryTarget: { deliver: boolean; @@ -750,10 +843,14 @@ async function deliverCompletionDirect(params: { ) { return undefined; } - const agentId = resolveAgentIdFromSessionKey( + const agentId = tryResolveRequesterAgentId( + params.cfg, params.requesterSessionKey, - resolveDefaultAgentId(params.cfg), + params.requesterAgentId, ); + if (!agentId) { + return undefined; + } const idempotencyKey = `${params.directIdempotencyKey}:text-direct`; let committedDelivery: SubagentAnnounceDeliveryResult | undefined; try { @@ -876,6 +973,7 @@ function hasMessagingToolDeliveryToSource( async function sendSubagentAnnounceDirectly(params: { requesterSessionKey: string; + requesterAgentId?: string; targetRequesterSessionKey: string; triggerMessage: string; internalEvents?: AgentInternalEvent[]; @@ -906,6 +1004,7 @@ async function sendSubagentAnnounceDirectly(params: { const canonicalRequesterSessionKey = resolveRequesterStoreKey( cfg, params.targetRequesterSessionKey, + params.requesterAgentId, ); try { // Merge completionDirectOrigin with directOrigin so that missing fields @@ -919,6 +1018,7 @@ async function sendSubagentAnnounceDirectly(params: { : requesterSessionOrigin; const requesterEntry = subagentAnnounceDeliveryDeps.loadRequesterSessionEntry( params.targetRequesterSessionKey, + params.requesterAgentId, ).entry; const deliveryTarget = !params.requesterIsSubagent ? resolveExternalBestEffortDeliveryTarget({ @@ -974,7 +1074,10 @@ async function sendSubagentAnnounceDirectly(params: { const requiresMessageToolDelivery = completionRouteRequiresMessageToolDelivery || subagentDirectMessageCompletionRequiresMessageTool; - const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey); + const requesterActivity = resolveRequesterSessionActivity( + params.targetRequesterSessionKey, + params.requesterAgentId, + ); if ( params.expectsCompletionMessage && subagentAnnounceDeliveryDeps.isRequesterSessionAbandoned( @@ -1007,6 +1110,7 @@ async function sendSubagentAnnounceDirectly(params: { deliverCompletionDirect({ cfg, requesterSessionKey: canonicalRequesterSessionKey, + requesterAgentId: params.requesterAgentId, directIdempotencyKey: params.directIdempotencyKey, deliveryTarget, internalEvents: params.internalEvents, @@ -1071,7 +1175,8 @@ async function sendSubagentAnnounceDirectly(params: { if ( params.expectsCompletionMessage && isCronRunSessionKey(canonicalRequesterSessionKey) && - !resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive && + !resolveRequesterSessionActivity(params.targetRequesterSessionKey, params.requesterAgentId) + .isActive && !agentMediatedCompletion ) { return { @@ -1381,6 +1486,7 @@ async function sendSubagentAnnounceDirectly(params: { export async function deliverSubagentAnnouncement(params: { requesterSessionKey: string; + requesterAgentId?: string; announceId?: string; triggerMessage: string; steerMessage: string; @@ -1419,7 +1525,11 @@ export async function deliverSubagentAnnouncement(params: { if (durableGeneratedMediaHandoff) { try { const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); - const canonicalSessionKey = resolveRequesterStoreKey(cfg, params.targetRequesterSessionKey); + const canonicalSessionKey = resolveRequesterStoreKey( + cfg, + params.targetRequesterSessionKey, + params.requesterAgentId, + ); const queuedRoute = resolveGeneratedMediaSessionDeliveryRoute({ sessionKey: canonicalSessionKey, completionDirectOrigin: params.completionDirectOrigin, @@ -1434,6 +1544,7 @@ export async function deliverSubagentAnnouncement(params: { }); const requesterEntry = subagentAnnounceDeliveryDeps.loadRequesterSessionEntry( params.targetRequesterSessionKey, + params.requesterAgentId, ).entry; // No external route exists for an internal-only handoff. Let the normal // agent final enter the owning transcript instead of requiring a message tool target. @@ -1530,6 +1641,7 @@ export async function deliverSubagentAnnouncement(params: { subagentAnnounceDeliveryDeps.getRuntimeConfig(), ), requesterSessionKey: params.requesterSessionKey, + requesterAgentId: params.requesterAgentId, steerMessage: params.steerMessage, signal: params.signal, isSourceSessionEffectsAllowed: params.isSourceSessionEffectsAllowed, @@ -1541,6 +1653,7 @@ export async function deliverSubagentAnnouncement(params: { } return await sendSubagentAnnounceDirectly({ requesterSessionKey: params.requesterSessionKey, + requesterAgentId: params.requesterAgentId, targetRequesterSessionKey: params.targetRequesterSessionKey, triggerMessage: params.triggerMessage, internalEvents: params.internalEvents, diff --git a/src/agents/subagents/announce/subagent-announce-output.ts b/src/agents/subagents/announce/subagent-announce-output.ts index bf709ee82153..7faf34118cc3 100644 --- a/src/agents/subagents/announce/subagent-announce-output.ts +++ b/src/agents/subagents/announce/subagent-announce-output.ts @@ -588,6 +588,7 @@ export function filterCurrentDirectChildCompletionRows( runId: string; childSessionKey: string; requesterSessionKey: string; + requesterAgentId?: string; task: string; label?: string; createdAt: number; @@ -600,10 +601,12 @@ export function filterCurrentDirectChildCompletionRows( }>, params: { requesterSessionKey: string; + requesterAgentId?: string; getLatestSubagentRunByChildSessionKey?: (childSessionKey: string) => | { runId: string; requesterSessionKey: string; + requesterAgentId?: string; } | null | undefined; @@ -618,7 +621,9 @@ export function filterCurrentDirectChildCompletionRows( return true; } return ( - latest.runId === child.runId && latest.requesterSessionKey === params.requesterSessionKey + latest.runId === child.runId && + latest.requesterSessionKey === params.requesterSessionKey && + (!params.requesterAgentId || latest.requesterAgentId === params.requesterAgentId) ); }); } diff --git a/src/agents/subagents/announce/subagent-announce.requester-settle-wake.test.ts b/src/agents/subagents/announce/subagent-announce.requester-settle-wake.test.ts index 7122548ea554..fc38160840a6 100644 --- a/src/agents/subagents/announce/subagent-announce.requester-settle-wake.test.ts +++ b/src/agents/subagents/announce/subagent-announce.requester-settle-wake.test.ts @@ -75,6 +75,8 @@ import { } from "./subagent-announce.requester-settle-wake.js"; const REQUESTER = "agent:main:main"; +const requesterSettleKey = (suffix: string) => + `announce:requester-settle:main:${REQUESTER}:${suffix}`; type SettledChildOverrides = Omit, "execution"> & { startedAt?: number; @@ -200,7 +202,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { expect(call.expectsCompletionMessage).toBe(false); expect(call.requireDirectDelivery).toBe(true); expect(call.requireVisibleReply).toBeUndefined(); - expect(call.directIdempotencyKey).toBe(`announce:requester-settle:${REQUESTER}:run-a,run-b`); + expect(call.directIdempotencyKey).toBe(requesterSettleKey("run-a,run-b")); const message = String(call.triggerMessage); expect(message).toContain("settled"); expect(message).toContain("social findings"); @@ -209,6 +211,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { expect(registryRuntimeMock.hasDescendantRunAwaitingSettle).toHaveBeenCalledWith( REQUESTER, "run-b", + "main", ); }); @@ -236,9 +239,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { expect.arrayContaining([true, false]), ); - expect(deliveredCallArg().directIdempotencyKey).toBe( - `announce:requester-settle:${REQUESTER}:run-a,run-b`, - ); + expect(deliveredCallArg().directIdempotencyKey).toBe(requesterSettleKey("run-a,run-b")); deliverSpy.mockReset().mockResolvedValue({ delivered: true, path: "direct" }); }); @@ -296,9 +297,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { expect(woke).toBe(true); const call = deliveredCallArg(); - expect(call.directIdempotencyKey).toBe( - `announce:requester-settle:${REQUESTER}:run-a,run-b,run-c`, - ); + expect(call.directIdempotencyKey).toBe(requesterSettleKey("run-a,run-b,run-c")); const message = String(call.triggerMessage); expect(message).toContain("alpha findings"); expect(message).toContain("bravo findings"); @@ -324,7 +323,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { await maybeWakeRequesterAfterAllChildrenSettled(wakeParams({ settledEntry: queued })), ).toBe(true); expect(deliveredCallArg().directIdempotencyKey).toBe( - `announce:requester-settle:${REQUESTER}:run-first,run-queued`, + requesterSettleKey("run-first,run-queued"), ); }); @@ -442,9 +441,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { const message = String(deliveredCallArg().triggerMessage); expect(message).not.toContain("NO_REPLY"); expect(message).toContain("original user request still requires your visible final answer"); - expect(deliveredCallArg().directIdempotencyKey).toBe( - `announce:requester-settle:${REQUESTER}:run-b:yield-1`, - ); + expect(deliveredCallArg().directIdempotencyKey).toBe(requesterSettleKey("run-b:yield-1")); expect(completeBatchSpy).toHaveBeenCalledWith(["run-b"], 1, { delivered: true, path: "direct", @@ -536,9 +533,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { const message = String(deliveredCallArg().triggerMessage); expect(message).not.toContain("NO_REPLY"); expect(message).toContain("original user request still requires your visible final answer"); - expect(deliveredCallArg().directIdempotencyKey).toBe( - `announce:requester-settle:${REQUESTER}:run-b:yield-1`, - ); + expect(deliveredCallArg().directIdempotencyKey).toBe(requesterSettleKey("run-b:yield-1")); expect(completeBatchSpy).toHaveBeenCalledWith(["run-b"], 1, { delivered: true, path: "direct", @@ -623,8 +618,8 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { expect(woke).toBe(true); expect(deliverSpy).toHaveBeenCalledTimes(2); const keys = deliverSpy.mock.calls.map(([arg]) => arg.directIdempotencyKey); - expect(keys[0]).toBe(`announce:requester-settle:${REQUESTER}:run-a,run-b`); - expect(keys[1]).toBe(`announce:requester-settle:${REQUESTER}:run-a,run-b:retry-1`); + expect(keys[0]).toBe(requesterSettleKey("run-a,run-b")); + expect(keys[1]).toBe(requesterSettleKey("run-a,run-b:retry-1")); } finally { vi.useRealTimers(); } @@ -670,8 +665,8 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { maybeWakeRequesterAfterAllChildrenSettled(wakeParams({ settledEntry: child })), ).resolves.toBe(true); expect(deliverSpy.mock.calls.map(([arg]) => arg.directIdempotencyKey)).toEqual([ - `announce:requester-settle:${REQUESTER}:run-b:yield-1`, - `announce:requester-settle:${REQUESTER}:run-b:yield-1:retry-1`, + requesterSettleKey("run-b:yield-1"), + requesterSettleKey("run-b:yield-1:retry-1"), ]); expect(completeBatchSpy).toHaveBeenCalledWith(["run-b"], 1, { delivered: true, @@ -709,8 +704,8 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { ).toBe(true); expect(deliverSpy).toHaveBeenCalledTimes(2); expect(deliverSpy.mock.calls.map(([arg]) => arg.directIdempotencyKey)).toEqual([ - `announce:requester-settle:${REQUESTER}:run-a,run-b`, - `announce:requester-settle:${REQUESTER}:run-a,run-b`, + requesterSettleKey("run-a,run-b"), + requesterSettleKey("run-a,run-b"), ]); } finally { vi.useRealTimers(); @@ -884,9 +879,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { ).toBe(true); expect(transitionBatchSpy).not.toHaveBeenCalled(); - expect(deliveredCallArg().directIdempotencyKey).toBe( - `announce:requester-settle:${REQUESTER}:run-a,run-b`, - ); + expect(deliveredCallArg().directIdempotencyKey).toBe(requesterSettleKey("run-a,run-b")); }); it("defers a frozen batch replay until a newer descendant settles", async () => { @@ -1003,7 +996,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { ), ).toBe(true); expect(deliveredCallArg().directIdempotencyKey).toBe( - `announce:requester-settle:${REQUESTER}:run-a,run-b:retry-1`, + requesterSettleKey("run-a,run-b:retry-1"), ); } finally { vi.useRealTimers(); diff --git a/src/agents/subagents/announce/subagent-announce.requester-settle-wake.ts b/src/agents/subagents/announce/subagent-announce.requester-settle-wake.ts index 8444cebd5c49..c0961b5e677e 100644 --- a/src/agents/subagents/announce/subagent-announce.requester-settle-wake.ts +++ b/src/agents/subagents/announce/subagent-announce.requester-settle-wake.ts @@ -5,6 +5,7 @@ * this module selects a drained wave and delivers its synthesized wake. */ import { SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; +import { getRuntimeConfig } from "../../../config/config.js"; import { logWarn } from "../../../logger.js"; import { isCronSessionKey } from "../../../sessions/session-key-utils.js"; import { @@ -13,6 +14,7 @@ import { } from "../../../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js"; import { buildAnnounceIdempotencyKey } from "../../announce-idempotency.js"; +import { resolveSubagentRequesterAgentId } from "../../subagent-requester-owner.js"; import { getLatestSubagentRunByChildSessionKey, hasDescendantRunAwaitingSettle, @@ -215,6 +217,8 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { params.completeBatch(runIds, rearmGeneration, delivery); }; const requesterSessionKey = params.requesterSessionKey.trim(); + const cfg = getRuntimeConfig(); + const requesterAgentId = resolveSubagentRequesterAgentId(cfg, params.settledEntry); const initialState = params.settledEntry.requesterSettleWake; if (!requesterSessionKey || !initialState) { return false; @@ -229,7 +233,9 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { return false; } - const listedRuns = listSubagentRunsForRequester(requesterSessionKey); + const listedRuns = listSubagentRunsForRequester(requesterSessionKey, { + requesterAgentId, + }); const requesterRuns = Array.isArray(listedRuns) ? listedRuns : []; const currentSettledEntry = requesterRuns.find((entry) => entry.runId === params.settledEntry.runId) ?? params.settledEntry; @@ -240,7 +246,11 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { return false; } const requesterHasUnsettledDescendants = () => - hasDescendantRunAwaitingSettle(requesterSessionKey, currentSettledEntry.runId); + hasDescendantRunAwaitingSettle( + requesterSessionKey, + currentSettledEntry.runId, + requesterAgentId, + ); const frozenBatchRunIds = currentState.batchRunIds; const currentRearmGeneration = currentState.rearmGeneration; @@ -308,7 +318,10 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { (requiredSettled.length < 2 && !hasUndeliveredRequiredCompletion && !requesterYieldedAfterDelivery) || - getSubagentDepthFromSessionStore(requesterSessionKey) >= 1 + getSubagentDepthFromSessionStore(requesterSessionKey, { + cfg, + agentId: requesterAgentId, + }) >= 1 ) { completeRequesterSettleWakeBatch({ runIds: batchRunIds, @@ -318,7 +331,10 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { return false; } - const { entry: requesterEntry } = loadRequesterSessionEntry(requesterSessionKey); + const { entry: requesterEntry } = loadRequesterSessionEntry( + requesterSessionKey, + requesterAgentId, + ); if (!hasUsableSessionEntry(requesterEntry)) { completeRequesterSettleWakeBatch({ runIds: batchRunIds, @@ -333,6 +349,7 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { dedupeLatestChildCompletionRows( filterCurrentDirectChildCompletionRows(settledBatch, { requesterSessionKey, + requesterAgentId, getLatestSubagentRunByChildSessionKey, }), ), @@ -344,7 +361,7 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { const requesterSessionOrigin = normalizeDeliveryContext(params.requesterOrigin); const directOrigin = resolveAnnounceOrigin(requesterEntry, requesterSessionOrigin); const wakeKeyBase = [ - `requester-settle:${requesterSessionKey}:${batchRunIds.join(",")}`, + `requester-settle:${requesterAgentId ?? "unknown"}:${requesterSessionKey}:${batchRunIds.join(",")}`, selectedState.rearmGeneration === undefined ? undefined : `yield-${selectedState.rearmGeneration}`, @@ -415,6 +432,7 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { try { delivery = await deliverSubagentAnnouncement({ requesterSessionKey, + requesterAgentId, triggerMessage: wakeMessage, steerMessage: wakeMessage, summaryLine: "all spawned subagents settled", diff --git a/src/agents/subagents/announce/subagent-announce.ts b/src/agents/subagents/announce/subagent-announce.ts index 9048d43fa036..9036078d1e1b 100644 --- a/src/agents/subagents/announce/subagent-announce.ts +++ b/src/agents/subagents/announce/subagent-announce.ts @@ -160,6 +160,7 @@ export async function runSubagentAnnounceFlow(params: { childSessionKey: string; childRunId: string; requesterSessionKey: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; requesterDisplayKey: string; task: string; @@ -205,6 +206,7 @@ export async function runSubagentAnnounceFlow(params: { let childSessionLifecycleRevision: string | undefined; try { let targetRequesterSessionKey = params.requesterSessionKey; + let targetRequesterAgentId = params.requesterAgentId; let targetRequesterOrigin = normalizeDeliveryContext(params.requesterOrigin); const childSessionEntry = !childSessionEffectsAllowed() ? undefined @@ -255,7 +257,10 @@ export async function runSubagentAnnounceFlow(params: { if (failedTerminalOutcome && !params.terminalReply) { reply = undefined; } - let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); + let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey, { + cfg: subagentAnnounceDeps.getRuntimeConfig(), + agentId: targetRequesterAgentId, + }); const requesterIsInternalSession = () => requesterDepth >= 1 || isCronSessionKey(targetRequesterSessionKey); @@ -479,9 +484,13 @@ export async function runSubagentAnnounceFlow(params: { return "retryable"; } targetRequesterSessionKey = fallback.requesterSessionKey; + targetRequesterAgentId = fallback.requesterAgentId; targetRequesterOrigin = normalizeDeliveryContext(fallback.requesterOrigin) ?? targetRequesterOrigin; - requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); + requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey, { + cfg: subagentAnnounceDeps.getRuntimeConfig(), + agentId: targetRequesterAgentId, + }); requesterIsSubagent = requesterIsInternalSession(); } } @@ -521,7 +530,10 @@ export async function runSubagentAnnounceFlow(params: { // follow-up injection (deliver=false) so the orchestrator receives it. let directOrigin = targetRequesterOrigin; if (!requesterIsSubagent) { - const { entry } = loadRequesterSessionEntry(targetRequesterSessionKey); + const { entry } = loadRequesterSessionEntry( + targetRequesterSessionKey, + targetRequesterAgentId, + ); directOrigin = resolveAnnounceOrigin(entry, targetRequesterOrigin); } const candidateCompletionDirectOrigin = @@ -551,6 +563,7 @@ export async function runSubagentAnnounceFlow(params: { }; const delivery = await deliverSubagentAnnouncement({ requesterSessionKey: targetRequesterSessionKey, + requesterAgentId: targetRequesterAgentId, announceId, triggerMessage, steerMessage: triggerMessage, diff --git a/src/agents/subagents/announce/subagent-requester-store-key.ts b/src/agents/subagents/announce/subagent-requester-store-key.ts index 0047acd00390..892a75dc9011 100644 --- a/src/agents/subagents/announce/subagent-requester-store-key.ts +++ b/src/agents/subagents/announce/subagent-requester-store-key.ts @@ -3,16 +3,17 @@ * * Converts raw requester session keys into the canonical registry key shape. */ -import { - resolveAgentIdFromSessionKey, - resolveMainSessionKey, -} from "../../../config/sessions/main-session.js"; +import { resolveAgentMainSessionKey } from "../../../config/sessions/main-session.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizeMainKey } from "../../../routing/session-key.js"; -import { resolveDefaultAgentId } from "../../agent-scope-config.js"; +import { resolveSessionAgentId } from "../../agent-scope.js"; /** Resolve the canonical store key for a subagent requester session. */ -export function resolveRequesterStoreKey(cfg: OpenClawConfig, requesterSessionKey: string): string { +export function resolveRequesterStoreKey( + cfg: OpenClawConfig, + requesterSessionKey: string, + explicitAgentId?: string, +): string { const raw = (requesterSessionKey ?? "").trim(); if (!raw) { return raw; @@ -23,10 +24,16 @@ export function resolveRequesterStoreKey(cfg: OpenClawConfig, requesterSessionKe if (raw.startsWith("agent:")) { return raw; } + const agentId = resolveSessionAgentId({ + sessionKey: raw, + config: cfg, + agentId: explicitAgentId, + }); const mainKey = normalizeMainKey(cfg?.session?.mainKey); if (raw === "main" || raw === mainKey) { - return resolveMainSessionKey(cfg); + return cfg.session?.scope === "global" + ? "global" + : resolveAgentMainSessionKey({ cfg, agentId }); } - const agentId = resolveAgentIdFromSessionKey(raw, resolveDefaultAgentId(cfg)); return `agent:${agentId}:${raw}`; } diff --git a/src/agents/subagents/registry/subagent-active-context.ts b/src/agents/subagents/registry/subagent-active-context.ts index 029f1f4e7645..a0151078ca5a 100644 --- a/src/agents/subagents/registry/subagent-active-context.ts +++ b/src/agents/subagents/registry/subagent-active-context.ts @@ -22,6 +22,7 @@ function quotePromptData(value: string): string { export function buildActiveSubagentSystemPromptAddition(params: { cfg: OpenClawConfig; controllerSessionKey?: string; + controllerAgentId?: string; hasSessionsYield?: boolean; recentMinutes?: number; }): string | undefined { @@ -35,7 +36,11 @@ export function buildActiveSubagentSystemPromptAddition(params: { alias, mainKey, }); - const runs = listControlledSubagentRuns(controllerSessionKey); + const runs = listControlledSubagentRuns( + controllerSessionKey, + params.controllerAgentId, + params.cfg, + ); if (runs.length === 0) { return undefined; } diff --git a/src/agents/subagents/registry/subagent-control.test.ts b/src/agents/subagents/registry/subagent-control.test.ts index 33b697ee3887..a9a79dcf29a2 100644 --- a/src/agents/subagents/registry/subagent-control.test.ts +++ b/src/agents/subagents/registry/subagent-control.test.ts @@ -3120,5 +3120,33 @@ describe("listControlledSubagentRuns", () => { ), ).toBe(2); }); + + it("partitions duplicate bare controller keys by owning agent", () => { + const now = Date.now(); + for (const agentId of ["research", "ops"]) { + addSubagentRunForTests({ + runId: `run-${agentId}`, + childSessionKey: `agent:${agentId}:subagent:child`, + controllerSessionKey: "global", + requesterSessionKey: "global", + requesterAgentId: agentId, + requesterDisplayKey: "global", + task: `${agentId} task`, + cleanup: "keep", + createdAt: now, + startedAt: now, + }); + } + + const cfg = { + agents: { + ownership: "explicit", + entries: { research: {}, ops: {} }, + }, + } as OpenClawConfig; + expect(listControlledSubagentRuns("global", "research", cfg).map((run) => run.runId)).toEqual([ + "run-research", + ]); + }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/subagents/registry/subagent-control.ts b/src/agents/subagents/registry/subagent-control.ts index 68108792bcb7..ae340cb191e6 100644 --- a/src/agents/subagents/registry/subagent-control.ts +++ b/src/agents/subagents/registry/subagent-control.ts @@ -20,7 +20,11 @@ import { isAgentEventLifecycleGenerationCurrent, } from "../../../infra/agent-events.js"; import { formatErrorMessage } from "../../../infra/errors.js"; -import { isSubagentSessionKey, parseAgentSessionKey } from "../../../routing/session-key.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../../routing/session-key.js"; import { interruptSessionWorkAdmissions, runExclusiveSessionLifecycleMutation, @@ -32,11 +36,13 @@ import { type DetachedTaskTerminalState, } from "../../../tasks/detached-task-runtime-contract.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js"; +import { resolveSessionAgentId } from "../../agent-scope.js"; import { AGENT_LANE_SUBAGENT } from "../../lanes.js"; import { readLatestAssistantReplySnapshot, waitForAgentRunAndReadUpdatedAssistantReply, } from "../../run-wait.js"; +import { resolveSubagentRequesterAgentId } from "../../subagent-requester-owner.js"; import { resolveInternalSessionKey, resolveMainSessionAlias, @@ -150,6 +156,7 @@ async function resolveSubagentControlRuntime(): Promise<{ /** Controller identity and capability scope resolved from the caller session. */ export type ResolvedSubagentController = { controllerSessionKey: string; + controllerAgentId?: string; callerSessionKey: string; callerIsSubagent: boolean; controlScope: "children" | "none"; @@ -158,6 +165,7 @@ export type ResolvedSubagentController = { export function resolveSubagentController(params: { cfg: OpenClawConfig; agentSessionKey?: string; + agentId?: string; }): ResolvedSubagentController { const { mainKey, alias } = resolveMainSessionAlias(params.cfg); const callerRaw = params.agentSessionKey?.trim() || alias; @@ -166,9 +174,15 @@ export function resolveSubagentController(params: { alias, mainKey, }); + const controllerAgentId = resolveSessionAgentId({ + config: params.cfg, + sessionKey: callerSessionKey, + agentId: params.agentId, + }); if (!isSubagentSessionKey(callerSessionKey)) { return { controllerSessionKey: callerSessionKey, + controllerAgentId, callerSessionKey, callerIsSubagent: false, controlScope: "children", @@ -176,30 +190,63 @@ export function resolveSubagentController(params: { } const capabilities = resolveStoredSubagentCapabilities(callerSessionKey, { cfg: params.cfg, + agentId: controllerAgentId, }); return { controllerSessionKey: callerSessionKey, + controllerAgentId, callerSessionKey, callerIsSubagent: true, controlScope: capabilities.controlScope, }; } -function isSubagentRunVisibleToSession(entry: SubagentRunRecord, sessionKey: string): boolean { +function resolveRunRequesterAgentId( + entry: SubagentRunRecord, + cfg?: OpenClawConfig, +): string | undefined { + if (entry.requesterAgentId) { + return entry.requesterAgentId; + } + const parsed = parseAgentSessionKey(entry.requesterSessionKey)?.agentId; + if (parsed || !cfg) { + return parsed; + } + return resolveSubagentRequesterAgentId(cfg, entry); +} + +function isSubagentRunVisibleToSession( + entry: SubagentRunRecord, + sessionKey: string, + agentId: string, + cfg?: OpenClawConfig, +): boolean { const controllerKey = entry.controllerSessionKey?.trim(); const requesterKey = entry.requesterSessionKey.trim(); // Completion routing can target a different session than control ownership. // Both owners may read the run, while ensureControllerOwnsRun still gates mutations. - return controllerKey === sessionKey || requesterKey === sessionKey; + const requesterAgentId = resolveRunRequesterAgentId(entry, cfg); + const controllerAgentId = + (controllerKey ? parseAgentSessionKey(controllerKey)?.agentId : undefined) ?? requesterAgentId; + const normalizedAgentId = normalizeAgentId(agentId); + return ( + (controllerKey === sessionKey && controllerAgentId === normalizedAgentId) || + (requesterKey === sessionKey && requesterAgentId === normalizedAgentId) + ); } /** Builds one stable snapshot for controlled-run listing and descendant status reads. */ -export function buildControlledSubagentRunsReadContext(controllerSessionKey: string): { +export function buildControlledSubagentRunsReadContext( + controllerSessionKey: string, + controllerAgentId?: string, + cfg?: OpenClawConfig, +): { runs: SubagentRunRecord[]; countPendingDescendantRuns(rootSessionKey: string): number; } { const key = controllerSessionKey.trim(); - if (!key) { + const agentId = controllerAgentId ?? parseAgentSessionKey(key)?.agentId; + if (!key || !agentId) { return { runs: [], countPendingDescendantRuns: () => 0, @@ -209,7 +256,7 @@ export function buildControlledSubagentRunsReadContext(controllerSessionKey: str const snapshot = getSubagentRunsSnapshotForRead(subagentRuns); const readIndex = buildSubagentRunReadIndexFromRuns({ runs: snapshot }); const filtered = Array.from(readIndex.latestRunsByChildSessionKey.values()).filter((entry) => - isSubagentRunVisibleToSession(entry, key), + isSubagentRunVisibleToSession(entry, key, agentId, cfg), ); return { runs: sortSubagentRuns(filtered), @@ -219,16 +266,26 @@ export function buildControlledSubagentRunsReadContext(controllerSessionKey: str } /** Lists latest child runs controlled by a session key. */ -export function listControlledSubagentRuns(controllerSessionKey: string): SubagentRunRecord[] { - return buildControlledSubagentRunsReadContext(controllerSessionKey).runs; +export function listControlledSubagentRuns( + controllerSessionKey: string, + controllerAgentId?: string, + cfg?: OpenClawConfig, +): SubagentRunRecord[] { + return buildControlledSubagentRunsReadContext(controllerSessionKey, controllerAgentId, cfg).runs; } function ensureControllerOwnsRun(params: { + cfg: OpenClawConfig; controller: ResolvedSubagentController; entry: SubagentRunRecord; }) { const owner = params.entry.controllerSessionKey?.trim() || params.entry.requesterSessionKey; - if (owner === params.controller.controllerSessionKey) { + const ownerAgentId = + parseAgentSessionKey(owner)?.agentId ?? resolveRunRequesterAgentId(params.entry, params.cfg); + const controllerAgentId = + params.controller.controllerAgentId ?? + parseAgentSessionKey(params.controller.controllerSessionKey)?.agentId; + if (owner === params.controller.controllerSessionKey && ownerAgentId === controllerAgentId) { return undefined; } return "Subagents can only control runs spawned from their own session."; @@ -242,8 +299,35 @@ function isFinishedForSteerControl(entry: SubagentRunRecord, hasPendingDescendan ); } -function isCurrentSubagentRun(entry: SubagentRunRecord): boolean { - return getLatestLiveSubagentRunByChildSessionKey(entry.childSessionKey) === entry; +function getLatestLiveSubagentRunForOwner( + childSessionKey: string, + agentId: string | undefined, + cfg: OpenClawConfig, +): SubagentRunRecord | undefined { + // Agent-scoped child keys already carry their sole owner; any newer generation fences + // the old row. Bare per-agent keys need the explicit owner to avoid cross-agent shadowing. + const ownerFilter = parseAgentSessionKey(childSessionKey) ? undefined : agentId; + return ( + getLatestLiveSubagentRunByChildSessionKey( + childSessionKey, + ownerFilter + ? (candidate) => resolveRunRequesterAgentId(candidate, cfg) === ownerFilter + : undefined, + ) ?? undefined + ); +} + +function isCurrentSubagentRun(entry: SubagentRunRecord, cfg?: OpenClawConfig): boolean { + if (!cfg) { + return getLatestLiveSubagentRunByChildSessionKey(entry.childSessionKey) === entry; + } + return ( + getLatestLiveSubagentRunForOwner( + entry.childSessionKey, + resolveRunRequesterAgentId(entry, cfg), + cfg, + ) === entry + ); } function isSameSubagentRunGeneration( @@ -400,7 +484,7 @@ async function killSubagentRun(params: { scope: resolved.storePath, identities: [childSessionKey, sessionId], prepare: async () => { - if (!isCurrentSubagentRun(params.entry)) { + if (!isCurrentSubagentRun(params.entry, params.cfg)) { return; } admittedWorkReleased = await interruptSessionWorkAdmissions({ @@ -419,7 +503,7 @@ async function killSubagentRun(params: { } // Runtime loading and admission draining yield. Fence the exact row before // touching session-owned queues so a successor cannot inherit an older kill. - if (!isCurrentSubagentRun(params.entry)) { + if (!isCurrentSubagentRun(params.entry, params.cfg)) { return { killed: false, sessionId, superseded: true }; } const targetStateAfterRuntimeLoad = resolveSubagentKillTargetState(params.entry); @@ -434,7 +518,7 @@ async function killSubagentRun(params: { } let killClaim: ReturnType; const killOwnerCurrent = () => - isCurrentSubagentRun(params.entry) && + isCurrentSubagentRun(params.entry, params.cfg) && (!killClaim || ((params.entry.killIntent === killClaim || (params.entry.endedReason === SUBAGENT_ENDED_REASON_KILLED && @@ -676,7 +760,7 @@ async function killSubagentRunTree(params: { if (stopResult.error) { errors.push(`${resolveSubagentLabel(stopped.entry)}: ${stopResult.error}`); } - const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry); + const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry, params.cfg); if (stopResult.superseded || (!stopResult.killed && !stoppedEntryIsCurrent)) { continue; } @@ -782,6 +866,7 @@ export async function killControlledSubagentRun(params: { }; } const ownershipError = ensureControllerOwnsRun({ + cfg: params.cfg, controller: params.controller, entry: currentEntry, }); @@ -809,7 +894,7 @@ export async function killControlledSubagentRun(params: { error: stopResult.error, }; } - const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry); + const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry, params.cfg); if (stopResult.superseded || (!stopResult.killed && !stoppedEntryIsCurrent)) { return { status: "done" as const, @@ -880,12 +965,16 @@ export async function killControlledSubagentRun(params: { } /** Admin kill path for a subagent session key, bypassing caller ownership checks. */ -export async function killSubagentRunAdmin(params: { cfg: OpenClawConfig; sessionKey: string }) { +export async function killSubagentRunAdmin(params: { + cfg: OpenClawConfig; + sessionKey: string; + agentId?: string; +}) { const targetSessionKey = params.sessionKey.trim(); if (!targetSessionKey) { return { found: false as const, killed: false }; } - const entry = getLatestLiveSubagentRunByChildSessionKey(targetSessionKey); + const entry = getLatestLiveSubagentRunForOwner(targetSessionKey, params.agentId, params.cfg); if (!entry) { return { found: false as const, killed: false }; } @@ -907,7 +996,7 @@ export async function killSubagentRunAdmin(params: { cfg: OpenClawConfig; sessio error: stopResult.error, }; } - const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry); + const stoppedEntryIsCurrent = isCurrentSubagentRun(stopped.entry, params.cfg); if (stopResult.superseded || (!stopResult.killed && !stoppedEntryIsCurrent)) { return { found: true as const, @@ -961,7 +1050,7 @@ export async function killSubagentRunAdmin(params: { cfg: OpenClawConfig; sessio expectedSessionId: resolved.entry?.sessionId, expectedLifecycleRevision: resolved.entry?.lifecycleRevision, abortedLastRun: false, - isCurrent: () => isCurrentSubagentRun(stopped.entry), + isCurrent: () => isCurrentSubagentRun(stopped.entry, params.cfg), }); } @@ -1034,6 +1123,7 @@ export async function steerControlledSubagentRun(params: { }; } const ownershipError = ensureControllerOwnsRun({ + cfg: params.cfg, controller: params.controller, entry: currentEntry, }); @@ -1261,6 +1351,7 @@ export async function sendControlledSubagentMessage(params: { message: string; }) { const ownershipError = ensureControllerOwnsRun({ + cfg: params.cfg, controller: params.controller, entry: params.entry, }); diff --git a/src/agents/subagents/registry/subagent-registry-lifecycle.ts b/src/agents/subagents/registry/subagent-registry-lifecycle.ts index 10679f10ec37..cc1fb79993dd 100644 --- a/src/agents/subagents/registry/subagent-registry-lifecycle.ts +++ b/src/agents/subagents/registry/subagent-registry-lifecycle.ts @@ -223,6 +223,7 @@ export class SubagentLifecycleController { settleRequesterTurnAfterSessionSpawns = (args: { requesterSessionKey: string; + requesterAgentId?: string; requesterTurnRunId: string; requesterYielded: boolean; acceptedSessionSpawns: readonly AcceptedSessionSpawn[]; diff --git a/src/agents/subagents/registry/subagent-registry-public-api.ts b/src/agents/subagents/registry/subagent-registry-public-api.ts index b94185c53bfd..25ac1803b5d8 100644 --- a/src/agents/subagents/registry/subagent-registry-public-api.ts +++ b/src/agents/subagents/registry/subagent-registry-public-api.ts @@ -137,6 +137,7 @@ export function createSubagentRegistryPublicApi(config: { function listSwarmRunsForGroup( groupId: string, requesterSessionKey?: string, + requesterAgentId?: string, ): SubagentRunRecord[] { const key = groupId.trim(); const requesterKey = requesterSessionKey?.trim(); @@ -145,7 +146,8 @@ export function createSubagentRegistryPublicApi(config: { entry.collect === true && entry.groupId === key && (!requesterKey || - (entry.swarmRequesterSessionKey ?? entry.requesterSessionKey) === requesterKey), + (entry.swarmRequesterSessionKey ?? entry.requesterSessionKey) === requesterKey) && + (!requesterAgentId || entry.requesterAgentId === requesterAgentId), ); } @@ -153,6 +155,7 @@ export function createSubagentRegistryPublicApi(config: { function getSwarmRunByLaunchReplayKey( replayKey: string, requesterSessionKey?: string, + requesterAgentId?: string, ): SubagentRunRecord | undefined { const key = replayKey.trim(); const requesterKey = requesterSessionKey?.trim(); @@ -164,13 +167,14 @@ export function createSubagentRegistryPublicApi(config: { entry.collect === true && entry.swarmLaunchReplayKey === key && (!requesterKey || - (entry.swarmRequesterSessionKey ?? entry.requesterSessionKey) === requesterKey), + (entry.swarmRequesterSessionKey ?? entry.requesterSessionKey) === requesterKey) && + (!requesterAgentId || entry.requesterAgentId === requesterAgentId), ); } function countActiveRunsForSession( requesterSessionKey: string, - options?: { collect?: boolean }, + options?: { collect?: boolean; requesterAgentId?: string }, ): number { return countActiveRunsForSessionFromRuns(readRuns(), requesterSessionKey, options); } @@ -178,6 +182,7 @@ export function createSubagentRegistryPublicApi(config: { /** Records sessions_yield before the active requester run is aborted. */ function markRequesterTurnYielded(params: { requesterSessionKey: string; + requesterAgentId?: string; requesterTurnRunId: string; }): number { restoreOnce(); diff --git a/src/agents/subagents/registry/subagent-registry-queries.ts b/src/agents/subagents/registry/subagent-registry-queries.ts index 169f0ead2390..0016e350b577 100644 --- a/src/agents/subagents/registry/subagent-registry-queries.ts +++ b/src/agents/subagents/registry/subagent-registry-queries.ts @@ -36,6 +36,7 @@ export function listRunsForRequesterFromRuns( requesterSessionKey: string, options?: { requesterRunId?: string; + requesterAgentId?: string; }, ): SubagentRunRecord[] { const key = requesterSessionKey.trim(); @@ -56,6 +57,7 @@ export function listRunsForRequesterFromRuns( for (const entry of runs.values()) { if ( entry.requesterSessionKey === key && + (!options?.requesterAgentId || entry.requesterAgentId === options.requesterAgentId) && (typeof lowerBound !== "number" || entry.createdAt >= lowerBound) && (typeof upperBound !== "number" || entry.createdAt <= upperBound) ) { @@ -69,6 +71,7 @@ export function listRunsForRequesterFromRuns( export function listRunsForControllerFromRuns( runs: Map, controllerSessionKey: string, + controllerAgentId?: string, ): SubagentRunRecord[] { const key = controllerSessionKey.trim(); const results: SubagentRunRecord[] = []; @@ -76,7 +79,10 @@ export function listRunsForControllerFromRuns( return results; } for (const entry of runs.values()) { - if (resolveControllerSessionKey(entry) === key) { + if ( + resolveControllerSessionKey(entry) === key && + (!controllerAgentId || entry.requesterAgentId === controllerAgentId) + ) { results.push(entry); } } @@ -393,6 +399,7 @@ export function resolveRequesterForChildSessionFromRuns( childSessionKey: string, ): { requesterSessionKey: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; } | null { const latest = getLatestSubagentRunByChildSessionKeyFromRuns(runs, childSessionKey); @@ -401,6 +408,7 @@ export function resolveRequesterForChildSessionFromRuns( } return { requesterSessionKey: latest.requesterSessionKey, + requesterAgentId: latest.requesterAgentId, requesterOrigin: latest.requesterOrigin, }; } @@ -424,7 +432,7 @@ export function shouldIgnorePostCompletionAnnounceForSessionFromRuns( export function countActiveRunsForSessionFromRuns( runs: Map, controllerSessionKey: string, - options?: { collect?: boolean }, + options?: { collect?: boolean; requesterAgentId?: string }, ): number { const key = controllerSessionKey.trim(); if (!key) { @@ -443,6 +451,9 @@ export function countActiveRunsForSessionFromRuns( if (resolveConcurrencyOwnerSessionKey(entry) !== key) { continue; } + if (options?.requesterAgentId && entry.requesterAgentId !== options.requesterAgentId) { + continue; + } rememberLatestRunEntry(latestByChildSessionKey, entry.childSessionKey, entry); } @@ -485,8 +496,18 @@ export function hasDescendantRunAwaitingSettleFromRuns( runs: Map, rootSessionKey: string, excludeRunId?: string, + requesterAgentId?: string, ): boolean { - return buildSubagentRunReadIndexFromRuns({ runs }).hasDescendantRunAwaitingSettle( + const scopedRuns = requesterAgentId + ? new Map( + [...runs].filter( + ([, entry]) => + entry.requesterSessionKey !== rootSessionKey || + entry.requesterAgentId === requesterAgentId, + ), + ) + : runs; + return buildSubagentRunReadIndexFromRuns({ runs: scopedRuns }).hasDescendantRunAwaitingSettle( rootSessionKey, excludeRunId, ); diff --git a/src/agents/subagents/registry/subagent-registry-read.ts b/src/agents/subagents/registry/subagent-registry-read.ts index c85f579ff9ca..5aaf9f0763a3 100644 --- a/src/agents/subagents/registry/subagent-registry-read.ts +++ b/src/agents/subagents/registry/subagent-registry-read.ts @@ -66,10 +66,14 @@ export function buildSubagentRunReadIndex(now = Date.now()): SubagentRunReadInde } /** Lists runs controlled by a session key. */ -export function listSubagentRunsForController(controllerSessionKey: string): SubagentRunRecord[] { +export function listSubagentRunsForController( + controllerSessionKey: string, + controllerAgentId?: string, +): SubagentRunRecord[] { return listRunsForControllerFromRuns( getSubagentRunsSnapshotForController(subagentRuns, controllerSessionKey), controllerSessionKey, + controllerAgentId, ); } @@ -101,17 +105,20 @@ export function countPendingDescendantRuns(rootSessionKey: string): number { export function hasDescendantRunAwaitingSettle( rootSessionKey: string, excludeRunId?: string, + requesterAgentId?: string, ): boolean { return hasDescendantRunAwaitingSettleFromRuns( getSubagentRunsSnapshotForRead(subagentRuns), rootSessionKey, excludeRunId, + requesterAgentId, ); } /** Resolves the requester session and normalized origin for a child subagent session. */ export function resolveRequesterForChildSession(childSessionKey: string): { requesterSessionKey: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; } | null { const resolved = resolveRequesterForChildSessionFromRuns( @@ -123,6 +130,7 @@ export function resolveRequesterForChildSession(childSessionKey: string): { } return { requesterSessionKey: resolved.requesterSessionKey, + requesterAgentId: resolved.requesterAgentId, requesterOrigin: normalizeDeliveryContext(resolved.requesterOrigin), }; } @@ -144,7 +152,7 @@ export function isSubagentSessionRunActive(childSessionKey: string): boolean { /** Lists process-local runs requested by one session key. */ export function listSubagentRunsForRequester( requesterSessionKey: string, - options?: { requesterRunId?: string }, + options?: { requesterRunId?: string; requesterAgentId?: string }, ): SubagentRunRecord[] { // Request-run lifetime scoping must observe the raw live map, including rows not persisted yet. return listRunsForRequesterFromRuns(subagentRuns, requesterSessionKey, options); diff --git a/src/agents/subagents/registry/subagent-registry-requester-yield.ts b/src/agents/subagents/registry/subagent-registry-requester-yield.ts index 4208bd177ab2..97d4a9b7c8c6 100644 --- a/src/agents/subagents/registry/subagent-registry-requester-yield.ts +++ b/src/agents/subagents/registry/subagent-registry-requester-yield.ts @@ -5,6 +5,7 @@ import type { SubagentRunRecord } from "./subagent-registry.types.js"; /** Persists explicit yield intent before the requester run is aborted. */ export function markRequesterTurnYieldedInRuns(params: { requesterSessionKey: string; + requesterAgentId?: string; requesterTurnRunId: string; runs: Map; persistOrThrow(...runIds: string[]): void; @@ -17,6 +18,7 @@ export function markRequesterTurnYieldedInRuns(params: { const entries = [...params.runs.values()].filter( (entry) => entry.requesterSessionKey === requesterSessionKey && + (!params.requesterAgentId || entry.requesterAgentId === params.requesterAgentId) && entry.requesterTurnRunId === requesterTurnRunId && entry.expectsCompletionMessage === true, ); @@ -40,6 +42,7 @@ export function markRequesterTurnYieldedInRuns(params: { export function settleRequesterTurnAfterSessionSpawns(params: { requesterSessionKey: string; + requesterAgentId?: string; requesterTurnRunId: string; requesterYielded: boolean; acceptedSessionSpawns: readonly AcceptedSessionSpawn[]; @@ -61,6 +64,7 @@ export function settleRequesterTurnAfterSessionSpawns(params: { const entries = [...params.runs.values()].filter( (entry) => entry.requesterSessionKey === requesterSessionKey && + (!params.requesterAgentId || entry.requesterAgentId === params.requesterAgentId) && entry.requesterTurnRunId === requesterTurnRunId && entry.expectsCompletionMessage === true, ); diff --git a/src/agents/subagents/registry/subagent-registry-restore.ts b/src/agents/subagents/registry/subagent-registry-restore.ts index 73a7e102fed4..6f493f2c4401 100644 --- a/src/agents/subagents/registry/subagent-registry-restore.ts +++ b/src/agents/subagents/registry/subagent-registry-restore.ts @@ -8,6 +8,10 @@ import { GatewayDrainingError, } from "../../../process/gateway-work-admission.js"; import { emitSessionLifecycleEvent } from "../../../sessions/session-lifecycle-events.js"; +import { + backfillSubagentRequesterAgentIds, + resolveSubagentRequesterAgentId, +} from "../../subagent-requester-owner.js"; import { applySubagentLaunchAuthorization } from "../spawn/subagent-launch-authorization.js"; import { retrySubagentCleanup } from "../spawn/subagent-spawn-cleanup.js"; import { readGatewayRunId } from "../spawn/subagent-spawn-gateway.js"; @@ -57,7 +61,11 @@ export function createSubagentRegistryRestorer(config: { ensureListener: () => void; startSweeper: () => void; resumeRun: (runId: string) => void; - listSwarmRunsForGroup: (groupId: string, requesterSessionKey?: string) => SubagentRunRecord[]; + listSwarmRunsForGroup: ( + groupId: string, + requesterSessionKey?: string, + requesterAgentId?: string, + ) => SubagentRunRecord[]; startQueuedSubagentRun: ( runId: string, gatewayRunId?: string, @@ -153,6 +161,9 @@ export function createSubagentRegistryRestorer(config: { runs, resumedRuns, }); + if (backfillSubagentRequesterAgentIds(cfg, runs.values()) > 0) { + restoredStateChanged = true; + } for (const entry of runs.values()) { if (updateSubagentArchiveAtMs(entry, cfg)) { restoredStateChanged = true; @@ -162,24 +173,32 @@ export function createSubagentRegistryRestorer(config: { persist(); } const requesterTurns = new Map>(); + const resolveRequesterAgentId = (entry: SubagentRunRecord) => + resolveSubagentRequesterAgentId(cfg, entry); for (const entry of runs.values()) { const requesterTurnRunId = entry.requesterTurnRunId?.trim(); if (!requesterTurnRunId) { continue; } - let turns = requesterTurns.get(entry.requesterSessionKey); + const requesterIdentity = `${resolveRequesterAgentId(entry) ?? "unknown"}\0${entry.requesterSessionKey}`; + let turns = requesterTurns.get(requesterIdentity); if (!turns) { turns = new Map(); - requesterTurns.set(entry.requesterSessionKey, turns); + requesterTurns.set(requesterIdentity, turns); } const entries = turns.get(requesterTurnRunId) ?? []; entries.push(entry); turns.set(requesterTurnRunId, entries); } - for (const [requesterSessionKey, turns] of requesterTurns) { + for (const [, turns] of requesterTurns) { for (const [requesterTurnRunId, entries] of turns) { + const firstEntry = entries[0]; + if (!firstEntry) { + continue; + } settleRequesterTurn({ - requesterSessionKey, + requesterSessionKey: firstEntry.requesterSessionKey, + requesterAgentId: resolveRequesterAgentId(firstEntry), requesterTurnRunId, requesterYielded: entries.every((entry) => entry.requesterTurnYielded === true), acceptedSessionSpawns: entries.map((entry) => ({ @@ -226,6 +245,7 @@ export function createSubagentRegistryRestorer(config: { const groupRuns = listSwarmRunsForGroup( entry.groupId ?? "", entry.swarmRequesterSessionKey ?? entry.requesterSessionKey, + entry.requesterAgentId, ); const currentSwarmConfig = resolveSwarmConfig( deps().getRuntimeConfig(), diff --git a/src/agents/subagents/registry/subagent-registry-state.ts b/src/agents/subagents/registry/subagent-registry-state.ts index 388574907922..5bede331ef31 100644 --- a/src/agents/subagents/registry/subagent-registry-state.ts +++ b/src/agents/subagents/registry/subagent-registry-state.ts @@ -67,6 +67,7 @@ function projectSubagentRunForSessionList(entry: SubagentRunRecord): SubagentRun childSessionKey: entry.childSessionKey, ...(entry.controllerSessionKey ? { controllerSessionKey: entry.controllerSessionKey } : {}), requesterSessionKey: entry.requesterSessionKey, + ...(entry.requesterAgentId ? { requesterAgentId: entry.requesterAgentId } : {}), ...(entry.model ? { model: entry.model } : {}), ...(entry.generation !== undefined ? { generation: entry.generation } : {}), createdAt: entry.createdAt, diff --git a/src/agents/subagents/registry/subagent-registry.store.sqlite.ts b/src/agents/subagents/registry/subagent-registry.store.sqlite.ts index a03eedfb02b9..85a739456059 100644 --- a/src/agents/subagents/registry/subagent-registry.store.sqlite.ts +++ b/src/agents/subagents/registry/subagent-registry.store.sqlite.ts @@ -44,6 +44,7 @@ type SubagentRunReadSqliteRow = Pick< outcome_status: string | null; delivery_status: string | null; delivery_suspended_at: number | null; + requester_agent_id: string | null; }; type CanonicalSubagentRunRecord = SubagentRunRecord & Required>; @@ -317,6 +318,7 @@ function readSubagentSessionListRows(): SubagentRunReadSqliteRow[] { subagentPayloadJsonValue("$.generation").as("generation"), subagentPayloadJsonValue("$.execution.outcome.status").as("outcome_status"), subagentPayloadJsonValue("$.delivery.status").as("delivery_status"), + subagentPayloadJsonValue("$.requesterAgentId").as("requester_agent_id"), subagentPayloadJsonValue("$.delivery.suspendedAt").as( "delivery_suspended_at", ), @@ -354,6 +356,7 @@ function rowToSubagentRunReadRecord(row: SubagentRunReadSqliteRow): SubagentRunR childSessionKey, controllerSessionKey: row.controller_session_key?.trim() || undefined, requesterSessionKey, + requesterAgentId: row.requester_agent_id?.trim() || undefined, model: row.model || undefined, generation: normalizeFiniteNumber(row.generation), createdAt: row.created_at, diff --git a/src/agents/subagents/registry/subagent-registry.ts b/src/agents/subagents/registry/subagent-registry.ts index dc1dad984ef0..43ab12d1253c 100644 --- a/src/agents/subagents/registry/subagent-registry.ts +++ b/src/agents/subagents/registry/subagent-registry.ts @@ -327,8 +327,8 @@ const subagentRestorer = createSubagentRegistryRestorer({ ensureListener: () => subagentListener.ensure(), startSweeper: () => subagentSweeper.start(), resumeRun: (runId) => resumeSubagentRun(runId), - listSwarmRunsForGroup: (groupId, requesterSessionKey) => - listSwarmRunsForGroup(groupId, requesterSessionKey), + listSwarmRunsForGroup: (groupId, requesterSessionKey, requesterAgentId) => + listSwarmRunsForGroup(groupId, requesterSessionKey, requesterAgentId), startQueuedSubagentRun: (runId, gatewayRunId, lifecycleGeneration) => subagentRunManager.startQueuedSubagentRun(runId, gatewayRunId, lifecycleGeneration), terminateAcceptedRestoredCollectorRun: ({ diff --git a/src/agents/subagents/registry/subagent-registry.types.ts b/src/agents/subagents/registry/subagent-registry.types.ts index 23187c175472..df10f8e879cb 100644 --- a/src/agents/subagents/registry/subagent-registry.types.ts +++ b/src/agents/subagents/registry/subagent-registry.types.ts @@ -314,6 +314,7 @@ export type SubagentRunReadRecord = Pick< | "childSessionKey" | "controllerSessionKey" | "requesterSessionKey" + | "requesterAgentId" | "model" | "generation" | "createdAt" diff --git a/src/agents/subagents/spawn/acp-spawn-heartbeat.ts b/src/agents/subagents/spawn/acp-spawn-heartbeat.ts index e44e6a850d5d..569ccd27ec8b 100644 --- a/src/agents/subagents/spawn/acp-spawn-heartbeat.ts +++ b/src/agents/subagents/spawn/acp-spawn-heartbeat.ts @@ -4,32 +4,29 @@ import { parseDurationMs } from "../../../cli/parse-duration.js"; import { resolveSessionStorePathCore } from "../../../config/sessions/paths.js"; import { loadSessionEntryReadOnly } from "../../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { isHeartbeatEnabledForAgent } from "../../../infra/heartbeat-summary.js"; import { areHeartbeatsEnabled } from "../../../infra/heartbeat-wake.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../../../routing/session-key.js"; import { deliveryContextFromSession } from "../../../utils/delivery-context.shared.js"; -import { listAgentEntries } from "../../agent-scope-config.js"; -import { resolveAgentConfig, resolveDefaultAgentId } from "../../agent-scope.js"; +import { resolveAgentConfig, resolveSessionAgentIds } from "../../agent-scope.js"; export function isHeartbeatEnabledForSessionAgent(params: { cfg: OpenClawConfig; + requesterAgentId?: string; sessionKey?: string; }): boolean { if (!areHeartbeatsEnabled()) { return false; } - const requesterAgentId = parseAgentSessionKey(params.sessionKey)?.agentId; - if (!requesterAgentId) { + if (!params.sessionKey?.trim()) { return true; } + const requesterAgentId = resolveSessionAgentIds({ + config: params.cfg, + agentId: params.requesterAgentId, + sessionKey: params.sessionKey, + }).sessionAgentId; - const agentEntries = listAgentEntries(params.cfg); - const hasExplicitHeartbeatAgents = agentEntries.some((entry) => Boolean(entry?.heartbeat)); - const enabledByPolicy = hasExplicitHeartbeatAgents - ? agentEntries.some( - (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === requesterAgentId, - ) - : requesterAgentId === resolveDefaultAgentId(params.cfg); - if (!enabledByPolicy) { + if (!isHeartbeatEnabledForAgent(params.cfg, requesterAgentId)) { return false; } diff --git a/src/agents/subagents/spawn/acp-spawn-requester.ts b/src/agents/subagents/spawn/acp-spawn-requester.ts index aac79825e399..e5f1d6980729 100644 --- a/src/agents/subagents/spawn/acp-spawn-requester.ts +++ b/src/agents/subagents/spawn/acp-spawn-requester.ts @@ -126,6 +126,7 @@ export function resolveAcpSpawnRequesterState(params: { hasThreadContext, heartbeatEnabled: isHeartbeatEnabledForSessionAgent({ cfg: params.cfg, + requesterAgentId: params.requesterAgentId, sessionKey: params.parentSessionKey, }), heartbeatRelayRouteUsable: diff --git a/src/agents/subagents/spawn/acp-spawn.ts b/src/agents/subagents/spawn/acp-spawn.ts index 73fdc32bcdd0..a2efbf873fdb 100644 --- a/src/agents/subagents/spawn/acp-spawn.ts +++ b/src/agents/subagents/spawn/acp-spawn.ts @@ -33,7 +33,6 @@ import { recordSubagentSpawned, } from "../../../sessions/session-state-events.js"; import { deliveryContextFromSession } from "../../../utils/delivery-context.shared.js"; -import { resolveDefaultAgentId } from "../../agent-scope.js"; import { reserveChildAdmissionSlot } from "../../child-admission.js"; import { findAcpUnsupportedInheritedToolAllow, @@ -449,7 +448,7 @@ export async function spawnAcpDirect( let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined; const childIdem = crypto.randomUUID(); const parentAgentId = parentSessionKey - ? resolveAgentIdFromSessionKey(parentSessionKey, resolveDefaultAgentId(cfg)) + ? resolveAgentIdFromSessionKey(parentSessionKey, requesterAgentId) : undefined; // Resolve parent session delivery context so system events route to the // correct thread/topic instead of falling back to the main DM. diff --git a/src/agents/subagents/spawn/subagent-capabilities.ts b/src/agents/subagents/spawn/subagent-capabilities.ts index 63005ebeb581..4d45895c5d8c 100644 --- a/src/agents/subagents/spawn/subagent-capabilities.ts +++ b/src/agents/subagents/spawn/subagent-capabilities.ts @@ -147,6 +147,7 @@ export function resolveSubagentCapabilityStore( opts?: { cfg?: OpenClawConfig; store?: SessionCapabilityStore; + agentId?: string; }, ): SessionCapabilityStore | undefined { const normalizedSessionKey = normalizeOptionalString(sessionKey); @@ -315,6 +316,7 @@ export function resolvePersistedSubagentToolPolicyEnvelope( opts?: { cfg?: OpenClawConfig; store?: SessionCapabilityStore; + agentId?: string; }, ): PersistedSubagentToolPolicyEnvelope | undefined { const normalizedSessionKey = normalizeOptionalString(sessionKey); @@ -365,6 +367,7 @@ export function resolveStoredSubagentCapabilities( opts?: { cfg?: OpenClawConfig; store?: SessionCapabilityStore; + agentId?: string; }, ) { const normalizedSessionKey = normalizeOptionalString(sessionKey); @@ -377,6 +380,7 @@ export function resolveStoredSubagentCapabilities( const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, { cfg: opts?.cfg, store: opts?.store, + agentId: opts?.agentId, }); return resolveSubagentCapabilities({ depth, maxSpawnDepth }); } @@ -394,6 +398,7 @@ export function resolveStoredSubagentCapabilities( const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, { cfg: opts?.cfg, store: depthStore, + agentId: opts?.agentId, }); if (!isSubagentEnvelopeSession(normalizedSessionKey, { ...opts, store, entry })) { return resolveSubagentCapabilities({ depth, maxSpawnDepth }); diff --git a/src/agents/subagents/spawn/subagent-depth.test.ts b/src/agents/subagents/spawn/subagent-depth.test.ts index f7eac2f07650..ed31717db65a 100644 --- a/src/agents/subagents/spawn/subagent-depth.test.ts +++ b/src/agents/subagents/spawn/subagent-depth.test.ts @@ -120,6 +120,33 @@ describe("getSubagentDepthFromSessionStore", () => { expect(depth).toBe(2); }); + it("reads a bare fixed-store key through its persisted owner", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-shared-")); + try { + const storePath = path.join(tmpDir, "sessions.sqlite"); + await replaceSessionEntry( + { agentId: "ops", storePath, sessionKey: "global" }, + { + sessionId: "global-session", + updatedAt: Date.now(), + spawnDepth: 2, + }, + ); + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { scope: "global", store: storePath }, + } satisfies OpenClawConfig; + + expect(getSubagentDepthFromSessionStore("global", { cfg })).toBe(2); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("resolves a cross-agent parent outside the supplied child store", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-cross-agent-")); try { @@ -154,6 +181,42 @@ describe("getSubagentDepthFromSessionStore", () => { } }); + it("keeps agent-scoped views separate for a fixed shared store", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-fixed-")); + try { + const storePath = path.join(tmpDir, "sessions.sqlite"); + const childKey = "agent:ops:dashboard:child"; + const parentKey = "agent:research:dashboard:parent"; + await replaceSessionEntry( + { agentId: "ops", storePath, sessionKey: childKey }, + { + sessionId: "child", + updatedAt: Date.now(), + spawnedBy: parentKey, + }, + ); + await replaceSessionEntry( + { agentId: "research", storePath, sessionKey: parentKey }, + { + sessionId: "parent", + updatedAt: Date.now(), + spawnDepth: 2, + }, + ); + + expect( + getSubagentDepthFromSessionStore(childKey, { + cfg: { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + session: { store: storePath }, + }, + }), + ).toBe(3); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("falls back to session-key segment counting when metadata is missing", () => { const key = "agent:main:subagent:flat"; const depth = getSubagentDepthFromSessionStore(key, { diff --git a/src/agents/subagents/spawn/subagent-depth.ts b/src/agents/subagents/spawn/subagent-depth.ts index 80f82908e85d..e215b14ac308 100644 --- a/src/agents/subagents/spawn/subagent-depth.ts +++ b/src/agents/subagents/spawn/subagent-depth.ts @@ -8,8 +8,9 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { resolveSessionStorePathCore } from "../../../config/sessions/paths.js"; import { listSessionEntriesReadOnly } from "../../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { normalizeAgentId } from "../../../routing/session-key.js"; import { getSubagentDepth, parseAgentSessionKey } from "../../../sessions/session-key-utils.js"; -import { resolveDefaultAgentId } from "../../agent-scope.js"; +import { resolveSessionAgentId } from "../../agent-scope.js"; type SessionDepthEntry = { sessionId?: unknown; @@ -43,18 +44,26 @@ export function readSubagentSessionStore; cache: Map>; + agentId?: string; }): SessionDepthEntry | undefined { - const candidates = buildKeyCandidates(params.sessionKey, params.cfg); + const candidates = buildKeyCandidates(params.sessionKey, params.cfg, params.agentId); if (params.store) { for (const key of candidates) { @@ -100,20 +110,25 @@ function resolveEntryForSessionKey(params: { return undefined; } - for (const key of candidates) { - const parsed = parseAgentSessionKey(key); - if (!parsed?.agentId) { - continue; - } - const storePath = resolveSessionStorePathCore(params.cfg.session?.store, { - agentId: parsed.agentId, - }); - let store = params.cache.get(storePath); + const candidateAgentIds = new Set( + candidates.flatMap((key) => { + const agentId = parseAgentSessionKey(key)?.agentId; + return agentId ? [agentId] : []; + }), + ); + for (const agentId of candidateAgentIds) { + const storePath = resolveSessionStorePathCore(params.cfg.session?.store, { agentId }); + // A fixed path still exposes an agent-scoped logical view. Reusing another + // agent's snapshot can erase cross-agent lineage or adopt the wrong row. + const cacheKey = `${storePath}\0${normalizeAgentId(agentId)}`; + let store = params.cache.get(cacheKey); if (!store) { - store = readSubagentSessionStore(storePath, parsed.agentId); - params.cache.set(storePath, store); + store = readSubagentSessionStore(storePath, agentId); + params.cache.set(cacheKey, store); } - const entry = store[key] ?? findSubagentSessionEntryById(store, params.sessionKey); + const entry = + candidates.map((key) => store[key]).find((candidate) => candidate !== undefined) ?? + findSubagentSessionEntryById(store, params.sessionKey); if (entry) { return entry; } @@ -127,6 +142,7 @@ export function getSubagentDepthFromSessionStore( opts?: { cfg?: OpenClawConfig; store?: Record; + agentId?: string; }, ): number { const raw = (sessionKey ?? "").trim(); @@ -153,6 +169,7 @@ export function getSubagentDepthFromSessionStore( cfg: opts?.cfg, store: opts?.store, cache, + agentId: opts?.agentId, }); const storedDepth = normalizeSpawnDepth(entry?.spawnDepth); diff --git a/src/agents/subagents/spawn/subagent-spawn-context.ts b/src/agents/subagents/spawn/subagent-spawn-context.ts index df7890d08e3f..987cf45bdeeb 100644 --- a/src/agents/subagents/spawn/subagent-spawn-context.ts +++ b/src/agents/subagents/spawn/subagent-spawn-context.ts @@ -40,10 +40,12 @@ export async function prepareSubagentSessionContext(params: { const childTarget = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: params.childSessionKey, + agentId: params.targetAgentId, }); const parentTarget = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: params.requesterInternalKey, + agentId: params.requesterAgentId, }); let parentEntry: SessionEntry | undefined; diff --git a/src/agents/subagents/spawn/subagent-spawn-request.ts b/src/agents/subagents/spawn/subagent-spawn-request.ts index 7b6cf630c6a3..d5937b28f60c 100644 --- a/src/agents/subagents/spawn/subagent-spawn-request.ts +++ b/src/agents/subagents/spawn/subagent-spawn-request.ts @@ -2,12 +2,9 @@ import crypto from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { SubagentLifecycleHookRunner } from "../../../plugins/hooks.js"; -import { - isValidAgentId, - normalizeAgentId, - parseAgentSessionKey, -} from "../../../routing/session-key.js"; +import { isValidAgentId, normalizeAgentId } from "../../../routing/session-key.js"; import { listAgentIds } from "../../agent-scope-config.js"; +import { resolveSessionAgentId } from "../../agent-scope.js"; import { reserveChildAdmissionSlot } from "../../child-admission.js"; import { resolveSpawnAdmission, resolveSpawnMode } from "../../spawn-plan.js"; import { listSwarmRunsForGroup } from "../registry/subagent-registry.js"; @@ -158,9 +155,11 @@ export function resolveSubagentSpawnRequest( completionOwnerKey: ctx.completionOwnerKey, }); - const requesterAgentId = normalizeAgentId( - ctx.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId, - ); + const requesterAgentId = resolveSessionAgentId({ + config: cfg, + sessionKey: requesterInternalKey, + agentId: ctx.requesterAgentIdOverride, + }); const swarmConfig = resolveSwarmConfig(cfg, requesterAgentId); const hasSwarmParams = params.collect !== undefined || @@ -218,7 +217,7 @@ export function resolveSubagentSpawnRequest( const resolveAdmission = (pendingChildren = 0) => { const collectorRuns = params.collect ? swarmGroupId - ? listSwarmRunsForGroup(swarmGroupId, requesterInternalKey) + ? listSwarmRunsForGroup(swarmGroupId, requesterInternalKey, requesterAgentId) : [] : undefined; return resolveSpawnAdmission({ @@ -273,7 +272,7 @@ export function resolveSubagentSpawnRequest( : crypto.randomUUID(); let reservationPending = false; if (params.collect && swarmGroupId && swarmSchedulerGroupKey) { - const groupRuns = listSwarmRunsForGroup(swarmGroupId, requesterInternalKey); + const groupRuns = listSwarmRunsForGroup(swarmGroupId, requesterInternalKey, requesterAgentId); if ( !reserveSwarmRun({ groupId: swarmSchedulerGroupKey, diff --git a/src/agents/subagents/spawn/subagent-spawn-requester-prefs.ts b/src/agents/subagents/spawn/subagent-spawn-requester-prefs.ts index 583756ff592c..2962816a23f5 100644 --- a/src/agents/subagents/spawn/subagent-spawn-requester-prefs.ts +++ b/src/agents/subagents/spawn/subagent-spawn-requester-prefs.ts @@ -24,6 +24,7 @@ export function readRequesterThinkingLevel(params: { const target = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: params.requesterInternalKey, + agentId: params.requesterAgentId, }); entry = loadSessionEntry({ storePath: target.storePath, @@ -83,6 +84,7 @@ export function readRequesterFastMode(params: { const target = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: params.requesterInternalKey, + agentId: params.requesterAgentId, }); entry = loadSessionEntry({ storePath: target.storePath, diff --git a/src/agents/subagents/spawn/subagent-spawn.test.ts b/src/agents/subagents/spawn/subagent-spawn.test.ts index 52dca40ae055..364b18376a5b 100644 --- a/src/agents/subagents/spawn/subagent-spawn.test.ts +++ b/src/agents/subagents/spawn/subagent-spawn.test.ts @@ -741,7 +741,11 @@ describe("spawnSubagentDirect seam flow", () => { ); expect(liveRejected.status).toBe("forbidden"); expect(liveRejected.error).toContain("tools.swarm.maxChildrenPerGroup"); - expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenLastCalledWith("group", "agent:main:main"); + expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenLastCalledWith( + "group", + "agent:main:main", + "main", + ); hoisted.listSwarmRunsForGroupMock.mockReturnValueOnce([ { runId: "done", collect: true, collectorCompletion: { status: "done" } }, @@ -765,7 +769,11 @@ describe("spawnSubagentDirect seam flow", () => { ); expect(accepted.status).toBe("accepted"); - expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenCalledWith("fresh", "agent:main:main"); + expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenCalledWith( + "fresh", + "agent:main:main", + "main", + ); }); it("enforces group caps atomically across concurrent collector registration", async () => { @@ -844,6 +852,7 @@ describe("spawnSubagentDirect seam flow", () => { expect(hoisted.registerSubagentRunMock).toHaveBeenCalledTimes(2); expect(hoisted.countActiveRunsForSessionMock).toHaveBeenCalledWith(controllerSessionKey, { collect: false, + requesterAgentId: "main", }); }); @@ -989,6 +998,7 @@ describe("spawnSubagentDirect seam flow", () => { expect(accepted.status).toBe("accepted"); expect(hoisted.countActiveRunsForSessionMock).toHaveBeenCalledWith("agent:main:main", { collect: false, + requesterAgentId: "main", }); }); @@ -1641,7 +1651,11 @@ describe("spawnSubagentDirect seam flow", () => { requesterSessionKey: "agent:main:main", swarmRequesterSessionKey: spawningSessionKey, }); - expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenCalledWith("routed", spawningSessionKey); + expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenCalledWith( + "routed", + spawningSessionKey, + "main", + ); }); it("keeps spawn cwd separate from inherited agent workspace", async () => { diff --git a/src/agents/tools/agent-step.ts b/src/agents/tools/agent-step.ts index 71cd71c57f84..3f2ca97dc8e8 100644 --- a/src/agents/tools/agent-step.ts +++ b/src/agents/tools/agent-step.ts @@ -58,6 +58,7 @@ function extractAgentCommandReply(result: unknown): string | undefined { /** Sends one annotated message to a target session and returns the resulting assistant text. */ export async function runAgentStep(params: { + agentId?: string; sessionKey: string; message: string; extraSystemPrompt: string; @@ -87,6 +88,7 @@ export async function runAgentStep(params: { // Keep announce bookkeeping off the wire without expanding the model-authored RPC surface. const result = await agentStepDeps.agentCommandFromIngress({ message, + ...(params.agentId ? { agentId: params.agentId } : {}), transcriptMessage: params.transcriptMessage, sessionKey: params.sessionKey, deliver: false, @@ -108,6 +110,7 @@ export async function runAgentStep(params: { method: "agent", params: { message, + ...(params.agentId ? { agentId: params.agentId } : {}), sessionKey: params.sessionKey, idempotencyKey: stepIdem, deliver: false, @@ -126,6 +129,7 @@ export async function runAgentStep(params: { const result = await waitForAgentRunAndReadUpdatedAssistantReply({ runId: resolvedRunId, sessionKey: params.sessionKey, + agentId: params.agentId, timeoutMs: Math.min(params.timeoutMs, 60_000), callGateway: gatewayCall, }); diff --git a/src/agents/tools/agents-list-tool.test.ts b/src/agents/tools/agents-list-tool.test.ts index 38930a8286b7..abb009e22bdb 100644 --- a/src/agents/tools/agents-list-tool.test.ts +++ b/src/agents/tools/agents-list-tool.test.ts @@ -207,4 +207,22 @@ describe("agents_list tool", () => { ], }); }); + + it("uses the persisted fixed-store owner for a bare requester key", async () => { + loadConfigMock.mockReturnValue({ + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }); + + const result = await createAgentsListTool({ agentSessionKey: "global" }).execute("call", {}); + + expect(result.details).toMatchObject({ + requester: "ops", + agents: [{ id: "ops", configured: true }], + }); + }); }); diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 88cf1e3b1e8a..64aad897ab72 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -5,10 +5,14 @@ */ import { Type } from "typebox"; import { getRuntimeConfig } from "../../config/config.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { resolveModelAgentRuntimeMetadata } from "../agent-runtime-metadata.js"; -import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../agent-scope-config.js"; -import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "../agent-scope.js"; +import { listAgentEntries, listAgentIds } from "../agent-scope-config.js"; +import { + resolveAgentConfig, + resolveAgentEffectiveModelPrimary, + resolveSessionAgentIds, +} from "../agent-scope.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; import { resolveSubagentAllowedTargetIds } from "../subagents/spawn/subagent-target-policy.js"; import type { AnyAgentTool } from "./common.js"; @@ -96,11 +100,11 @@ export function createAgentsListTool(opts?: { mainKey, }) : alias; - const requesterAgentId = normalizeAgentId( - opts?.requesterAgentIdOverride ?? - parseAgentSessionKey(requesterInternalKey)?.agentId ?? - resolveDefaultAgentId(cfg), - ); + const requesterAgentId = resolveSessionAgentIds({ + config: cfg, + sessionKey: requesterInternalKey, + agentId: opts?.requesterAgentIdOverride, + }).sessionAgentId; const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? diff --git a/src/agents/tools/agents-wait-tool.test.ts b/src/agents/tools/agents-wait-tool.test.ts index 2dae62a205c0..bb9d6cad0968 100644 --- a/src/agents/tools/agents-wait-tool.test.ts +++ b/src/agents/tools/agents-wait-tool.test.ts @@ -314,6 +314,30 @@ describe("agents_wait", () => { expect(isToolResultError(denied)).toBe(true); }); + it("rejects a foreign collector with the same bare requester key", async () => { + const foreign = collectorRun("foreign-global", "global", { status: "done" }); + foreign.requesterAgentId = "ops"; + records.set(foreign.runId, foreign); + const tool = createAgentsWaitTool({ + agentSessionKey: "global", + agentId: "research", + config: { + agents: { ownership: "explicit", entries: { research: {}, ops: {} } }, + tools: { swarm: true }, + }, + }); + + const result = await tool.execute("wait", { + ids: [foreign.runId], + timeoutSeconds: 0, + }); + + expect(result.details).toMatchObject({ + errors: [{ runId: foreign.runId, error: "not_owner" }], + success: false, + }); + }); + it("marks entirely missing collector batches as failures without losing per-id errors", async () => { const tool = createAgentsWaitTool({ agentSessionKey: "agent:main:main", diff --git a/src/agents/tools/agents-wait-tool.ts b/src/agents/tools/agents-wait-tool.ts index deb4ce6cc723..6cc711195387 100644 --- a/src/agents/tools/agents-wait-tool.ts +++ b/src/agents/tools/agents-wait-tool.ts @@ -1,6 +1,9 @@ import { Type } from "typebox"; +import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createAbortError } from "../../infra/abort-signal.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveSubagentCompletionResultText } from "../subagents/completion/subagent-completion-result.js"; import { onSubagentRegistryPersisted } from "../subagents/registry/subagent-registry-state.js"; import { getSubagentRunsByRunIds } from "../subagents/registry/subagent-registry.js"; @@ -19,7 +22,12 @@ const AgentsWaitToolSchema = Type.Object({ type WaitError = { runId: string; error: "not_found" | "not_owner" }; type WaitTarget = { runId: string; entry: SubagentRunRecord }; -function ownsRun(entry: SubagentRunRecord, currentSessionKeys: ReadonlySet): boolean { +function ownsRun( + entry: SubagentRunRecord, + currentSessionKeys: ReadonlySet, + currentAgentId?: string, + config?: OpenClawConfig, +): boolean { const owner = entry.swarmRequesterSessionKey?.trim(); if (!owner) { return false; @@ -28,7 +36,28 @@ function ownsRun(entry: SubagentRunRecord, currentSessionKeys: ReadonlySet 0 ? entry.swarmWaitOwnerSessionKeys : [owner]; - return authorizedSessionKeys.some((sessionKey) => currentSessionKeys.has(sessionKey)); + return authorizedSessionKeys.some((sessionKey) => { + if (!currentSessionKeys.has(sessionKey)) { + return false; + } + const ownerAgentId = + parseAgentSessionKey(sessionKey)?.agentId ?? + entry.requesterAgentId ?? + paramsOwner(config, sessionKey); + return Boolean(ownerAgentId && (!currentAgentId || ownerAgentId === currentAgentId)); + }); +} + +function paramsOwner(config: OpenClawConfig | undefined, sessionKey: string): string | undefined { + if (!config) { + return undefined; + } + const persisted = resolvePersistedSessionStoreOwnerForKey(config, sessionKey); + return persisted.kind === "configured" + ? persisted.agentId + : persisted.kind === "none" + ? tryResolveLegacyCompatibilityAgentId(config) + : undefined; } function completionResult(entry: SubagentRunRecord) { @@ -54,10 +83,17 @@ export type CollectorCompletionResult = NonNullable; + currentAgentId?: string; + config?: OpenClawConfig; signal?: AbortSignal; }): Promise { const readCompletion = (): CollectorCompletionResult | undefined => { - const state = readWaitState([params.runId], params.currentSessionKeys); + const state = readWaitState( + [params.runId], + params.currentSessionKeys, + params.currentAgentId, + params.config, + ); const error = state.errors?.[0]; if (error) { throw new ToolInputError(`agents.run ${error.error}: ${error.runId}`); @@ -108,7 +144,12 @@ export async function waitForCollectorCompletion(params: { }); } -function resolveWaitTargets(ids: readonly string[], currentSessionKeys: ReadonlySet) { +function resolveWaitTargets( + ids: readonly string[], + currentSessionKeys: ReadonlySet, + currentAgentId?: string, + config?: OpenClawConfig, +) { const targets: WaitTarget[] = []; const errors: WaitError[] = []; const snapshot = getSubagentRunsByRunIds(ids); @@ -116,7 +157,7 @@ function resolveWaitTargets(ids: readonly string[], currentSessionKeys: Readonly const entry = snapshot.entries.get(runId); if (!entry?.collect) { errors.push({ runId, error: "not_found" }); - } else if (!ownsRun(entry, currentSessionKeys)) { + } else if (!ownsRun(entry, currentSessionKeys, currentAgentId, config)) { errors.push({ runId, error: "not_owner" }); } else { targets.push({ runId, entry }); @@ -155,14 +196,21 @@ function readResolvedWaitState(targets: readonly WaitTarget[], errors: readonly }; } -function readWaitState(ids: readonly string[], currentSessionKeys: ReadonlySet) { - const resolved = resolveWaitTargets(ids, currentSessionKeys); +function readWaitState( + ids: readonly string[], + currentSessionKeys: ReadonlySet, + currentAgentId?: string, + config?: OpenClawConfig, +) { + const resolved = resolveWaitTargets(ids, currentSessionKeys, currentAgentId, config); return readResolvedWaitState(resolved.targets, resolved.errors); } async function waitForCollector(params: { ids: readonly string[]; currentSessionKeys: ReadonlySet; + currentAgentId?: string; + config?: OpenClawConfig; timeoutMs: number; signal?: AbortSignal; }) { @@ -173,7 +221,12 @@ async function waitForCollector(params: { } // Recovery can replace a registry row while preserving its stable swarm id. // Re-resolve ownership and completion on every poll instead of retaining old objects. - const state = readWaitState(params.ids, params.currentSessionKeys); + const state = readWaitState( + params.ids, + params.currentSessionKeys, + params.currentAgentId, + params.config, + ); if (state.completed.length > 0 || state.pending.length === 0 || Date.now() >= deadline) { return state; } @@ -234,6 +287,8 @@ export function createAgentsWaitTool(opts: { const result = await waitForCollector({ ids, currentSessionKeys, + currentAgentId: opts.agentId, + config: opts.config, timeoutMs: timeoutSeconds * 1_000, signal, }); diff --git a/src/agents/tools/ask-user-tool-normalization.ts b/src/agents/tools/ask-user-tool-normalization.ts new file mode 100644 index 000000000000..97f3b15ee5f2 --- /dev/null +++ b/src/agents/tools/ask-user-tool-normalization.ts @@ -0,0 +1,108 @@ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import type { QuestionRequestQuestion } from "../../../packages/gateway-protocol/src/index.js"; +import { ToolInputError } from "./common.js"; + +export const DEFAULT_ASK_USER_TIMEOUT_SECONDS = 900; +const MIN_ASK_USER_TIMEOUT_SECONDS = 30; +const MAX_ASK_USER_TIMEOUT_SECONDS = 3600; +const QUESTION_ID_PATTERN = /^[a-z][a-z0-9_]*$/; + +export type NormalizedAskUserParams = { + questions: QuestionRequestQuestion[]; + timeoutSeconds: number; +}; + +function readRequiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new ToolInputError(`${label} must be a non-empty string`); + } + return value.trim(); +} + +function normalizeOption(value: unknown, questionIndex: number, optionIndex: number) { + const labelPrefix = `questions[${questionIndex}].options[${optionIndex}]`; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ToolInputError(`${labelPrefix} must be an object`); + } + const record = value as Record; + const label = readRequiredString(record.label, `${labelPrefix}.label`); + if (label.length > 64) { + throw new ToolInputError(`${labelPrefix}.label must be at most 64 characters (use 1-5 words)`); + } + if (record.description !== undefined && typeof record.description !== "string") { + throw new ToolInputError(`${labelPrefix}.description must be a string`); + } + const description = + typeof record.description === "string" ? record.description.trim() : undefined; + return { label, ...(description ? { description } : {}) }; +} + +/** Validates and canonicalizes model-authored ask_user arguments. */ +export function normalizeAskUserParams(value: unknown): NormalizedAskUserParams { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ToolInputError("ask_user arguments must be an object"); + } + const params = value as Record; + if ( + !Array.isArray(params.questions) || + params.questions.length < 1 || + params.questions.length > 3 + ) { + throw new ToolInputError("questions must contain 1 to 3 questions"); + } + const ids = new Set(); + const questions = params.questions.map( + (questionValue, questionIndex): QuestionRequestQuestion => { + const prefix = `questions[${questionIndex}]`; + if (!questionValue || typeof questionValue !== "object" || Array.isArray(questionValue)) { + throw new ToolInputError(`${prefix} must be an object`); + } + const question = questionValue as Record; + const id = readRequiredString(question.id, `${prefix}.id`); + if (!QUESTION_ID_PATTERN.test(id)) { + throw new ToolInputError(`${prefix}.id must be snake_case (for example, deploy_target)`); + } + if (ids.has(id)) { + throw new ToolInputError(`duplicate question id '${id}'`); + } + ids.add(id); + const header = truncateUtf16Safe(readRequiredString(question.header, `${prefix}.header`), 12); + const questionText = readRequiredString(question.question, `${prefix}.question`); + if ( + !Array.isArray(question.options) || + question.options.length < 2 || + question.options.length > 4 + ) { + throw new ToolInputError(`${prefix}.options must contain 2 to 4 options`); + } + if (question.multiSelect !== undefined && typeof question.multiSelect !== "boolean") { + throw new ToolInputError(`${prefix}.multiSelect must be a boolean`); + } + return { + questionId: id, + header, + question: questionText, + options: question.options.map((option, optionIndex) => + normalizeOption(option, questionIndex, optionIndex), + ), + ...(question.multiSelect === true ? { multiSelect: true } : {}), + isOther: true, + }; + }, + ); + + const rawTimeoutSeconds = params.timeoutSeconds; + if ( + rawTimeoutSeconds !== undefined && + (typeof rawTimeoutSeconds !== "number" || + !Number.isFinite(rawTimeoutSeconds) || + !Number.isInteger(rawTimeoutSeconds)) + ) { + throw new ToolInputError("timeoutSeconds must be an integer"); + } + const timeoutSeconds = Math.min( + MAX_ASK_USER_TIMEOUT_SECONDS, + Math.max(MIN_ASK_USER_TIMEOUT_SECONDS, rawTimeoutSeconds ?? DEFAULT_ASK_USER_TIMEOUT_SECONDS), + ); + return { questions, timeoutSeconds }; +} diff --git a/src/agents/tools/ask-user-tool.test.ts b/src/agents/tools/ask-user-tool.test.ts index f7b1394aedc9..f7897dc2e86e 100644 --- a/src/agents/tools/ask-user-tool.test.ts +++ b/src/agents/tools/ask-user-tool.test.ts @@ -104,6 +104,26 @@ describe("ask_user normalization", () => { }); describe("ask_user prompt delivery", () => { + it("reserves duplicate bare keys independently per agent", () => { + const questions = normalizeAskUserParams(validArgs).questions; + const research = reserveAskUserPromptDelivery({ + toolCallId: "call-research", + sessionKey: "global", + agentId: "research", + questions, + }); + const ops = reserveAskUserPromptDelivery({ + toolCallId: "call-ops", + sessionKey: "global", + agentId: "ops", + questions, + }); + + expect(research).toBeDefined(); + expect(ops).toBeDefined(); + expect(research?.questionId).not.toBe(ops?.questionId); + }); + it("uses the Gateway record when the executor has isolated runtime state", async () => { const questions = normalizeAskUserParams(validArgs).questions; const reservation = reserveAskUserPromptDelivery({ diff --git a/src/agents/tools/ask-user-tool.ts b/src/agents/tools/ask-user-tool.ts index 3c51e6e643cb..08037dce5783 100644 --- a/src/agents/tools/ask-user-tool.ts +++ b/src/agents/tools/ask-user-tool.ts @@ -1,23 +1,24 @@ /** Built-in blocking user-question tool and its active-session answer bridge. */ import { createHash } from "node:crypto"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { Type } from "typebox"; import type { QuestionAnswers, QuestionRequestQuestion, QuestionWaitAnswerResult, } from "../../../packages/gateway-protocol/src/index.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { registerPendingAgentQuestion } from "../harness/gateway-question.js"; import { ASK_USER_TOOL_DISPLAY_SUMMARY, describeAskUserTool } from "../tool-description-presets.js"; +import { + DEFAULT_ASK_USER_TIMEOUT_SECONDS, + type NormalizedAskUserParams, + normalizeAskUserParams, +} from "./ask-user-tool-normalization.js"; import { type AnyAgentTool, ToolInputError, textResult } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; -const DEFAULT_ASK_USER_TIMEOUT_SECONDS = 900; -const MIN_ASK_USER_TIMEOUT_SECONDS = 30; -const MAX_ASK_USER_TIMEOUT_SECONDS = 3600; const ASK_USER_RPC_GRACE_MS = 10_000; const ASK_USER_PROMPT_RECHECK_MS = 50; -const QUESTION_ID_PATTERN = /^[a-z][a-z0-9_]*$/; const TERMINAL_QUESTION_ERROR_REASONS = new Set([ "QUESTION_ALREADY_TERMINAL", "QUESTION_NOT_FOUND", @@ -103,117 +104,26 @@ const askUserQuestions = (() => { return questions; })(); -type NormalizedAskUserParams = { - questions: QuestionRequestQuestion[]; - timeoutSeconds: number; -}; - -function readRequiredString(value: unknown, label: string): string { - if (typeof value !== "string" || !value.trim()) { - throw new ToolInputError(`${label} must be a non-empty string`); - } - return value.trim(); -} - -function normalizeOption(value: unknown, questionIndex: number, optionIndex: number) { - const labelPrefix = `questions[${questionIndex}].options[${optionIndex}]`; - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new ToolInputError(`${labelPrefix} must be an object`); - } - const record = value as Record; - const label = readRequiredString(record.label, `${labelPrefix}.label`); - // Telegram button text caps at 64 chars — the tightest native transport. - // Bounding here keeps schema-valid prompts deliverable on every channel. - if (label.length > 64) { - throw new ToolInputError(`${labelPrefix}.label must be at most 64 characters (use 1-5 words)`); - } - if (record.description !== undefined && typeof record.description !== "string") { - throw new ToolInputError(`${labelPrefix}.description must be a string`); - } - const description = - typeof record.description === "string" ? record.description.trim() : undefined; - return { label, ...(description ? { description } : {}) }; -} - -/** Validates and canonicalizes model-authored ask_user arguments. */ -export function normalizeAskUserParams(value: unknown): NormalizedAskUserParams { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new ToolInputError("ask_user arguments must be an object"); - } - const params = value as Record; - if ( - !Array.isArray(params.questions) || - params.questions.length < 1 || - params.questions.length > 3 - ) { - throw new ToolInputError("questions must contain 1 to 3 questions"); - } - const ids = new Set(); - const questions = params.questions.map( - (questionValue, questionIndex): QuestionRequestQuestion => { - const prefix = `questions[${questionIndex}]`; - if (!questionValue || typeof questionValue !== "object" || Array.isArray(questionValue)) { - throw new ToolInputError(`${prefix} must be an object`); - } - const question = questionValue as Record; - const id = readRequiredString(question.id, `${prefix}.id`); - if (!QUESTION_ID_PATTERN.test(id)) { - throw new ToolInputError(`${prefix}.id must be snake_case (for example, deploy_target)`); - } - if (ids.has(id)) { - throw new ToolInputError(`duplicate question id '${id}'`); - } - ids.add(id); - const header = truncateUtf16Safe(readRequiredString(question.header, `${prefix}.header`), 12); - const questionText = readRequiredString(question.question, `${prefix}.question`); - if ( - !Array.isArray(question.options) || - question.options.length < 2 || - question.options.length > 4 - ) { - throw new ToolInputError(`${prefix}.options must contain 2 to 4 options`); - } - if (question.multiSelect !== undefined && typeof question.multiSelect !== "boolean") { - throw new ToolInputError(`${prefix}.multiSelect must be a boolean`); - } - return { - questionId: id, - header, - question: questionText, - options: question.options.map((option, optionIndex) => - normalizeOption(option, questionIndex, optionIndex), - ), - ...(question.multiSelect === true ? { multiSelect: true } : {}), - isOther: true, - }; - }, - ); - - const rawTimeoutSeconds = params.timeoutSeconds; - if ( - rawTimeoutSeconds !== undefined && - (typeof rawTimeoutSeconds !== "number" || - !Number.isFinite(rawTimeoutSeconds) || - !Number.isInteger(rawTimeoutSeconds)) - ) { - throw new ToolInputError("timeoutSeconds must be an integer"); - } - const timeoutSeconds = Math.min( - MAX_ASK_USER_TIMEOUT_SECONDS, - Math.max(MIN_ASK_USER_TIMEOUT_SECONDS, rawTimeoutSeconds ?? DEFAULT_ASK_USER_TIMEOUT_SECONDS), - ); - return { questions, timeoutSeconds }; -} +export { normalizeAskUserParams } from "./ask-user-tool-normalization.js"; /** Stable client-generated gateway question id shared with tool-start delivery. */ -function buildAskUserQuestionId(toolCallId: string, sessionKey?: string, runId?: string): string { - const owner = runId?.trim() || sessionKey?.trim() || ""; +function buildAskUserQuestionId( + toolCallId: string, + sessionKey?: string, + runId?: string, + agentId?: string, +): string { + const owner = runId?.trim() || askUserSessionKey(sessionKey, agentId); const identity = `${owner}\0${toolCallId}`; return `ask_${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`; } function askUserSessionKey(sessionKey: string | undefined, agentId?: string): string { - return sessionKey?.trim() || (agentId?.trim() ? `agent:${agentId.trim()}` : "session:unknown"); + const normalizedSessionKey = sessionKey?.trim(); + if (normalizedSessionKey && parseAgentSessionKey(normalizedSessionKey)) { + return normalizedSessionKey; + } + return `${agentId?.trim() || "unknown"}\0${normalizedSessionKey || "session:unknown"}`; } function findAskUserQuestionForSession(sessionKey: string): AskUserQuestionState | undefined { @@ -270,14 +180,20 @@ export function reserveAskUserPromptDelivery(params: { toolCallId: string; sessionKey?: string; runId?: string; + agentId?: string; questions: QuestionRequestQuestion[]; timeoutSeconds?: number; }): { questionId: string } | undefined { - const sessionKey = askUserSessionKey(params.sessionKey); + const sessionKey = askUserSessionKey(params.sessionKey, params.agentId); if (findAskUserQuestionForSession(sessionKey)) { return undefined; } - const questionId = buildAskUserQuestionId(params.toolCallId, params.sessionKey, params.runId); + const questionId = buildAskUserQuestionId( + params.toolCallId, + params.sessionKey, + params.runId, + params.agentId, + ); if (askUserQuestions.has(questionId)) { return undefined; } @@ -467,8 +383,9 @@ export function cancelAskUserPromptDelivery( toolCallId: string, sessionKey?: string, runId?: string, + agentId?: string, ): void { - releaseAskUserQuestion(buildAskUserQuestionId(toolCallId, sessionKey, runId)); + releaseAskUserQuestion(buildAskUserQuestionId(toolCallId, sessionKey, runId, agentId)); } function answeredResult(questions: readonly QuestionRequestQuestion[], answers: QuestionAnswers) { @@ -553,7 +470,12 @@ export function createAskUserTool(params: { description: describeAskUserTool(), parameters: AskUserToolSchema, execute: async (toolCallId, args, signal) => { - const questionId = buildAskUserQuestionId(toolCallId, params.sessionKey, params.runId); + const questionId = buildAskUserQuestionId( + toolCallId, + params.sessionKey, + params.runId, + params.agentId, + ); let normalized: NormalizedAskUserParams; try { signal?.throwIfAborted(); diff --git a/src/agents/tools/cron-tool-caller-scope.ts b/src/agents/tools/cron-tool-caller-scope.ts index 10f1cca9ed70..dfaada171a86 100644 --- a/src/agents/tools/cron-tool-caller-scope.ts +++ b/src/agents/tools/cron-tool-caller-scope.ts @@ -14,7 +14,7 @@ export function resolveCronToolCallerScope( } return { kind: "agentTool", - agentId: resolveSessionAgentId({ sessionKey, config: cfg }), + agentId: resolveSessionAgentId({ sessionKey, config: cfg, agentId: opts?.agentId }), }; } diff --git a/src/agents/tools/cron-tool-context.ts b/src/agents/tools/cron-tool-context.ts index 8b97db252241..839f402eb7a4 100644 --- a/src/agents/tools/cron-tool-context.ts +++ b/src/agents/tools/cron-tool-context.ts @@ -34,6 +34,7 @@ function extractMessageText(message: ChatMessage): { role: string; text: string export async function buildReminderContextLines(params: { agentSessionKey?: string; + agentId?: string; gatewayOpts: GatewayCallOptions; contextMessages: number; callGatewayTool: GatewayToolCaller; @@ -58,6 +59,7 @@ export async function buildReminderContextLines(params: { params.gatewayOpts, { sessionKey: resolvedKey, + agentId: params.agentId, limit: maxMessages, }, ); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 8f046ee944fd..e87162b3efb6 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -480,6 +480,7 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any if (typeof payload.text === "string" && payload.text.trim()) { const contextLines = await buildReminderContextLines({ agentSessionKey: opts?.agentSessionKey, + agentId: callerScope?.agentId, gatewayOpts, contextMessages, callGatewayTool: callGateway, @@ -669,7 +670,11 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any ? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey }) : undefined; const inferredAgentId = opts?.agentSessionKey - ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) + ? resolveSessionAgentId({ + sessionKey: opts.agentSessionKey, + config: cfg, + agentId: opts.agentId, + }) : undefined; const sessionKey = explicitSessionKey ?? inferredSessionKey; // When a caller supplies an explicit cross-agent sessionKey without diff --git a/src/agents/tools/cron-tool.types.ts b/src/agents/tools/cron-tool.types.ts index 21f00130f545..51022c6c6171 100644 --- a/src/agents/tools/cron-tool.types.ts +++ b/src/agents/tools/cron-tool.types.ts @@ -38,6 +38,7 @@ export type CronCreatorToolAuthoritySnapshot = Omit< export type CronToolOptions = { agentSessionKey?: string; + agentId?: string; /** Authenticated source account; authority must not be inferred from delivery. */ agentAccountId?: string; /** diff --git a/src/agents/tools/dashboard-tool.ts b/src/agents/tools/dashboard-tool.ts index b16078b0da5a..c3b15ca21fe7 100644 --- a/src/agents/tools/dashboard-tool.ts +++ b/src/agents/tools/dashboard-tool.ts @@ -84,7 +84,11 @@ const DashboardToolSchema = Type.Object( { additionalProperties: false }, ); -type DashboardCommandEmitter = (params: { sessionKey: string; command: BoardCommand }) => number; +type DashboardCommandEmitter = (params: { + sessionKey: string; + agentId?: string; + command: BoardCommand; +}) => number; type DashboardGatewayContext = { getClientConnIds?: ( @@ -95,6 +99,7 @@ type DashboardGatewayContext = { type DashboardToolOptions = { agentSessionKey?: string; + agentId?: string; callGateway?: InProcessGatewayCaller; emitCommand?: DashboardCommandEmitter; }; @@ -220,7 +225,11 @@ function opForAction(action: string, params: Record): BoardOp { } } -function emitBoardCommand(params: { sessionKey: string; command: BoardCommand }): number { +function emitBoardCommand(params: { + sessionKey: string; + agentId?: string; + command: BoardCommand; +}): number { const context = getInProcessGatewayToolContext() as DashboardGatewayContext | undefined; if (!context) { throw new ToolInputError("dashboard command unavailable outside gateway runtime"); @@ -254,11 +263,17 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo const action = readToolStringParam(params, "action", { required: true }); const sessionKey = requireSessionKey(opts.agentSessionKey); if (action === "read") { - return snapshotResult(await gatewayCall("board.get", { sessionKey })); + return snapshotResult( + await gatewayCall("board.get", { + sessionKey, + agentId: opts.agentId, + }), + ); } if (action === "focus_tab") { const delivered = emitCommand({ sessionKey, + agentId: opts.agentId, command: { kind: "focus_tab", tabId: readTabId(params), @@ -274,7 +289,11 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo if (!dock) { throw new ToolInputError("dock required"); } - const delivered = emitCommand({ sessionKey, command: { kind: "set_chat_dock", dock } }); + const delivered = emitCommand({ + sessionKey, + agentId: opts.agentId, + command: { kind: "set_chat_dock", dock }, + }); return textResult(`Dashboard command sent to ${delivered} client(s)`, { ok: true, delivered, @@ -293,6 +312,7 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo return snapshotResult( await gatewayCall("board.widget.put", { sessionKey, + agentId: opts.agentId, name: readToolStringParam(params, "name", { required: true }), ...(title !== undefined ? { title } : {}), content: { @@ -315,6 +335,7 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo return snapshotResult( await gatewayCall("board.update", { sessionKey, + agentId: opts.agentId, ops: [opForAction(action, params)], }), ); diff --git a/src/agents/tools/embedded-gateway-stub.test.ts b/src/agents/tools/embedded-gateway-stub.test.ts index a07836b20d2f..659c0d59c906 100644 --- a/src/agents/tools/embedded-gateway-stub.test.ts +++ b/src/agents/tools/embedded-gateway-stub.test.ts @@ -202,6 +202,26 @@ describe("embedded gateway stub", () => { expect(runtime.searchSessionTranscripts).not.toHaveBeenCalled(); }); + it("rejects an explicit agent that conflicts with an unscoped store owner", async () => { + runtime.resolveSessionAgentId.mockImplementationOnce(() => { + throw new Error('The shared fixed-store row belongs to "ops", not "research".'); + }); + const callGateway = createEmbeddedCallGateway(); + + await expect( + callGateway({ + method: "sessions.search", + params: { agentId: "research", query: "needle", sessionKeys: ["global"] }, + }), + ).rejects.toThrow('belongs to "ops", not "research"'); + expect(runtime.resolveSessionAgentId).toHaveBeenCalledWith({ + sessionKey: "global", + config: { agents: { list: [{ id: "main", default: true }] } }, + agentId: "research", + }); + expect(runtime.searchSessionTranscripts).not.toHaveBeenCalled(); + }); + it("projects embedded chat history through the shared display projector", async () => { // Embedded history must use the same projection path as gateway history so // byte/message limits and display filtering stay aligned. diff --git a/src/agents/tools/embedded-gateway-stub.ts b/src/agents/tools/embedded-gateway-stub.ts index 69810d9b357f..15cac55b3bc6 100644 --- a/src/agents/tools/embedded-gateway-stub.ts +++ b/src/agents/tools/embedded-gateway-stub.ts @@ -229,7 +229,7 @@ async function handleSessionsResolve(params: Record) { if ("ambiguous" in resolved) { return { ok: false, candidates: resolved.candidates }; } - return { ok: true, key: resolved.key }; + return { ok: true, key: resolved.key, agentId: resolved.agentId }; } async function handleSessionsSearch(params: Record) { @@ -264,9 +264,11 @@ async function handleSessionsSearch(params: Record) { ); const agentIds = new Set( sessionKeys?.map((sessionKey) => - requestedAgentId && (sessionKey === "global" || sessionKey === "unknown") - ? requestedAgentId - : rt.resolveSessionAgentId({ sessionKey, config: cfg }), + rt.resolveSessionAgentId({ + sessionKey, + config: cfg, + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + }), ), ); if ( diff --git a/src/agents/tools/image-generate-tool.actions.ts b/src/agents/tools/image-generate-tool.actions.ts index 7645aae28061..8c4364944b59 100644 --- a/src/agents/tools/image-generate-tool.actions.ts +++ b/src/agents/tools/image-generate-tool.actions.ts @@ -104,7 +104,8 @@ export function createImageGenerateListActionResult(params: { const imageGenerateTaskStatusActions = createMediaGenerateTaskStatusActions({ inactiveText: "No active image generation task is currently running for this session.", - findActiveTask: (sessionKey) => findActiveImageGenerationTaskForSession(sessionKey) ?? undefined, + findActiveTask: (sessionKey, agentId) => + findActiveImageGenerationTaskForSession(sessionKey, { agentId }) ?? undefined, buildStatusText: buildImageGenerationTaskStatusText, buildStatusDetails: buildImageGenerationTaskStatusDetails, }); @@ -112,8 +113,9 @@ const imageGenerateTaskStatusActions = createMediaGenerateTaskStatusActions({ /** Builds status output for active image-generation tasks in the current session. */ export function createImageGenerateStatusActionResult( sessionKey?: string, + agentId?: string, ): ImageGenerateActionResult { - const activeTasks = listActiveImageGenerationTasksForSession(sessionKey); + const activeTasks = listActiveImageGenerationTasksForSession(sessionKey, agentId); if (activeTasks.length > 1) { return { content: [{ type: "text", text: buildImageGenerationTaskStatusListText(activeTasks) }], @@ -123,18 +125,19 @@ export function createImageGenerateStatusActionResult( }, }; } - return imageGenerateTaskStatusActions.createStatusActionResult(sessionKey); + return imageGenerateTaskStatusActions.createStatusActionResult(sessionKey, agentId); } /** Returns duplicate-guard status output when a matching image task is already active. */ export function createImageGenerateDuplicateGuardResult( sessionKey?: string, - params?: { prompt?: string; requestKey?: string }, + params?: { prompt?: string; requestKey?: string; agentId?: string }, ): ImageGenerateActionResult | undefined { return createMediaGenerateDuplicateGuardResult({ sessionKey, prompt: params?.prompt, requestKey: params?.requestKey, + agentId: params?.agentId, findDuplicateTask: findDuplicateGuardImageGenerationTaskForSession, buildStatusText: buildImageGenerationTaskStatusText, buildStatusDetails: buildImageGenerationTaskStatusDetails, diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 80f903aec47d..6bf1dd2b6385 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -869,6 +869,7 @@ export function createImageGenerateTool(options?: { agentDir?: string; authProfileStore?: AuthProfileStore; agentSessionKey?: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; workspaceDir?: string; preparedModelRuntime?: PreparedModelRuntimeSnapshot; @@ -924,7 +925,10 @@ export function createImageGenerateTool(options?: { }); } if (action === "status") { - return createImageGenerateStatusActionResult(options?.agentSessionKey); + return createImageGenerateStatusActionResult( + options?.agentSessionKey, + options?.requesterAgentId, + ); } const model = readToolStringParam(params, "model"); @@ -949,7 +953,7 @@ export function createImageGenerateTool(options?: { const activeDuplicateGuardResult = createImageGenerateDuplicateGuardResult( options?.agentSessionKey, - { prompt }, + { prompt, agentId: options?.requesterAgentId }, ); if (activeDuplicateGuardResult) { return activeDuplicateGuardResult; @@ -1018,7 +1022,7 @@ export function createImageGenerateTool(options?: { }); const duplicateGuardResult = createImageGenerateDuplicateGuardResult( options?.agentSessionKey, - { prompt, requestKey }, + { prompt, requestKey, agentId: options?.requesterAgentId }, ); if (duplicateGuardResult) { return duplicateGuardResult; @@ -1073,17 +1077,20 @@ export function createImageGenerateTool(options?: { signal?.throwIfAborted(); const taskHandle = createImageGenerationTaskRun({ sessionKey: options?.agentSessionKey, + requesterAgentId: options?.requesterAgentId, requesterOrigin: options?.requesterOrigin, prompt, providerId: selectedProvider?.id, }); const shouldDetach = Boolean( - taskHandle && shouldDetachMediaGenerationTask(options?.agentSessionKey), + taskHandle && + shouldDetachMediaGenerationTask(options?.agentSessionKey, options?.requesterAgentId), ); if (shouldDetach && taskHandle) { recordRecentMediaGenerationTaskStartForSession({ sessionKey: options?.agentSessionKey, + agentId: options?.requesterAgentId, taskKind: "image_generation", sourcePrefix: "image_generate", taskId: taskHandle.taskId, diff --git a/src/agents/tools/media-generate-background-shared.ts b/src/agents/tools/media-generate-background-shared.ts index 310c8dc10dc1..cff9d9fe2c61 100644 --- a/src/agents/tools/media-generate-background-shared.ts +++ b/src/agents/tools/media-generate-background-shared.ts @@ -51,6 +51,7 @@ export type MediaGenerationTaskHandle = { taskId: string; runId: string; requesterSessionKey: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; taskLabel: string; }; @@ -62,7 +63,10 @@ export type MediaGenerateBackgroundScheduler = (work: () => Promise) => vo export type MediaGenerateAsyncStartCallback = (message: string) => Promise | void; /** Returns whether a media generation request should detach for a session. */ -export function shouldDetachMediaGenerationTask(sessionKey: string | undefined): boolean { +export function shouldDetachMediaGenerationTask( + sessionKey: string | undefined, + requesterAgentId?: string, +): boolean { const normalizedSessionKey = sessionKey?.trim(); if (!normalizedSessionKey) { return false; @@ -73,6 +77,7 @@ export function shouldDetachMediaGenerationTask(sessionKey: string | undefined): try { const entry = loadSessionEntryReadOnly({ sessionKey: normalizedSessionKey, + agentId: requesterAgentId, clone: false, hydrateSkillPromptRefs: false, readConsistency: "latest", @@ -105,6 +110,7 @@ type MediaGenerationExecutionResult = { type CreateMediaGenerationTaskRunParams = { sessionKey?: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; prompt: string; providerId?: string; @@ -193,12 +199,14 @@ function touchMediaGenerationTaskRunContext(handle: MediaGenerationTaskHandle) { registerGeneratedMediaTaskActivity(handle.runId, handle.requesterSessionKey); registerAgentRunContext(handle.runId, { sessionKey: handle.requesterSessionKey, + agentId: handle.requesterAgentId, lastActiveAt: Date.now(), }); } function createMediaGenerationTaskRun(params: { sessionKey?: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; prompt: string; providerId?: string; @@ -216,7 +224,7 @@ function createMediaGenerationTaskRun(params: { // Pin the complete requester route when detached work starts. Completion-time // session state can move to another peer while generation is still running. const requesterOrigin = resolveAnnounceOrigin( - loadRequesterSessionEntry(sessionKey).entry, + loadRequesterSessionEntry(sessionKey, params.requesterAgentId).entry, params.requesterOrigin, ); const task = createRunningTaskRun({ @@ -224,6 +232,7 @@ function createMediaGenerationTaskRun(params: { taskKind: params.taskKind, sourceId: params.providerId ? `${params.toolName}:${params.providerId}` : params.toolName, requesterSessionKey: sessionKey, + requesterAgentId: params.requesterAgentId, ownerKey: sessionKey, scopeKind: "session", requesterOrigin, @@ -244,6 +253,7 @@ function createMediaGenerationTaskRun(params: { taskId: task.taskId, runId, requesterSessionKey: sessionKey, + requesterAgentId: params.requesterAgentId, requesterOrigin, taskLabel: params.prompt, }; @@ -649,6 +659,7 @@ async function wakeMediaGenerationTaskCompletion(params: { `A ${params.completionLabel} generation task finished. Process the completion update now.`; const delivery = await deliverSubagentAnnouncement({ requesterSessionKey: params.handle.requesterSessionKey, + requesterAgentId: params.handle.requesterAgentId, targetRequesterSessionKey: params.handle.requesterSessionKey, announceId, triggerMessage, diff --git a/src/agents/tools/media-generate-tool-actions-shared.ts b/src/agents/tools/media-generate-tool-actions-shared.ts index 9016f9fe8c0c..0b2d6900e8ed 100644 --- a/src/agents/tools/media-generate-tool-actions-shared.ts +++ b/src/agents/tools/media-generate-tool-actions-shared.ts @@ -21,7 +21,7 @@ type MediaGenerateActionResult = { type TaskStatusTextBuilder = (task: Task, params?: { duplicateGuard?: boolean }) => string; type MediaGenerateTaskStatusParams = { inactiveText: string; - findActiveTask: (sessionKey?: string) => Task | undefined; + findActiveTask: (sessionKey?: string, agentId?: string) => Task | undefined; buildStatusText: TaskStatusTextBuilder; buildStatusDetails: (task: Task) => Record; }; @@ -163,8 +163,12 @@ export function createMediaGenerateTaskStatusActions( params: MediaGenerateTaskStatusParams, ) { return { - createStatusActionResult(this: void, sessionKey?: string): MediaGenerateActionResult { - const activeTask = params.findActiveTask(sessionKey); + createStatusActionResult( + this: void, + sessionKey?: string, + agentId?: string, + ): MediaGenerateActionResult { + const activeTask = params.findActiveTask(sessionKey, agentId); return activeTask ? { content: [{ type: "text", text: params.buildStatusText(activeTask) }], @@ -183,7 +187,7 @@ export function createMediaGenerateTaskActions( params: MediaGenerateTaskStatusParams & { findDuplicateTask: ( sessionKey?: string, - request?: { prompt?: string; requestKey?: string }, + request?: { prompt?: string; requestKey?: string; agentId?: string }, ) => Task | undefined; }, ) { @@ -192,7 +196,7 @@ export function createMediaGenerateTaskActions( createDuplicateGuardResult( this: void, sessionKey?: string, - request?: { prompt?: string; requestKey?: string }, + request?: { prompt?: string; requestKey?: string; agentId?: string }, ) { return createMediaGenerateDuplicateGuardResult({ sessionKey, ...request, ...params }); }, @@ -204,9 +208,10 @@ export function createMediaGenerateDuplicateGuardResult(params: { sessionKey?: string; prompt?: string; requestKey?: string; + agentId?: string; findDuplicateTask: ( sessionKey?: string, - params?: { prompt?: string; requestKey?: string }, + params?: { prompt?: string; requestKey?: string; agentId?: string }, ) => Task | undefined; buildStatusText: TaskStatusTextBuilder; buildStatusDetails: (task: Task) => Record; @@ -214,6 +219,7 @@ export function createMediaGenerateDuplicateGuardResult(params: { const blockingTask = params.findDuplicateTask(params.sessionKey, { prompt: params.prompt, requestKey: params.requestKey, + agentId: params.agentId, }); if (!blockingTask) { return undefined; diff --git a/src/agents/tools/message-tool-execution.ts b/src/agents/tools/message-tool-execution.ts index c16cd9d4e18b..661be2b7262a 100644 --- a/src/agents/tools/message-tool-execution.ts +++ b/src/agents/tools/message-tool-execution.ts @@ -244,7 +244,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { // Poll-vote echo record lives in the session-scoped map (recentPollVoteBySession) // so it survives the run boundary between the vote and the follow-up text; a // null session key disables the guard. - const pollEchoSessionKey = options?.agentSessionKey?.trim() || undefined; + const rawPollEchoSessionKey = options?.agentSessionKey?.trim() || undefined; const failedAutogeneratedIdempotencyKeys = new Map(); const effectiveCurrentChannel = resolveEffectiveCurrentChannelContext(options); const currentThreadTs = @@ -271,6 +271,10 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { config: options?.config, }) : undefined); + const pollEchoSessionKey = + rawPollEchoSessionKey && resolvedAgentId + ? `${resolvedAgentId}\0${rawPollEchoSessionKey}` + : undefined; const messageToolDiscoveryParams: MessageToolDiscoveryParams | undefined = options?.config && !options.sourceReplyOnly ? { diff --git a/src/agents/tools/music-generate-tool.actions.ts b/src/agents/tools/music-generate-tool.actions.ts index 327e68bb65c5..f48d66e04e5e 100644 --- a/src/agents/tools/music-generate-tool.actions.ts +++ b/src/agents/tools/music-generate-tool.actions.ts @@ -87,7 +87,8 @@ export const { createDuplicateGuardResult: createMusicGenerateDuplicateGuardResult, } = createMediaGenerateTaskActions({ inactiveText: "No active music generation task is currently running for this session.", - findActiveTask: findActiveMusicGenerationTaskForSession, + findActiveTask: (sessionKey, agentId) => + findActiveMusicGenerationTaskForSession(sessionKey, { agentId }), // Prompt-only imports must not resolve duplicate guards until an action runs. findDuplicateTask: (sessionKey, request) => findDuplicateGuardMusicGenerationTaskForSession(sessionKey, request), diff --git a/src/agents/tools/music-generate-tool.ts b/src/agents/tools/music-generate-tool.ts index dc4b8ec36495..7a97a372bd1e 100644 --- a/src/agents/tools/music-generate-tool.ts +++ b/src/agents/tools/music-generate-tool.ts @@ -614,6 +614,7 @@ export function createMusicGenerateTool(options?: { agentDir?: string; authProfileStore?: AuthProfileStore; agentSessionKey?: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; workspaceDir?: string; preparedModelRuntime?: PreparedModelRuntimeSnapshot; @@ -671,7 +672,10 @@ export function createMusicGenerateTool(options?: { } if (action === "status") { - return createMusicGenerateStatusActionResult(options?.agentSessionKey); + return createMusicGenerateStatusActionResult( + options?.agentSessionKey, + options?.requesterAgentId, + ); } const musicGenerationModelConfig = resolveMusicGenerationModelConfigForTool({ @@ -690,7 +694,7 @@ export function createMusicGenerateTool(options?: { const activeDuplicateGuardResult = createMusicGenerateDuplicateGuardResult( options?.agentSessionKey, - { prompt }, + { prompt, agentId: options?.requesterAgentId }, ); if (activeDuplicateGuardResult) { return activeDuplicateGuardResult; @@ -749,7 +753,7 @@ export function createMusicGenerateTool(options?: { }); const duplicateGuardResult = createMusicGenerateDuplicateGuardResult( options?.agentSessionKey, - { prompt, requestKey }, + { prompt, requestKey, agentId: options?.requesterAgentId }, ); if (duplicateGuardResult) { return duplicateGuardResult; @@ -775,17 +779,20 @@ export function createMusicGenerateTool(options?: { signal?.throwIfAborted(); const taskHandle = createMusicGenerationTaskRun({ sessionKey: options?.agentSessionKey, + requesterAgentId: options?.requesterAgentId, requesterOrigin: options?.requesterOrigin, prompt, providerId: selectedProvider?.id ?? selectedModelRef?.provider, }); const shouldDetach = Boolean( - taskHandle && shouldDetachMediaGenerationTask(options?.agentSessionKey), + taskHandle && + shouldDetachMediaGenerationTask(options?.agentSessionKey, options?.requesterAgentId), ); if (shouldDetach && taskHandle) { recordRecentMediaGenerationTaskStartForSession({ sessionKey: options?.agentSessionKey, + agentId: options?.requesterAgentId, taskKind: "music_generation", sourcePrefix: "music_generate", taskId: taskHandle.taskId, diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 8409b73c1399..adc207e95375 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -167,6 +167,7 @@ const NodesToolSchema = Type.Object({ export function createNodesTool(options?: { agentSessionKey?: string; + agentId?: string; agentChannel?: string; agentAccountId?: string; currentChannelId?: string; @@ -178,6 +179,7 @@ export function createNodesTool(options?: { const agentId = resolveSessionAgentId({ sessionKey: options?.agentSessionKey, config: options?.config, + agentId: options?.agentId, }); const imageSanitization = resolveImageSanitizationLimits(options?.config); return { diff --git a/src/agents/tools/openclaw-delegate-tool.ts b/src/agents/tools/openclaw-delegate-tool.ts index db5bae416a45..541dc3c7a40e 100644 --- a/src/agents/tools/openclaw-delegate-tool.ts +++ b/src/agents/tools/openclaw-delegate-tool.ts @@ -28,9 +28,12 @@ type OpenClawDelegateResult = { proposalId?: string; }; -function stableDelegationSessionId(sessionKey: string | undefined): string { +function stableDelegationSessionId(sessionKey: string | undefined, agentId?: string): string { return sessionKey?.trim() - ? `delegate-${createHash("sha256").update(sessionKey.trim()).digest("hex").slice(0, 32)}` + ? `delegate-${createHash("sha256") + .update(`${agentId?.trim() ?? "unknown"}\0${sessionKey.trim()}`) + .digest("hex") + .slice(0, 32)}` : `delegate-${randomUUID()}`; } @@ -43,7 +46,10 @@ function createOpenClawDelegateTool(options?: { turnSourceThreadId?: string | number; callGateway?: InProcessGatewayCaller; }): AnyAgentTool { - const defaultSessionId = stableDelegationSessionId(options?.agentSessionKey); + const defaultSessionId = stableDelegationSessionId( + options?.agentSessionKey, + options?.requesterAgentId, + ); return { name: "openclaw", label: "OpenClaw", diff --git a/src/agents/tools/scoped-session-access.ts b/src/agents/tools/scoped-session-access.ts index fd1e2b49f96e..8f50674349ce 100644 --- a/src/agents/tools/scoped-session-access.ts +++ b/src/agents/tools/scoped-session-access.ts @@ -1,12 +1,39 @@ import { resolveSessionStorePathCore } from "../../config/sessions.js"; import { loadSessionEntry as getSessionEntry } from "../../config/sessions/session-accessor.js"; +import { isPerAgentSessionStoreConfig } from "../../config/sessions/session-store-config.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js"; +import { resolveSessionAgentIds } from "../agent-scope.js"; + +/** Resolves a target key without letting requester scope override a durable fixed-store owner. */ +export function resolveSessionToolTargetAgentId(params: { + cfg: OpenClawConfig; + targetSessionKey: string; + resolvedAgentId?: string; + requesterAgentId?: string; +}): string { + const persistedOwner = resolvePersistedSessionStoreOwnerForKey( + params.cfg, + params.targetSessionKey, + ); + const canUseRequesterScope = + !params.resolvedAgentId && + !parseAgentSessionKey(params.targetSessionKey)?.agentId && + persistedOwner.kind === "none" && + isPerAgentSessionStoreConfig(params.cfg.session?.store); + return resolveSessionAgentIds({ + config: params.cfg, + sessionKey: params.targetSessionKey, + agentId: params.resolvedAgentId ?? (canUseRequesterScope ? params.requesterAgentId : undefined), + }).sessionAgentId; +} /** Linearizes a host-scoped grant against reset/delete of its expected incarnation. */ export async function runWithScopedSessionAccess(params: { cfg: OpenClawConfig; + agentId?: string; expectedSessionId?: string; signal?: AbortSignal; targetSessionKey: string; @@ -16,10 +43,14 @@ export async function runWithScopedSessionAccess(params: { if (!expectedSessionId) { return await params.run(); } - const agentId = resolveAgentIdFromSessionKey(params.targetSessionKey); + const { sessionAgentId: agentId } = resolveSessionAgentIds({ + config: params.cfg, + sessionKey: params.targetSessionKey, + agentId: params.agentId, + }); const storePath = resolveSessionStorePathCore(params.cfg.session?.store, { agentId }); const assertExpectedIncarnation = () => { - const current = getSessionEntry({ storePath, sessionKey: params.targetSessionKey }); + const current = getSessionEntry({ agentId, storePath, sessionKey: params.targetSessionKey }); if (current?.sessionId !== expectedSessionId || current.archivedAt !== undefined) { throw new Error(`Session "${params.targetSessionKey}" changed after access was granted.`); } diff --git a/src/agents/tools/screen-tool.ts b/src/agents/tools/screen-tool.ts index 8fe803679a68..5955e7538d10 100644 --- a/src/agents/tools/screen-tool.ts +++ b/src/agents/tools/screen-tool.ts @@ -34,6 +34,7 @@ const ScreenToolSchema = Type.Object( type ScreenToolOptions = { agentSessionKey?: string; + agentId?: string; callGateway?: InProcessGatewayCaller; }; @@ -45,7 +46,7 @@ function resolveSessionKey( if (!sessionKey) { throw new ToolInputError("sessionKey required"); } - return sessionKey; + return sessionKey === "current" && agentSessionKey?.trim() ? agentSessionKey.trim() : sessionKey; } function readDock(params: Record): "bottom" | "right" | undefined { @@ -111,6 +112,7 @@ export function createScreenTool(opts: ScreenToolOptions = {}): AnyAgentTool { const payload: UiCommandParams = { command: commandForAction(action, params, opts.agentSessionKey), ...(opts.agentSessionKey ? { sessionKey: opts.agentSessionKey } : {}), + ...(opts.agentId ? { agentId: opts.agentId } : {}), }; return jsonResult(await gatewayCall("ui.command", payload)); }, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 752b1b5c7d87..a49984d94e56 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -50,8 +50,11 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; -import { resolveDefaultAgentId } from "../agent-scope-config.js"; -import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agent-scope.js"; +import { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveSessionAgentIds, +} from "../agent-scope.js"; import { buildModelAliasIndex, modelKey, @@ -76,7 +79,10 @@ import { callAgentToolGatewayRequest, type AgentToolGatewayRequestCaller, } from "./in-process-gateway.js"; -import { runWithScopedSessionAccess } from "./scoped-session-access.js"; +import { + resolveSessionToolTargetAgentId, + runWithScopedSessionAccess, +} from "./scoped-session-access.js"; import { listImplicitDefaultDirectFallbackKeys, resolveImplicitCurrentSessionFallback, @@ -383,6 +389,8 @@ function resolveActiveStatusModelIdentity(params: { liveSessionKeys: Iterable; modelRaw?: string; resolvedKey: string; + resolvedAgentId: string; + requesterAgentId: string; }): ActiveStatusModelIdentity | undefined { const activeModelId = params.activeModelId?.trim(); if (!activeModelId || params.modelRaw !== undefined) { @@ -391,6 +399,9 @@ function resolveActiveStatusModelIdentity(params: { if (!params.isSemanticCurrentRequest && !params.isImplicitCurrentRequest) { return undefined; } + if (params.resolvedAgentId !== params.requesterAgentId) { + return undefined; + } const resolvedKey = params.resolvedKey.trim(); const liveSessionKeys = new Set( Array.from(params.liveSessionKeys, (value) => value?.trim()).filter((value): value is string => @@ -425,10 +436,14 @@ function withActiveStatusModelIdentity( function formatSessionTaskLine(params: { relatedSessionKey: string; callerOwnerKey: string; + callerAgentId: string; + config: OpenClawConfig; }): string | undefined { const snapshot = buildTaskStatusSnapshotForRelatedSessionKeyForOwner({ relatedSessionKey: params.relatedSessionKey, callerOwnerKey: params.callerOwnerKey, + callerAgentId: params.callerAgentId, + config: params.config, }); const task = snapshot.focus; if (!task) { @@ -546,6 +561,7 @@ async function resolveModelOverride(params: { export function createSessionStatusTool(opts?: { agentSessionKey?: string; + requesterAgentIdOverride?: string; /** * The actual live run session key. When the tool is constructed with a sandbox/policy * session key (e.g. a Telegram direct peer key), this allows `session_status({sessionKey: @@ -579,11 +595,12 @@ export function createSessionStatusTool(opts?: { sandboxed: opts?.sandboxed, }); const a2aPolicy = createAgentToAgentPolicy(cfg); - const configuredDefaultAgentId = resolveDefaultAgentId(cfg); - const requesterAgentId = resolveAgentIdFromSessionKey( - opts?.agentSessionKey ?? effectiveRequesterKey, - configuredDefaultAgentId, - ); + const requesterAgentId = resolveSessionAgentIds({ + config: cfg, + sessionKey: opts?.agentSessionKey ?? effectiveRequesterKey, + agentId: opts?.requesterAgentIdOverride, + }).sessionAgentId; + const configuredDefaultAgentId = requesterAgentId; const visibilityRequesterKey = (opts?.agentSessionKey ?? effectiveRequesterKey).trim(); const usesLegacyMainAlias = alias === mainKey; const isLegacyMainVisibilityKey = (sessionKey: string) => { @@ -624,7 +641,8 @@ export function createSessionStatusTool(opts?: { }; const visibilityGuard = await createSessionVisibilityGuard({ action: "status", - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId: requesterAgentId, + requesterAgentId, requesterSessionKey: visibilityRequesterKey, visibility: resolveEffectiveSessionToolsVisibility({ cfg, @@ -711,10 +729,18 @@ export function createSessionStatusTool(opts?: { } } - const isExplicitAgentKey = requestedKeyInput.startsWith("agent:"); - let agentId = isExplicitAgentKey - ? resolveAgentIdFromSessionKey(requestedKeyInput, configuredDefaultAgentId) - : requesterAgentId; + const deferTargetOwnerResolution = + !isSemanticCurrentRequest && shouldResolveSessionIdInput(requestedKeyInput); + let agentId = deferTargetOwnerResolution + ? requesterAgentId + : resolveSessionToolTargetAgentId({ + cfg, + targetSessionKey: requestedKeyInput, + requesterAgentId, + }); + if (!isSemanticCurrentRequest && !deferTargetOwnerResolution) { + ensureAgentAccess(agentId); + } let storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId }); let storeScopedRequesterKey = resolveStoreScopedRequesterKey({ requesterKey: effectiveRequesterKey, @@ -723,15 +749,17 @@ export function createSessionStatusTool(opts?: { }); // Resolve against the requester-scoped store first to avoid leaking default agent data. - let resolved = resolveSessionStatusEntry({ - cfg, - agentId, - keyRaw: requestedKeyRaw, - alias, - mainKey, - requesterInternalKey: storeScopedRequesterKey, - includeAliasFallback: requestedKeyInput !== "current", - }); + let resolved = deferTargetOwnerResolution + ? undefined + : resolveSessionStatusEntry({ + cfg, + agentId, + keyRaw: requestedKeyRaw, + alias, + mainKey, + requesterInternalKey: storeScopedRequesterKey, + includeAliasFallback: requestedKeyInput !== "current", + }); if ( !resolved && @@ -739,17 +767,20 @@ export function createSessionStatusTool(opts?: { ) { const resolvedSession = await resolveSessionReference({ sessionKey: requestedKeyInput, + ...(requestedKeyInput === "current" ? { agentId: requesterAgentId } : {}), + keyAgentId: requesterAgentId, alias, mainKey, requesterInternalKey: effectiveRequesterKey, restrictToSpawned: opts?.sandboxed === true, callGateway: gatewayCall, }); - if (resolvedSession.ok && resolvedSession.resolvedViaSessionId) { + if (resolvedSession.ok) { const visibleSession = await resolveVisibleSessionReference({ action: "status", resolvedSession, requesterSessionKey: effectiveRequesterKey, + requesterAgentId, restrictToSpawned: opts?.sandboxed === true, visibilitySessionKey: requestedKeyInput, callGateway: gatewayCall, @@ -760,13 +791,17 @@ export function createSessionStatusTool(opts?: { throw new Error(visibleSession.error); } // If resolution points at another agent, enforce A2A policy before switching stores. - ensureAgentAccess( - resolveAgentIdFromSessionKey(visibleSession.key, configuredDefaultAgentId), - ); - resolvedViaSessionId = true; + const visibleAgentId = resolveSessionToolTargetAgentId({ + cfg, + targetSessionKey: visibleSession.key, + resolvedAgentId: visibleSession.agentId, + requesterAgentId, + }); + ensureAgentAccess(visibleAgentId); + resolvedViaSessionId = resolvedSession.resolvedViaSessionId; requestedKeyRaw = visibleSession.key; requestedKeyInput = requestedKeyRaw.trim(); - agentId = resolveAgentIdFromSessionKey(visibleSession.key, configuredDefaultAgentId); + agentId = visibleAgentId; storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId }); storeScopedRequesterKey = resolveStoreScopedRequesterKey({ requesterKey: effectiveRequesterKey, @@ -860,7 +895,8 @@ export function createSessionStatusTool(opts?: { isSemanticCurrentRequest || resolvedViaImplicitCurrentFallback || (!resolvedViaSessionId && - (requestedKeyInput === "current" || resolved.key === requestedKeyInput)); + (requestedKeyInput === "current" || + (resolved.key === requestedKeyInput && agentId === requesterAgentId))); const visibilityTargetKey = shouldTreatVisibilityTargetAsSelf ? visibilityRequesterKey : normalizeVisibilityTargetSessionKey(resolved.key, agentId); @@ -872,6 +908,7 @@ export function createSessionStatusTool(opts?: { return await runWithScopedSessionAccess({ cfg, + agentId, expectedSessionId: access.expectedSessionId, targetSessionKey: scopedResolved.key, run: async () => { @@ -988,6 +1025,8 @@ export function createSessionStatusTool(opts?: { liveSessionKeys, modelRaw, resolvedKey: scopedResolved.key, + resolvedAgentId: agentId, + requesterAgentId, }); const runtimeModelIdentity = activeModelIdentity ? activeModelIdentity @@ -1029,6 +1068,8 @@ export function createSessionStatusTool(opts?: { const taskLine = formatSessionTaskLine({ relatedSessionKey: scopedResolved.key, callerOwnerKey: visibilityRequesterKey, + callerAgentId: requesterAgentId, + config: cfg, }); // Tool status may read persisted/configured facts, but must not start provider discovery. const thinkingCatalog = await loadPreparedModelCatalog({ @@ -1090,8 +1131,8 @@ export function createSessionStatusTool(opts?: { ); const activeRouteRunSessionKey = opts?.runSessionKey?.trim(); const isLiveRouteSession = activeRouteRunSessionKey - ? scopedResolved.key.trim() === activeRouteRunSessionKey - : liveSessionKeySet.has(scopedResolved.key.trim()); + ? agentId === requesterAgentId && scopedResolved.key.trim() === activeRouteRunSessionKey + : agentId === requesterAgentId && liveSessionKeySet.has(scopedResolved.key.trim()); const routeDetails = buildSessionStatusRouteDetails({ entry: statusSessionEntry, sessionKey: scopedResolved.key, diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts index 20fa03550a48..f2ab6fd167f7 100644 --- a/src/agents/tools/sessions-announce-target.ts +++ b/src/agents/tools/sessions-announce-target.ts @@ -18,6 +18,7 @@ export async function resolveAnnounceTarget(params: { sessionKey: string; displayKey: string; callGateway: AgentToolGatewayRequestCaller; + agentId?: string; }): Promise { const parsed = resolveAnnounceTargetFromKey(params.sessionKey); const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey); @@ -49,12 +50,19 @@ export async function resolveAnnounceTarget(params: { includeGlobal: true, includeUnknown: true, limit: 200, + agentId: params.agentId, }, }); const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const match = - sessions.find((entry) => entry?.key === params.sessionKey) ?? - sessions.find((entry) => entry?.key === params.displayKey); + sessions.find( + (entry) => + entry?.key === params.sessionKey && (!params.agentId || entry.agentId === params.agentId), + ) ?? + sessions.find( + (entry) => + entry?.key === params.displayKey && (!params.agentId || entry.agentId === params.agentId), + ); const context = match?.deliveryContext; const threadId = normalizeOptionalStringifiedId(context?.threadId ?? fallbackThreadId); diff --git a/src/agents/tools/sessions-history-tool.test.ts b/src/agents/tools/sessions-history-tool.test.ts index 0d2341db8409..a401b8b97bd1 100644 --- a/src/agents/tools/sessions-history-tool.test.ts +++ b/src/agents/tools/sessions-history-tool.test.ts @@ -629,4 +629,58 @@ describe("sessions_history redaction", () => { unregister(); } }); + + it("carries the persisted fixed-store owner for a bare history key", async () => { + const requests: CallGatewayRequest[] = []; + const tool = createSessionsHistoryTool({ + agentSessionKey: "global", + config: { + session: { store: path.join(tempDir!, "owned-shared.sqlite"), scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }, + callGateway: async >(request: CallGatewayRequest): Promise => { + requests.push(request); + return { messages: [] } as T; + }, + }); + + await tool.execute("owned-global", { sessionKey: "global" }); + + expect(requests).toContainEqual({ + method: "chat.history", + params: expect.objectContaining({ sessionKey: "global", agentId: "ops" }), + }); + }); + + it("resolves current history under the requester instead of the fixed-store owner", async () => { + const requests: CallGatewayRequest[] = []; + const tool = createSessionsHistoryTool({ + agentSessionKey: "agent:research:main", + requesterAgentIdOverride: "research", + config: { + session: { store: path.join(tempDir!, "owned-current.sqlite"), scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }, + callGateway: async >(request: CallGatewayRequest): Promise => { + requests.push(request); + return { messages: [] } as T; + }, + }); + + await tool.execute("research-current-history", { sessionKey: "current" }); + + expect(requests).toContainEqual({ + method: "chat.history", + params: expect.objectContaining({ sessionKey: "agent:research:main", agentId: "research" }), + }); + expect(requests.some((request) => request.method === "sessions.resolve")).toBe(false); + }); }); diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 28b9f1a0161b..5dfab1a2e2d7 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -6,12 +6,14 @@ import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { Type } from "typebox"; import { getRuntimeConfig } from "../../config/config.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { capArrayByJsonBytes } from "../../gateway/session-transcript-readers.js"; import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; import { redactToolPayloadText } from "../../logging/redact.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { truncateUtf16Safe } from "../../utils.js"; -import { resolveDefaultAgentId } from "../agent-scope-config.js"; +import { resolveSessionAgentId, resolveSessionAgentIds } from "../agent-scope.js"; import { optionalPositiveIntegerSchema } from "../schema/typebox.js"; import { describeSessionsHistoryTool, @@ -30,7 +32,10 @@ import { callAgentToolGatewayRequest, type AgentToolGatewayRequestCaller, } from "./in-process-gateway.js"; -import { runWithScopedSessionAccess } from "./scoped-session-access.js"; +import { + resolveSessionToolTargetAgentId, + runWithScopedSessionAccess, +} from "./scoped-session-access.js"; import { createSessionVisibilityGuard, createSessionVisibilityRowChecker, @@ -39,6 +44,7 @@ import { resolveSessionReference, resolveSandboxedSessionToolContext, resolveVisibleSessionReference, + shouldResolveSessionIdInput, } from "./sessions-helpers.js"; const SessionsHistoryToolSchema = Type.Object({ @@ -353,6 +359,7 @@ function resolveSessionsHistoryPaginationMetadata(params: { export function createSessionsHistoryTool(opts?: { agentSessionKey?: string; + requesterAgentIdOverride?: string; sandboxed?: boolean; config?: OpenClawConfig; callGateway?: GatewayCaller; @@ -388,8 +395,30 @@ export function createSessionsHistoryTool(opts?: { agentSessionKey: opts?.agentSessionKey, sandboxed: opts?.sandboxed, }); + const requesterAgentId = resolveSessionAgentIds({ + config: cfg, + sessionKey: effectiveRequesterKey, + agentId: opts?.requesterAgentIdOverride, + }).sessionAgentId; + const normalizedInputKey = sessionKeyParam.trim(); + const isCurrentSession = normalizedInputKey === "current"; + const isConfiguredMainAlias = + normalizedInputKey === "main" || + normalizedInputKey === "global" || + normalizedInputKey === mainKey || + normalizedInputKey === alias; + const inputStoreOwner = + shouldResolveSessionIdInput(sessionKeyParam) && !isConfiguredMainAlias + ? { kind: "none" as const } + : resolvePersistedSessionStoreOwnerForKey(cfg, sessionKeyParam); const resolvedSession = await resolveSessionReference({ sessionKey: sessionKeyParam, + ...(isCurrentSession + ? { agentId: requesterAgentId } + : inputStoreOwner.kind === "configured" + ? { agentId: inputStoreOwner.agentId } + : {}), + keyAgentId: requesterAgentId, alias, mainKey, requesterInternalKey: effectiveRequesterKey, @@ -406,7 +435,10 @@ export function createSessionsHistoryTool(opts?: { }); const resolutionAccess = createSessionVisibilityRowChecker({ action: "history", - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId: + resolvedSession.agentId ?? + resolveSessionAgentId({ config: cfg, sessionKey: resolvedSession.key }), + requesterAgentId, requesterSessionKey: effectiveRequesterKey, visibility, a2aPolicy, @@ -415,6 +447,7 @@ export function createSessionsHistoryTool(opts?: { action: "history", resolvedSession, requesterSessionKey: effectiveRequesterKey, + requesterAgentId, restrictToSpawned, visibilitySessionKey: sessionKeyParam, concealResolutionError: resolutionAccess.allowed ? undefined : resolutionAccess.error, @@ -429,16 +462,27 @@ export function createSessionsHistoryTool(opts?: { // From here on, use the canonical key (sessionId inputs already resolved). const resolvedKey = visibleSession.key; const displayKey = visibleSession.displayKey; + const targetAgentId = resolveSessionToolTargetAgentId({ + cfg, + targetSessionKey: resolvedKey, + resolvedAgentId: visibleSession.agentId, + requesterAgentId, + }); const visibilityGuard = await createSessionVisibilityGuard({ action: "history", - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId: requesterAgentId, + requesterAgentId, requesterSessionKey: effectiveRequesterKey, visibility, a2aPolicy, callGateway: gatewayCall, }); - const access = visibilityGuard.check(resolvedKey); + const authorizationKey = + targetAgentId !== requesterAgentId && !parseAgentSessionKey(resolvedKey) + ? `agent:${targetAgentId}:${resolvedKey}` + : resolvedKey; + const access = visibilityGuard.check(authorizationKey); if (!access.allowed) { return jsonResult({ status: access.status, @@ -448,6 +492,7 @@ export function createSessionsHistoryTool(opts?: { const result = await runWithScopedSessionAccess({ cfg, + agentId: targetAgentId, expectedSessionId: access.expectedSessionId, targetSessionKey: resolvedKey, run: async () => @@ -461,6 +506,7 @@ export function createSessionsHistoryTool(opts?: { method: "chat.history", params: { sessionKey: resolvedKey, + agentId: targetAgentId, limit, ...(offset !== undefined ? { offset } : {}), ...(messageId ? { messageId } : {}), diff --git a/src/agents/tools/sessions-list-tool.test.ts b/src/agents/tools/sessions-list-tool.test.ts index 9f875acbfb9f..b0259d05d0d4 100644 --- a/src/agents/tools/sessions-list-tool.test.ts +++ b/src/agents/tools/sessions-list-tool.test.ts @@ -22,7 +22,7 @@ const mocks = vi.hoisted(() => ({ resolveSandboxedSessionToolContext: vi.fn(() => ({ mainKey: "main", alias: "main", - requesterInternalKey: undefined, + requesterInternalKey: undefined as string | undefined, restrictToSpawned: false, })), getSessionStateVersions: vi.fn( @@ -466,6 +466,11 @@ describe("sessions-list-tool", () => { classification: "channel", sessionId: "sess-malformed-agent", }, + { + key: "Agent::discord:channel:C456", + kind: "group", + sessionId: "sess-malformed-agent-mixed-case", + }, ], }; } @@ -559,6 +564,85 @@ describe("sessions-list-tool", () => { expect(getSessionsListDetails(result).sessions?.[0]).not.toHaveProperty("archivedAt"); }); + it("keeps a bare row's gateway owner during transcript hydration", async () => { + mocks.resolveSandboxedSessionToolContext.mockReturnValue({ + mainKey: "main", + alias: "global", + requesterInternalKey: "global", + restrictToSpawned: false, + }); + mocks.gatewayCall + .mockResolvedValueOnce({ + path: "/tmp/shared-sessions.sqlite", + sessions: [ + { + key: "global", + agentId: "ops", + kind: "main", + channel: "webchat", + archived: false, + pinned: false, + }, + ], + }) + .mockResolvedValueOnce({ messages: [] }); + const config: OpenClawConfig = { + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + const result = await createSessionsListTool({ + agentSessionKey: "global", + requesterAgentIdOverride: "ops", + config, + }).execute("owned-row", { messageLimit: 1 }); + + expect(getSessionsListDetails(result).sessions?.[0]).toMatchObject({ agentId: "ops" }); + expect(mocks.gatewayCall).toHaveBeenLastCalledWith({ + method: "chat.history", + params: { sessionKey: "global", agentId: "ops", limit: 1 }, + }); + }); + + it("does not attribute an ownerless fixed-store bare row to the requester", async () => { + mocks.resolveSandboxedSessionToolContext.mockReturnValue({ + mainKey: "main", + alias: "global", + requesterInternalKey: "agent:research:main", + restrictToSpawned: false, + }); + mocks.gatewayCall.mockResolvedValue({ + path: "/tmp/ownerless-shared.sqlite", + sessions: [ + { + key: "global", + kind: "main", + channel: "webchat", + archived: false, + pinned: false, + }, + ], + }); + + const result = await createSessionsListTool({ + agentSessionKey: "agent:research:main", + requesterAgentIdOverride: "research", + config: { + session: { store: "/tmp/ownerless-shared.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }, + }).execute("ownerless-row", {}); + + expect(getSessionsListDetails(result).sessions).toEqual([]); + }); + it.each([ [{ limit: 1.5 }, "limit must be a positive integer"], [{ activeMinutes: 0 }, "activeMinutes must be a positive integer"], diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index a0d777fd8040..4202e1db16b9 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -15,9 +15,9 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { readSessionTitleFieldsFromTranscriptAsync } from "../../gateway/session-transcript-title-reader.js"; import { deriveSessionTitle } from "../../gateway/session-utils.js"; -import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { classifySessionKeyShape, isIncognitoSessionKey } from "../../routing/session-key.js"; import { getSessionStateVersions } from "../../sessions/session-state-events.js"; -import { resolveDefaultAgentId } from "../agent-scope-config.js"; +import { resolveSessionAgentIds } from "../agent-scope.js"; import { optionalNonNegativeIntegerSchema, optionalPositiveIntegerSchema, @@ -40,6 +40,7 @@ import { callAgentToolGatewayRequest, type AgentToolGatewayRequestCaller, } from "./in-process-gateway.js"; +import { resolveSessionToolTargetAgentId } from "./scoped-session-access.js"; import { createAgentToAgentPolicy, createSessionVisibilityRowChecker, @@ -143,6 +144,7 @@ function readSessionRunStatus(value: unknown): SessionRunStatus | undefined { /** Creates the sessions-list tool with gateway-backed listing and local transcript enrichment. */ export function createSessionsListTool(opts?: { agentSessionKey?: string; + requesterAgentIdOverride?: string; sandboxed?: boolean; config?: OpenClawConfig; callGateway?: GatewayCaller; @@ -164,6 +166,11 @@ export function createSessionsListTool(opts?: { sandboxed: opts?.sandboxed, }); const effectiveRequesterKey = requesterInternalKey ?? alias; + const requesterAgentId = resolveSessionAgentIds({ + config: cfg, + sessionKey: effectiveRequesterKey, + agentId: opts?.requesterAgentIdOverride, + }).sessionAgentId; const visibility = resolveEffectiveSessionToolsVisibility({ cfg, sandboxed: opts?.sandboxed === true, @@ -190,7 +197,7 @@ export function createSessionsListTool(opts?: { const gatewayCall = opts?.callGateway ?? callAgentToolGatewayRequest; const a2aPolicy = createAgentToAgentPolicy(cfg); const hydrateTranscriptFieldsAfterFiltering = includeDerivedTitles || includeLastMessage; - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = requesterAgentId; const visibilityGuard = createSessionVisibilityRowChecker({ action: "list", defaultAgentId, @@ -200,6 +207,7 @@ export function createSessionsListTool(opts?: { }); const sessions: GatewaySessionListRow[] = []; const seenKeys = new Set(); + const resolvedAgentIdsByKey = new Map(); const outputLimit = limit ?? 100; let offset = 0; let storePath: string | undefined; @@ -240,9 +248,27 @@ export function createSessionsListTool(opts?: { if (isIncognitoSessionKey(key)) { continue; } + if (classifySessionKeyShape(key) === "malformed_agent") { + // A malformed scoped key is not an unscoped fixed-store row. Treating + // it as bare would let the compatibility owner adopt invalid input. + continue; + } + let resolvedAgentId: string; + try { + resolvedAgentId = resolveSessionToolTargetAgentId({ + cfg, + targetSessionKey: key, + resolvedAgentId: + typeof entry.agentId === "string" && entry.agentId ? entry.agentId : undefined, + requesterAgentId, + }); + } catch { + // An unowned fixed-store row is unavailable rather than adopted by the requester. + continue; + } const access = visibilityGuard.check({ key, - agentId: typeof entry.agentId === "string" ? entry.agentId : undefined, + agentId: resolvedAgentId, ownerSessionKey: typeof (entry as { ownerSessionKey?: unknown }).ownerSessionKey === "string" ? (entry as { ownerSessionKey?: string }).ownerSessionKey @@ -258,6 +284,7 @@ export function createSessionsListTool(opts?: { (key !== "global" || alias === "global") && (!allowedKinds || allowedKinds.has(kind)) ) { + resolvedAgentIdsByKey.set(key, resolvedAgentId); sessions.push(entry); if (sessions.length === outputLimit) { break; @@ -287,14 +314,9 @@ export function createSessionsListTool(opts?: { const stateVersions = getSessionStateVersions( sessions.flatMap((entry) => { const key = entry.key; - let stateAgentId = - typeof entry.agentId === "string" && entry.agentId ? entry.agentId : undefined; + const stateAgentId = resolvedAgentIdsByKey.get(key); if (!stateAgentId) { - try { - stateAgentId = resolveAgentIdFromSessionKey(key, defaultAgentId); - } catch { - return []; - } + return []; } return [{ sessionKey: key, agentId: stateAgentId }]; }), @@ -312,6 +334,10 @@ export function createSessionsListTool(opts?: { for (const entry of sessions) { const key = entry.key; + const resolvedAgentId = resolvedAgentIdsByKey.get(key); + if (!resolvedAgentId) { + continue; + } const kind = classifySessionListKind(entry); const displayKey = resolveDisplaySessionKey({ key, @@ -336,7 +362,6 @@ export function createSessionsListTool(opts?: { const sessionId = readStringValue(entry.sessionId); const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile; const sessionFile = readStringValue(sessionFileRaw); - const resolvedAgentId = resolveAgentIdFromSessionKey(key, defaultAgentId); // Version lookup keys on the store-owning agent (gateway row agentId), not the // key-derived agent: bare "global" keys parse to the default agent id. const stateVersionAgentId = @@ -474,7 +499,11 @@ export function createSessionsListTool(opts?: { async (target) => { const history = await gatewayCall<{ messages: Array }>({ method: "chat.history", - params: { sessionKey: target.resolvedKey, limit: messageLimit }, + params: { + sessionKey: target.resolvedKey, + agentId: target.row.agentId, + limit: messageLimit, + }, }); const rawMessages = Array.isArray(history?.messages) ? history.messages : []; const filtered = stripToolMessages(rawMessages); diff --git a/src/agents/tools/sessions-resolution.strict.test.ts b/src/agents/tools/sessions-resolution.strict.test.ts new file mode 100644 index 000000000000..60cad92d76d7 --- /dev/null +++ b/src/agents/tools/sessions-resolution.strict.test.ts @@ -0,0 +1,111 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (options: unknown) => callGatewayMock(options), +})); + +let resolveSessionReference: typeof import("./sessions-resolution.js").resolveSessionReference; +let resolveVisibleSessionReference: typeof import("./sessions-resolution.js").resolveVisibleSessionReference; + +beforeAll(async () => { + ({ resolveSessionReference, resolveVisibleSessionReference } = + await import("./sessions-resolution.js")); +}); + +beforeEach(() => { + callGatewayMock.mockReset(); +}); + +describe("strict explicit session resolution", () => { + it("resolves current to the requester before any ownership lookup", async () => { + const result = await resolveSessionReference({ + sessionKey: "current", + keyAgentId: "ops", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:research:subagent:child", + restrictToSpawned: false, + }); + + expect(result).toEqual({ + ok: true, + agentId: "research", + key: "agent:research:subagent:child", + displayKey: "agent:research:subagent:child", + resolvedViaSessionId: false, + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("still rejects an unknown non-alias explicit key", async () => { + callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing")); + const resolvedSession = await resolveSessionReference({ + sessionKey: "agent:main:missing", + keyAgentId: "main", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("expected literal reference resolution"); + } + + await expect( + resolveVisibleSessionReference({ + action: "history", + resolvedSession, + requesterSessionKey: "agent:main:main", + requesterAgentId: "main", + restrictToSpawned: false, + visibilitySessionKey: "agent:main:missing", + }), + ).resolves.toMatchObject({ + ok: false, + status: "error", + error: "No session found: agent:main:missing", + }); + expect(callGatewayMock).toHaveBeenCalledWith({ + method: "sessions.resolve", + params: { + key: "agent:main:missing", + agentId: "main", + spawnedBy: undefined, + }, + }); + }); + + it("carries an allowed missing fact only for deliberate main bootstrap", async () => { + callGatewayMock.mockResolvedValueOnce({}); + const resolvedSession = await resolveSessionReference({ + sessionKey: "agent:main:main", + keyAgentId: "main", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:dashboard:requester", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("expected literal reference resolution"); + } + + await expect( + resolveVisibleSessionReference({ + action: "send", + resolvedSession, + requesterSessionKey: "agent:main:dashboard:requester", + requesterAgentId: "main", + restrictToSpawned: false, + visibilitySessionKey: "agent:main:main", + allowMissingKey: true, + }), + ).resolves.toEqual({ + ok: true, + agentId: "main", + key: "agent:main:main", + displayKey: "agent:main:main", + missing: true, + }); + }); +}); diff --git a/src/agents/tools/sessions-resolution.test.ts b/src/agents/tools/sessions-resolution.test.ts index a2e345d0d081..c150d63a8272 100644 --- a/src/agents/tools/sessions-resolution.test.ts +++ b/src/agents/tools/sessions-resolution.test.ts @@ -2,7 +2,6 @@ // verification, and requester-spawned access checks. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { GatewayClientRequestError } from "../../gateway/client.js"; import { looksLikeSessionId } from "../../sessions/session-id.js"; const callGatewayMock = vi.fn(); vi.mock("../../gateway/call.js", () => ({ @@ -160,7 +159,7 @@ describe("resolved session visibility checks", () => { await expect( resolveVisibleSessionReference({ - action: "status", + action: "history", resolvedSession: { ok: true, key: sessionKey, @@ -168,6 +167,7 @@ describe("resolved session visibility checks", () => { resolvedViaSessionId: false, }, requesterSessionKey: sessionKey, + requesterAgentId: "main", restrictToSpawned: false, visibilitySessionKey: sessionKey, }), @@ -189,7 +189,7 @@ describe("resolved session visibility checks", () => { targetSessionKey: "agent:main:worker", restrictToSpawned: false, resolvedViaSessionId: false, - expectsGateway: false, + expectsGateway: true, }, { requesterSessionKey: "agent:main:main", @@ -203,14 +203,14 @@ describe("resolved session visibility checks", () => { targetSessionKey: "agent:main:main", restrictToSpawned: true, resolvedViaSessionId: false, - expectsGateway: false, + expectsGateway: true, }, ]; for (const testCase of cases) { callGatewayMock.mockResolvedValueOnce({ key: testCase.targetSessionKey }); const result = resolveVisibleSessionReference({ - action: "status", + action: "history", resolvedSession: { ok: true, key: testCase.targetSessionKey, @@ -218,12 +218,14 @@ describe("resolved session visibility checks", () => { resolvedViaSessionId: testCase.resolvedViaSessionId, }, requesterSessionKey: testCase.requesterSessionKey, + requesterAgentId: "main", restrictToSpawned: testCase.restrictToSpawned, visibilitySessionKey: testCase.targetSessionKey, }); await expect(result).resolves.toEqual({ ok: true, + agentId: "main", key: testCase.targetSessionKey, displayKey: testCase.targetSessionKey, }); @@ -253,7 +255,7 @@ describe("resolved session visibility checks", () => { await expect( resolveVisibleSessionReference({ - action: "status", + action: "history", resolvedSession: { ok: true, key: "agent:main:subagent:worker-999", @@ -261,17 +263,19 @@ describe("resolved session visibility checks", () => { resolvedViaSessionId: false, }, requesterSessionKey: "agent:main:main", + requesterAgentId: "main", restrictToSpawned: true, visibilitySessionKey: "agent:main:subagent:worker-999", }), ).resolves.toEqual({ ok: true, + agentId: "main", key: "agent:main:subagent:worker-999", displayKey: "agent:main:subagent:worker-999", }); }); - it("falls back to spawned-session listing when exact resolution is unsupported", async () => { + it("propagates strict explicit-key resolution failures without a list fallback", async () => { callGatewayMock.mockImplementation(async (request: { method?: string }) => { if (request.method === "sessions.resolve") { throw new Error("unsupported sessions.resolve shape"); @@ -281,7 +285,7 @@ describe("resolved session visibility checks", () => { await expect( resolveVisibleSessionReference({ - action: "status", + action: "history", resolvedSession: { ok: true, key: "agent:main:subagent:worker", @@ -289,82 +293,45 @@ describe("resolved session visibility checks", () => { resolvedViaSessionId: false, }, requesterSessionKey: "agent:main:main", + requesterAgentId: "main", restrictToSpawned: true, visibilitySessionKey: "agent:main:subagent:worker", }), - ).resolves.toMatchObject({ ok: true, key: "agent:main:subagent:worker" }); + ).resolves.toMatchObject({ ok: false, status: "forbidden" }); expect(callGatewayMock.mock.calls.map(([request]) => request.method)).toEqual([ "sessions.resolve", - "sessions.list", ]); }); }); describe("resolveSessionReference", () => { - it("prefers a literal current session key before alias fallback", async () => { - callGatewayMock.mockResolvedValueOnce({ key: "current" }); - - const result = await resolveSessionReference({ - sessionKey: "current", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:subagent:child", - restrictToSpawned: false, - }); - expectResolvedSessionReference(result, { - key: "current", - displayKey: "current", - resolvedViaSessionId: false, - }); - expect(callGatewayMock).toHaveBeenCalledWith({ - method: "sessions.resolve", - params: { - key: "current", - spawnedBy: undefined, - allowMissing: true, + it("uses a scoped key's encoded owner before visibility policy", async () => { + callGatewayMock.mockImplementation( + async (request: { method?: string; params?: { key?: string; agentId?: string } }) => { + expect(request.method).toBe("sessions.resolve"); + expect(request.params).toMatchObject({ key: "Agent:ops:main", agentId: "ops" }); + return { key: "agent:ops:main", agentId: "ops" }; }, - }); - }); - - it("prefers a literal current sessionId before alias fallback", async () => { - callGatewayMock.mockResolvedValueOnce({}); - callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:main" }); + ); const result = await resolveSessionReference({ - sessionKey: "current", + sessionKey: "Agent:ops:main", + keyAgentId: "main", + agentId: "main", alias: "main", mainKey: "main", - requesterInternalKey: "agent:main:subagent:child", + requesterInternalKey: "agent:main:main", restrictToSpawned: false, }); + expectResolvedSessionReference(result, { key: "agent:ops:main", displayKey: "agent:ops:main", - resolvedViaSessionId: true, - }); - expect(callGatewayMock).toHaveBeenNthCalledWith(1, { - method: "sessions.resolve", - params: { - key: "current", - spawnedBy: undefined, - allowMissing: true, - }, - }); - expect(callGatewayMock).toHaveBeenNthCalledWith(2, { - method: "sessions.resolve", - params: { - sessionId: "current", - spawnedBy: undefined, - includeGlobal: true, - includeUnknown: true, - allowMissing: true, - }, + resolvedViaSessionId: false, }); }); - it("does not compatibility-retry unrelated gateway failures", async () => { - callGatewayMock.mockRejectedValueOnce(new Error("gateway timeout")).mockResolvedValueOnce({}); - + it("resolves current directly to the requester without probing another owner", async () => { const result = await resolveSessionReference({ sessionKey: "current", alias: "main", @@ -377,33 +344,7 @@ describe("resolveSessionReference", () => { displayKey: "agent:main:subagent:child", resolvedViaSessionId: false, }); - expect(callGatewayMock).toHaveBeenCalledTimes(2); - }); - - it("skips literal current key lookup when spawned visibility is restricted", async () => { - const result = await resolveSessionReference({ - sessionKey: "current", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:subagent:child", - restrictToSpawned: true, - }); - expectResolvedSessionReference(result, { - key: "agent:main:subagent:child", - displayKey: "agent:main:subagent:child", - resolvedViaSessionId: false, - }); - expect(callGatewayMock).toHaveBeenNthCalledWith(1, { - method: "sessions.resolve", - params: { - sessionId: "current", - spawnedBy: "agent:main:subagent:child", - includeGlobal: false, - includeUnknown: false, - allowMissing: true, - }, - }); - expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(callGatewayMock).not.toHaveBeenCalled(); }); it("treats the TUI client label as the requester session", async () => { @@ -421,233 +362,4 @@ describe("resolveSessionReference", () => { }); expect(callGatewayMock).not.toHaveBeenCalled(); }); - - it("preserves the main alias without probing configured-main bootstrap", async () => { - const result = await resolveSessionReference({ - sessionKey: "main", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:dashboard:requester", - restrictToSpawned: false, - }); - - expectResolvedSessionReference(result, { - key: "main", - displayKey: "main", - resolvedViaSessionId: false, - }); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("defers explicit-key lookup to action-aware visibility resolution", async () => { - const result = await resolveSessionReference({ - sessionKey: "agent:main:worker", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:main", - restrictToSpawned: false, - }); - - expect(result).toEqual({ - ok: true, - key: "agent:main:worker", - displayKey: "agent:main:worker", - resolvedViaSessionId: false, - }); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("rejects an unknown explicit session key for history", async () => { - callGatewayMock.mockRejectedValueOnce( - new GatewayClientRequestError({ - code: "INVALID_REQUEST", - message: "No session found: agent:main:missing", - }), - ); - - const resolvedSession = await resolveSessionReference({ - sessionKey: "agent:main:missing", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:main", - restrictToSpawned: false, - }); - if (!resolvedSession.ok) { - throw new Error("Expected session reference"); - } - const result = await resolveVisibleSessionReference({ - action: "history", - resolvedSession, - requesterSessionKey: "agent:main:main", - restrictToSpawned: false, - visibilitySessionKey: "agent:main:missing", - }); - - expect(result).toEqual({ - ok: false, - status: "error", - error: "No session found: agent:main:missing", - displayKey: "agent:main:missing", - }); - expect(callGatewayMock).toHaveBeenCalledWith({ - method: "sessions.resolve", - params: { - key: "agent:main:missing", - spawnedBy: undefined, - }, - }); - }); - - it("canonicalizes an existing explicit session key", async () => { - callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:main" }); - - const resolvedSession = await resolveSessionReference({ - sessionKey: "agent:OPS:main", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:main", - restrictToSpawned: false, - }); - if (!resolvedSession.ok) { - throw new Error("Expected session reference"); - } - const result = await resolveVisibleSessionReference({ - action: "send", - resolvedSession, - requesterSessionKey: "agent:main:main", - restrictToSpawned: false, - visibilitySessionKey: "agent:OPS:main", - }); - - expect(result).toEqual({ - ok: true, - key: "agent:ops:main", - displayKey: "agent:ops:main", - }); - }); - - it("rejects an explicit key that canonicalizes to an incognito session", async () => { - callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:dashboard:incognito-private" }); - - const resolvedSession = await resolveSessionReference({ - sessionKey: "agent:OPS:dashboard:private", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:main", - restrictToSpawned: false, - }); - if (!resolvedSession.ok) { - throw new Error("Expected session reference"); - } - const result = await resolveVisibleSessionReference({ - action: "history", - resolvedSession, - requesterSessionKey: "agent:main:main", - restrictToSpawned: false, - visibilitySessionKey: "agent:OPS:dashboard:private", - }); - - expect(result).toEqual({ - ok: false, - status: "forbidden", - error: "Session not visible from session tools: agent:OPS:dashboard:private", - displayKey: "agent:ops:dashboard:incognito-private", - }); - }); - - it("conceals a missing explicit key from sandboxed callers", async () => { - callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing")); - - const resolvedSession = await resolveSessionReference({ - sessionKey: "agent:main:missing", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:subagent:child", - restrictToSpawned: true, - }); - if (!resolvedSession.ok) { - throw new Error("Expected session reference"); - } - const result = await resolveVisibleSessionReference({ - action: "history", - resolvedSession, - requesterSessionKey: "agent:main:subagent:child", - restrictToSpawned: true, - visibilitySessionKey: "agent:main:missing", - }); - - expect(result).toEqual({ - ok: false, - status: "forbidden", - error: "Session not visible from this sandboxed agent session: agent:main:missing", - displayKey: "agent:main:missing", - }); - }); - - it("propagates explicit-key gateway failures", async () => { - callGatewayMock.mockRejectedValueOnce(new Error("gateway unavailable")); - - const resolvedSession = await resolveSessionReference({ - sessionKey: "agent:main:worker", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:main", - restrictToSpawned: false, - }); - if (!resolvedSession.ok) { - throw new Error("Expected session reference"); - } - const result = await resolveVisibleSessionReference({ - action: "send", - resolvedSession, - requesterSessionKey: "agent:main:main", - restrictToSpawned: false, - visibilitySessionKey: "agent:main:worker", - }); - - expect(result).toEqual({ - ok: false, - status: "error", - error: "gateway unavailable", - displayKey: "agent:main:worker", - }); - }); - - it("reports an allowed missing explicit key for deliberate bootstrap", async () => { - callGatewayMock.mockResolvedValueOnce({}); - - const resolvedSession = await resolveSessionReference({ - sessionKey: "agent:main:main", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:dashboard:requester", - restrictToSpawned: false, - }); - if (!resolvedSession.ok) { - throw new Error("Expected session reference"); - } - const result = await resolveVisibleSessionReference({ - action: "send", - resolvedSession, - requesterSessionKey: "agent:main:dashboard:requester", - restrictToSpawned: false, - visibilitySessionKey: "agent:main:main", - allowMissingKey: true, - }); - - expect(result).toEqual({ - ok: true, - key: "agent:main:main", - displayKey: "agent:main:main", - missing: true, - }); - expect(callGatewayMock).toHaveBeenCalledWith({ - method: "sessions.resolve", - params: { - key: "agent:main:main", - spawnedBy: undefined, - allowMissing: true, - }, - }); - }); }); diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts index 13c668c781f7..6d4bef8e77c8 100644 --- a/src/agents/tools/sessions-resolution.ts +++ b/src/agents/tools/sessions-resolution.ts @@ -18,6 +18,7 @@ import { isAcpSessionKey, isIncognitoSessionKey, normalizeMainKey, + parseAgentSessionKey, } from "../../routing/session-key.js"; import { looksLikeSessionId } from "../../sessions/session-id.js"; import { @@ -88,10 +89,15 @@ export function resolveCurrentSessionClientAlias(params: { async function isRequesterSpawnedSessionVisible(params: { requesterSessionKey: string; + requesterAgentId: string; targetSessionKey: string; + targetAgentId?: string; callGateway?: GatewayCaller; }): Promise { - if (params.requesterSessionKey === params.targetSessionKey) { + if ( + params.requesterSessionKey === params.targetSessionKey && + params.targetAgentId === params.requesterAgentId + ) { return true; } const gatewayCall = params.callGateway ?? callAgentToolGatewayRequest; @@ -100,6 +106,7 @@ async function isRequesterSpawnedSessionVisible(params: { method: "sessions.resolve", params: { key: params.targetSessionKey, + agentId: params.targetAgentId, spawnedBy: params.requesterSessionKey, }, }); @@ -109,12 +116,14 @@ async function isRequesterSpawnedSessionVisible(params: { } catch { // Older Gateways can reject exact spawned-session resolution. } + const keys = await listSpawnedSessionKeys({ + requesterSessionKey: params.requesterSessionKey, + callGateway: gatewayCall, + }); return ( - await listSpawnedSessionKeys({ - requesterSessionKey: params.requesterSessionKey, - callGateway: gatewayCall, - }) - ).has(params.targetSessionKey); + (!params.targetAgentId || params.targetAgentId === params.requesterAgentId) && + keys.has(params.targetSessionKey) + ); } function looksLikeSessionKey(value: string): boolean { @@ -152,6 +161,7 @@ export function shouldResolveSessionIdInput(value: string): boolean { type SessionReferenceResolution = | { ok: true; + agentId?: string; key: string; displayKey: string; resolvedViaSessionId: boolean; @@ -161,6 +171,7 @@ type SessionReferenceResolution = type VisibleSessionReferenceResolution = | { ok: true; + agentId?: string; key: string; displayKey: string; missing?: true; @@ -173,6 +184,7 @@ type VisibleSessionReferenceResolution = }; function buildResolvedSessionReference(params: { + agentId?: string; key: string; alias: string; mainKey: string; @@ -180,6 +192,7 @@ function buildResolvedSessionReference(params: { }): Extract { return { ok: true, + ...(params.agentId ? { agentId: params.agentId } : {}), key: params.key, displayKey: resolveDisplaySessionKey({ key: params.key, @@ -210,26 +223,36 @@ function buildFailedSessionReference( }; } -async function requestResolvedSessionKey( +async function requestResolvedSession( params: Record & { allowMissing?: boolean }, callGateway: GatewayCaller, -): Promise { - const result = await callGateway<{ key?: unknown }>({ +): Promise<{ agentId?: string; key: string } | undefined> { + const toResolvedSession = (result: { agentId?: unknown; key?: unknown } | undefined) => { + const key = normalizeOptionalString(result?.key); + if (!key) { + return undefined; + } + const agentId = normalizeOptionalString(result?.agentId); + return { key, ...(agentId ? { agentId } : {}) }; + }; + const result = await callGateway<{ agentId?: unknown; key?: unknown }>({ method: "sessions.resolve", params, }); - return normalizeOptionalString(result?.key); + return toResolvedSession(result); } function buildSessionResolveQuery(params: { input: string; kind: "key" | "sessionId"; + agentId?: string; requesterInternalKey?: string; restrictToSpawned: boolean; allowMissing?: boolean; }): Record & { allowMissing?: boolean } { return { [params.kind]: params.input, + agentId: params.agentId, spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, ...(params.kind === "sessionId" ? { @@ -243,6 +266,9 @@ function buildSessionResolveQuery(params: { export async function resolveSessionReference(params: { sessionKey: string; + /** Owner already selected for literal key lookup; session-id lookup remains cross-agent. */ + keyAgentId?: string; + agentId?: string; alias: string; mainKey: string; requesterInternalKey?: string; @@ -250,26 +276,33 @@ export async function resolveSessionReference(params: { callGateway?: GatewayCaller; }): Promise { const gatewayCall = params.callGateway ?? callAgentToolGatewayRequest; - const buildReference = (key: string, resolvedViaSessionId: boolean) => + const buildReference = ( + resolved: { agentId?: string; key: string }, + resolvedViaSessionId: boolean, + ) => buildResolvedSessionReference({ - key, + ...resolved, alias: params.alias, mainKey: params.mainKey, resolvedViaSessionId, }); const tryResolve = async (input: string, kind: "key" | "sessionId", allowMissing = false) => { try { - const key = await requestResolvedSessionKey( + const resolved = await requestResolvedSession( buildSessionResolveQuery({ input, kind, + agentId: + kind === "key" + ? (parseAgentSessionKey(input)?.agentId ?? params.keyAgentId ?? params.agentId) + : params.agentId, requesterInternalKey: params.requesterInternalKey, restrictToSpawned: params.restrictToSpawned, allowMissing, }), gatewayCall, ); - return key ? buildReference(key, kind === "sessionId") : null; + return resolved ? buildReference(resolved, kind === "sessionId") : null; } catch { return null; } @@ -279,14 +312,6 @@ export async function resolveSessionReference(params: { key: params.sessionKey, requesterInternalKey: params.requesterInternalKey, }) ?? params.sessionKey.trim(); - if (rawInput === "current") { - const resolvedCurrent = - (params.restrictToSpawned ? null : await tryResolve(rawInput, "key", true)) ?? - (await tryResolve(rawInput, "sessionId", true)); - if (resolvedCurrent) { - return resolvedCurrent; - } - } const raw = rawInput === "current" && params.requesterInternalKey ? params.requesterInternalKey : rawInput; if (shouldResolveSessionIdInput(raw)) { @@ -295,19 +320,20 @@ export async function resolveSessionReference(params: { return resolvedByKey; } try { - const key = await requestResolvedSessionKey( + const resolved = await requestResolvedSession( buildSessionResolveQuery({ input: raw, kind: "sessionId", + agentId: params.agentId, requesterInternalKey: params.requesterInternalKey, restrictToSpawned: params.restrictToSpawned, }), gatewayCall, ); - if (!key) { + if (!resolved) { throw new Error(`Session not found: ${raw} (use the full sessionKey from sessions_list)`); } - return buildReference(key, true); + return buildReference(resolved, true); } catch (error) { return buildFailedSessionReference(error, raw, params.restrictToSpawned); } @@ -319,13 +345,24 @@ export async function resolveSessionReference(params: { mainKey: params.mainKey, requesterInternalKey: params.requesterInternalKey, }); - return buildReference(resolvedKey, false); + const semanticAliasAgentId = + params.agentId ?? + (rawInput === "current" + ? (parseAgentSessionKey(resolvedKey)?.agentId ?? params.keyAgentId) + : rawInput === "main" || rawInput === params.mainKey + ? params.keyAgentId + : undefined); + return buildReference( + { key: resolvedKey, ...(semanticAliasAgentId ? { agentId: semanticAliasAgentId } : {}) }, + false, + ); } export async function resolveVisibleSessionReference(params: { action: "history" | "send" | "status" | "list"; resolvedSession: Extract; requesterSessionKey: string; + requesterAgentId: string; restrictToSpawned: boolean; visibilitySessionKey: string; allowMissingKey?: boolean; @@ -333,8 +370,11 @@ export async function resolveVisibleSessionReference(params: { callGateway?: GatewayCaller; }): Promise { let resolvedKey = params.resolvedSession.key; + let resolvedAgentId = + params.resolvedSession.agentId ?? parseAgentSessionKey(resolvedKey)?.agentId; let displayKey = params.resolvedSession.displayKey; let missing = false; + let verifiedSpawnedVisibility = false; // Cross-session tools persist their results into the caller transcript; an // incognito target must remain unreachable even from an incognito requester. if (isIncognitoSessionKey(resolvedKey)) { @@ -355,19 +395,22 @@ export async function resolveVisibleSessionReference(params: { !shouldResolveSessionIdInput(input); if (isExplicitKey && (params.action === "history" || params.action === "send")) { try { - const key = await requestResolvedSessionKey( + const resolved = await requestResolvedSession( buildSessionResolveQuery({ input: resolvedKey, kind: "key", + agentId: resolvedAgentId, requesterInternalKey: params.requesterSessionKey, restrictToSpawned: params.restrictToSpawned, allowMissing: params.allowMissingKey, }), params.callGateway ?? callAgentToolGatewayRequest, ); - if (key) { - resolvedKey = key; - displayKey = key; + if (resolved) { + resolvedKey = resolved.key; + resolvedAgentId = resolved.agentId ?? parseAgentSessionKey(resolved.key)?.agentId; + displayKey = resolved.key; + verifiedSpawnedVisibility = params.restrictToSpawned; } else if (params.allowMissingKey) { missing = true; } @@ -399,7 +442,7 @@ export async function resolveVisibleSessionReference(params: { const shouldVerifySpawnedVisibility = params.restrictToSpawned && !params.resolvedSession.resolvedViaSessionId && - params.requesterSessionKey !== resolvedKey; + (params.requesterSessionKey !== resolvedKey || resolvedAgentId !== params.requesterAgentId); const scopedAccess = params.action === "list" ? undefined @@ -410,10 +453,13 @@ export async function resolveVisibleSessionReference(params: { }); const visible = Boolean(scopedAccess) || + verifiedSpawnedVisibility || !shouldVerifySpawnedVisibility || (await isRequesterSpawnedSessionVisible({ requesterSessionKey: params.requesterSessionKey, + requesterAgentId: params.requesterAgentId, targetSessionKey: resolvedKey, + targetAgentId: resolvedAgentId, callGateway: params.callGateway, })); if (!visible) { @@ -424,5 +470,11 @@ export async function resolveVisibleSessionReference(params: { displayKey, }; } - return { ok: true, key: resolvedKey, displayKey, ...(missing ? { missing: true } : {}) }; + return { + ok: true, + ...(resolvedAgentId ? { agentId: resolvedAgentId } : {}), + key: resolvedKey, + displayKey, + ...(missing ? { missing: true } : {}), + }; } diff --git a/src/agents/tools/sessions-search-tool.test.ts b/src/agents/tools/sessions-search-tool.test.ts index ee8236b1c380..cbb756c44b91 100644 --- a/src/agents/tools/sessions-search-tool.test.ts +++ b/src/agents/tools/sessions-search-tool.test.ts @@ -86,6 +86,33 @@ function createTool(params: { } describe("sessions_search tool", () => { + it("rejects a literal global target owned by another fixed-store agent", async () => { + const requests: CallGatewayRequest[] = []; + const tool = createTool({ + agentId: "research", + agentSessionKey: "agent:research:main", + config: { + session: { store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {}, ops: {} }, + }, + tools: { sessions: { visibility: "all" } }, + }, + results: [hit({ sessionKey: "global", agentId: "ops" })], + requests, + }); + + const result = await tool.execute("foreign-global", { + query: "text", + sessionKey: "global", + }); + + expect(result.details).toMatchObject({ status: "forbidden" }); + expect(requests.some((request) => request.method === "sessions.search")).toBe(false); + }); + it("declares exact success and error result contracts", async () => { const tool = createTool({ results: [hit()] }); const success = await tool.execute("success-contract", { query: "text" }); diff --git a/src/agents/tools/sessions-search-tool.ts b/src/agents/tools/sessions-search-tool.ts index 502ea8395dfa..665459ae8538 100644 --- a/src/agents/tools/sessions-search-tool.ts +++ b/src/agents/tools/sessions-search-tool.ts @@ -10,7 +10,6 @@ import { parseAgentSessionKey, } from "../../routing/session-key.js"; import { truncateUtf16Safe } from "../../utils.js"; -import { resolveDefaultAgentId } from "../agent-scope-config.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalPositiveIntegerSchema } from "../schema/typebox.js"; import { @@ -28,6 +27,7 @@ import { callAgentToolGatewayRequest, type AgentToolGatewayRequestCaller, } from "./in-process-gateway.js"; +import { resolveSessionToolTargetAgentId } from "./scoped-session-access.js"; import { createAgentToAgentPolicy, createSessionVisibilityGuard, @@ -364,11 +364,33 @@ export function createSessionsSearchTool(opts?: { agentSessionKey: opts?.agentSessionKey, sandboxed: opts?.sandboxed, }); + const requesterAgentId = resolveSessionAgentId({ + sessionKey: effectiveRequesterKey, + config: cfg, + agentId: opts?.agentId, + }); let sessionKey: string | undefined; + let sessionAgentId: string | undefined; if (requestedSessionKey) { + const normalizedRequestedKey = requestedSessionKey.trim(); + const semanticTargetAgentId = + normalizedRequestedKey === "current" + ? requesterAgentId + : normalizedRequestedKey === "main" || + normalizedRequestedKey === "global" || + normalizedRequestedKey === mainKey || + normalizedRequestedKey === alias || + Boolean(parseAgentSessionKey(normalizedRequestedKey)) + ? resolveSessionToolTargetAgentId({ + cfg, + targetSessionKey: normalizedRequestedKey, + requesterAgentId, + }) + : undefined; const resolved = await resolveSessionReference({ sessionKey: requestedSessionKey, + keyAgentId: semanticTargetAgentId ?? requesterAgentId, alias, mainKey, requesterInternalKey: effectiveRequesterKey, @@ -382,6 +404,7 @@ export function createSessionsSearchTool(opts?: { action: "list", resolvedSession: resolved, requesterSessionKey: effectiveRequesterKey, + requesterAgentId, restrictToSpawned, visibilitySessionKey: requestedSessionKey, callGateway: gatewayCall, @@ -390,6 +413,12 @@ export function createSessionsSearchTool(opts?: { return jsonResult({ status: visible.status, error: visible.error }); } sessionKey = visible.key; + sessionAgentId = resolveSessionToolTargetAgentId({ + cfg, + targetSessionKey: visible.key, + resolvedAgentId: visible.agentId ?? semanticTargetAgentId, + requesterAgentId, + }); } const visibility = resolveEffectiveSessionToolsVisibility({ @@ -397,9 +426,7 @@ export function createSessionsSearchTool(opts?: { sandboxed: opts?.sandboxed === true, }); const a2aPolicy = createAgentToAgentPolicy(cfg); - const defaultAgentId = resolveDefaultAgentId(cfg); - const requesterAgentId = - opts?.agentId ?? resolveSessionAgentId({ sessionKey: effectiveRequesterKey, config: cfg }); + const defaultAgentId = requesterAgentId; const rowGuard = createSessionVisibilityRowChecker({ action: "history", defaultAgentId, @@ -419,12 +446,9 @@ export function createSessionsSearchTool(opts?: { }); if (sessionKey) { const parsedSessionKey = parseAgentSessionKey(sessionKey); - let access: ReturnType; - if (parsedSessionKey) { - access = directGuard.check(sessionKey); - } else { - access = rowGuard.check({ key: sessionKey, agentId: requesterAgentId }); - } + const access = parsedSessionKey + ? directGuard.check(sessionKey) + : rowGuard.check({ key: sessionKey, agentId: sessionAgentId }); if (!access.allowed) { return jsonResult({ status: access.status, error: access.error }); } @@ -435,7 +459,9 @@ export function createSessionsSearchTool(opts?: { { key: sessionKey, access: "direct" as const, - ...(!parseAgentSessionKey(sessionKey) ? { agentId: requesterAgentId } : {}), + ...(!parseAgentSessionKey(sessionKey) && sessionAgentId + ? { agentId: sessionAgentId } + : {}), }, ] : await listVisibleSearchSessions({ diff --git a/src/agents/tools/sessions-send-tool.a2a.ts b/src/agents/tools/sessions-send-tool.a2a.ts index 158cddc92153..2b04aef3f2f4 100644 --- a/src/agents/tools/sessions-send-tool.a2a.ts +++ b/src/agents/tools/sessions-send-tool.a2a.ts @@ -7,6 +7,7 @@ import crypto from "node:crypto"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { splitMediaFromOutput } from "../../media/parse.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { resolveNestedAgentLaneForSession } from "../lanes.js"; import { @@ -34,6 +35,21 @@ import { const log = createSubsystemLogger("agents/sessions-send"); +function sameOwnedSession(params: { + leftKey: string | undefined; + leftAgentId: string | undefined; + rightKey: string; + rightAgentId: string | undefined; +}): boolean { + if (!params.leftKey || params.leftKey !== params.rightKey) { + return false; + } + const leftAgentId = params.leftAgentId ?? parseAgentSessionKey(params.leftKey)?.agentId; + const rightAgentId = params.rightAgentId ?? parseAgentSessionKey(params.rightKey)?.agentId; + return Boolean( + leftAgentId && rightAgentId && normalizeAgentId(leftAgentId) === normalizeAgentId(rightAgentId), + ); +} function isDeliveryFailureWait(wait: AgentWaitResult): boolean { return ( (wait.status === "error" && !isRecoverableAgentWaitError(wait.error)) || @@ -86,11 +102,13 @@ async function deliverAnnounceReply(params: { export async function runSessionsSendA2AFlow(params: { callGateway?: AgentToolGatewayRequestCaller; targetSessionKey: string; + targetAgentId?: string; displayKey: string; message: string; announceTimeoutMs: number; maxPingPongTurns: number; requesterSessionKey?: string; + requesterAgentId?: string; requesterChannel?: string; baseline?: AssistantReplySnapshot; roundOneReply?: string; @@ -111,6 +129,7 @@ export async function runSessionsSendA2AFlow(params: { if (wait.status === "ok") { const latestSnapshot = await readLatestAssistantReplySnapshot({ sessionKey: params.targetSessionKey, + agentId: params.targetAgentId, stopAtTranscriptArtifact: true, callGateway: gatewayCall, }); @@ -127,6 +146,7 @@ export async function runSessionsSendA2AFlow(params: { const error = typeof wait.error === "string" && wait.error.trim() ? `: ${wait.error.trim()}` : ""; await runAgentStep({ + agentId: params.requesterAgentId, sessionKey: params.requesterSessionKey, message: `sessions_send delivery to ${params.displayKey} failed${error}. ` + @@ -154,14 +174,19 @@ export async function runSessionsSendA2AFlow(params: { sessionKey: params.targetSessionKey, displayKey: params.displayKey, callGateway: gatewayCall, + agentId: params.targetAgentId, }); const targetChannel = announceTarget?.channel ?? "unknown"; // A same-session send is a human-facing source-channel reply, not a true // agent-to-agent announcement. Asking the same session to decide whether to // announce can re-run the same prompt and duplicate source-reply side effects. - const sameSessionSourceReply = - params.requesterSessionKey && params.requesterSessionKey === params.targetSessionKey; + const sameSessionSourceReply = sameOwnedSession({ + leftKey: params.requesterSessionKey, + leftAgentId: params.requesterAgentId, + rightKey: params.targetSessionKey, + rightAgentId: params.targetAgentId, + }); const canDirectDeliverSameSessionReply = announceTarget && (!params.requesterChannel || params.requesterChannel === announceTarget.channel); @@ -182,17 +207,15 @@ export async function runSessionsSendA2AFlow(params: { return; } - if ( - params.maxPingPongTurns > 0 && - params.requesterSessionKey && - params.requesterSessionKey !== params.targetSessionKey - ) { + if (params.maxPingPongTurns > 0 && params.requesterSessionKey && !sameSessionSourceReply) { let currentSessionKey = params.requesterSessionKey; let nextSessionKey = params.targetSessionKey; + let currentAgentId = params.requesterAgentId; + let nextAgentId = params.targetAgentId; + let currentRole: "requester" | "target" = "requester"; + let nextRole: "requester" | "target" = "target"; let incomingMessage = latestReply; for (let turn = 1; turn <= params.maxPingPongTurns; turn += 1) { - const currentRole = - currentSessionKey === params.requesterSessionKey ? "requester" : "target"; const replyPrompt = buildAgentToAgentReplyContext({ requesterSessionKey: params.requesterSessionKey, requesterChannel: params.requesterChannel, @@ -203,14 +226,14 @@ export async function runSessionsSendA2AFlow(params: { maxTurns: params.maxPingPongTurns, }); const replyText = await runAgentStep({ + agentId: currentAgentId, sessionKey: currentSessionKey, message: incomingMessage, extraSystemPrompt: replyPrompt, timeoutMs: params.announceTimeoutMs, lane: resolveNestedAgentLaneForSession(currentSessionKey), sourceSessionKey: nextSessionKey, - sourceChannel: - nextSessionKey === params.requesterSessionKey ? params.requesterChannel : targetChannel, + sourceChannel: nextRole === "requester" ? params.requesterChannel : targetChannel, sourceTool: "sessions_send", callGateway: gatewayCall, }); @@ -222,6 +245,12 @@ export async function runSessionsSendA2AFlow(params: { const swap = currentSessionKey; currentSessionKey = nextSessionKey; nextSessionKey = swap; + const agentSwap = currentAgentId; + currentAgentId = nextAgentId; + nextAgentId = agentSwap; + const roleSwap: "requester" | "target" = currentRole; + currentRole = nextRole; + nextRole = roleSwap; } } @@ -235,6 +264,7 @@ export async function runSessionsSendA2AFlow(params: { latestReply, }); const announceReply = await runAgentStep({ + agentId: params.targetAgentId, sessionKey: params.targetSessionKey, message: "Agent-to-agent announce step.", extraSystemPrompt: announcePrompt, diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 3496f90104aa..5d2b81afc3ac 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -9,6 +9,8 @@ import { finiteSecondsToTimerSafeMilliseconds } from "@openclaw/normalization-co import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { Type } from "typebox"; import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; import { parseSessionThreadInfo } from "../../config/sessions/thread-info.js"; import { runWithoutOwnedSessionTranscriptWrites } from "../../config/sessions/transcript-write-context.js"; import type { SessionEntry } from "../../config/sessions/types.js"; @@ -21,10 +23,11 @@ import { normalizeRouteBindingChannelId } from "../../routing/binding-scope.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { buildAgentMainSessionKey, + classifySessionKeyShape, + isUnscopedSessionKeySentinel, isSubagentSessionKey, normalizeAccountId, normalizeAgentId, - resolveAgentIdFromSessionKey, toAgentStoreSessionKey, } from "../../routing/session-key.js"; import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js"; @@ -38,8 +41,7 @@ import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js"; import { registerSessionStateWatch } from "../../sessions/session-state-events.js"; import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; -import { resolveDefaultAgentId } from "../agent-scope-config.js"; -import { listAgentIds } from "../agent-scope.js"; +import { listAgentIds, resolveSessionAgentId } from "../agent-scope.js"; import { type EmbeddedAgentQueueMessageOptions, type EmbeddedAgentQueueMessageOutcome, @@ -189,34 +191,41 @@ function resolveConfiguredAgentMainSessionKey(params: { function isConfiguredAgentMainSessionKey(params: { cfg: OpenClawConfig; + agentId?: string; sessionKey: string; mainKey: string; }): boolean { - const agentId = resolveAgentIdFromSessionKey( - params.sessionKey, - resolveDefaultAgentId(params.cfg), - ); - return ( - params.sessionKey === - resolveConfiguredAgentMainSessionKey({ - cfg: params.cfg, - agentId, - mainKey: params.mainKey, - }) - ); + if (isUnscopedSessionKeySentinel(params.sessionKey)) { + return false; + } + if (params.sessionKey === params.mainKey) { + return true; + } + const agentId = params.agentId ?? parseAgentSessionKey(params.sessionKey)?.agentId; + return agentId + ? params.sessionKey === + resolveConfiguredAgentMainSessionKey({ + cfg: params.cfg, + agentId, + mainKey: params.mainKey, + }) + : false; } async function createConfiguredAgentMainSession(params: { cfg: OpenClawConfig; callGateway: GatewayCaller; + agentId?: string; sessionKey: string; requesterSessionKey?: string; useTrustedInProcessCreation: boolean; }): Promise<{ ok: true } | { ok: false; error: string }> { + const targetAgentId = + params.agentId ?? resolveSessionAgentId({ config: params.cfg, sessionKey: params.sessionKey }); try { const createParams = { key: params.sessionKey, - agentId: resolveAgentIdFromSessionKey(params.sessionKey, resolveDefaultAgentId(params.cfg)), + agentId: targetAgentId, }; if ( params.useTrustedInProcessCreation && @@ -433,6 +442,7 @@ async function startAgentRun(params: { } export function createSessionsSendTool(opts?: { + agentId?: string; agentSessionKey?: string; agentChannel?: string; sandboxed?: boolean; @@ -459,6 +469,20 @@ export function createSessionsSendTool(opts?: { const timeoutSeconds = readNonNegativeIntegerParam(params, "timeoutSeconds") ?? 30; const { cfg, mainKey, alias, effectiveRequesterKey, restrictToSpawned } = resolveSessionToolContext(opts); + let requesterAgentId: string; + try { + requesterAgentId = resolveSessionAgentId({ + config: cfg, + sessionKey: effectiveRequesterKey, + agentId: opts?.agentId, + }); + } catch (err) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: formatErrorMessage(err), + }); + } const a2aPolicy = createAgentToAgentPolicy(cfg); const sessionVisibility = resolveEffectiveSessionToolsVisibility({ @@ -471,6 +495,7 @@ export function createSessionsSendTool(opts?: { const labelAgentIdParam = normalizeOptionalString(readToolStringParam(params, "agentId")); let sessionKey = sessionKeyParam; + let resolvedTargetAgentId: string | undefined; if (!sessionKey && !labelParam && labelAgentIdParam) { const agentMainKey = resolveConfiguredAgentMainSessionKey({ cfg, @@ -487,10 +512,6 @@ export function createSessionsSendTool(opts?: { sessionKey = agentMainKey; } if (!sessionKey && labelParam) { - const requesterAgentId = resolveAgentIdFromSessionKey( - effectiveRequesterKey, - resolveDefaultAgentId(cfg), - ); const requestedAgentId = labelAgentIdParam ? normalizeAgentId(labelAgentIdParam) : undefined; @@ -528,12 +549,13 @@ export function createSessionsSendTool(opts?: { }; let resolvedKey; try { - const resolved = await gatewayCall<{ key: string }>({ + const resolved = await gatewayCall<{ agentId?: string; key: string }>({ method: "sessions.resolve", params: resolveParams, timeoutMs: 10_000, }); resolvedKey = normalizeOptionalString(resolved?.key) ?? ""; + resolvedTargetAgentId = normalizeOptionalString(resolved?.agentId); } catch (err) { const msg = formatErrorMessage(err); if (restrictToSpawned) { @@ -581,6 +603,7 @@ export function createSessionsSendTool(opts?: { }); const resolvedSession = await resolveSessionReference({ sessionKey, + keyAgentId: requesterAgentId, alias, mainKey, requesterInternalKey: effectiveRequesterKey, @@ -596,7 +619,10 @@ export function createSessionsSendTool(opts?: { } const resolutionAccess = createSessionVisibilityRowChecker({ action: "send", - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId: + resolvedSession.agentId ?? + resolveSessionAgentId({ config: cfg, sessionKey: resolvedSession.key }), + requesterAgentId, requesterSessionKey: effectiveRequesterKey, visibility: sessionVisibility, a2aPolicy, @@ -605,6 +631,7 @@ export function createSessionsSendTool(opts?: { action: "send", resolvedSession, requesterSessionKey: effectiveRequesterKey, + requesterAgentId, restrictToSpawned, visibilitySessionKey: sessionKey, allowMissingKey, @@ -623,6 +650,68 @@ export function createSessionsSendTool(opts?: { // Normalize sessionKey/sessionId input into a canonical session key. const resolvedKey = visibleSession.key; const displayKey = visibleSession.displayKey; + const resolvedKeyAgentId = parseAgentSessionKey(resolvedKey)?.agentId; + const isLiteralLegacyKeyInput = + !labelParam && sessionKeyParam !== undefined && !resolvedSession.resolvedViaSessionId; + const isLiteralUnscopedTarget = + isLiteralLegacyKeyInput && classifySessionKeyShape(resolvedKey) === "legacy_or_alias"; + const persistedTargetOwner = isLiteralUnscopedTarget + ? resolvePersistedSessionStoreOwnerForKey(cfg, resolvedKey) + : { kind: "none" as const }; + const compatibilityTargetAgentId = + isLiteralUnscopedTarget && persistedTargetOwner.kind === "none" + ? tryResolveLegacyCompatibilityAgentId(cfg) + : undefined; + const isLiteralUnscopedMainTarget = + isLiteralUnscopedTarget && + (isUnscopedSessionKeySentinel(sessionKeyParam.trim()) || + sessionKeyParam.trim().toLowerCase() === mainKey); + if (persistedTargetOwner.kind === "retired") { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: "Session ownership could not be verified because its fixed-store owner retired.", + sessionKey: unresolvedDisplayKey, + }); + } + const resolvedTargetOwner = + visibleSession.agentId ?? + resolvedTargetAgentId ?? + (labelParam && labelAgentIdParam ? normalizeAgentId(labelAgentIdParam) : undefined); + if ( + persistedTargetOwner.kind === "configured" && + resolvedTargetOwner && + normalizeAgentId(resolvedTargetOwner) !== persistedTargetOwner.agentId + ) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: `Session belongs to agent "${persistedTargetOwner.agentId}", not "${normalizeAgentId(resolvedTargetOwner)}".`, + sessionKey: unresolvedDisplayKey, + }); + } + const targetAgentId = + (persistedTargetOwner.kind === "configured" ? persistedTargetOwner.agentId : undefined) ?? + resolvedTargetOwner ?? + resolvedKeyAgentId ?? + (isLiteralUnscopedMainTarget ? requesterAgentId : undefined) ?? + compatibilityTargetAgentId; + const mayUseRequesterForLiteralSentinel = + isLiteralUnscopedMainTarget && + (!targetAgentId || normalizeAgentId(targetAgentId) === requesterAgentId); + if ( + !targetAgentId && + !resolvedKeyAgentId && + (!isUnscopedSessionKeySentinel(resolvedKey) || resolvedSession.resolvedViaSessionId) + ) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: + "Session ownership could not be verified. Upgrade the gateway or use an agent-prefixed session key.", + sessionKey: unresolvedDisplayKey, + }); + } const rawRequesterSessionKey = opts?.agentSessionKey ? effectiveRequesterKey : undefined; const parsedRequesterSessionKey = parseAgentSessionKey(rawRequesterSessionKey); const requesterRouteBindings = cfg.bindings?.filter( @@ -727,7 +816,11 @@ export function createSessionsSendTool(opts?: { let runId: string = idempotencyKey; // Fire-and-forget self-send remains a channel-delivery path. A synchronous // self-send would wait behind its own active session lane until timeout. - if (timeoutSeconds !== 0 && requesterSessionKey === resolvedKey) { + if ( + timeoutSeconds !== 0 && + requesterSessionKey === resolvedKey && + targetAgentId === requesterAgentId + ) { return jsonResult({ runId, status: "error", @@ -746,13 +839,18 @@ export function createSessionsSendTool(opts?: { } const visibilityGuard = await createSessionVisibilityGuard({ action: "send", - defaultAgentId: resolveDefaultAgentId(cfg), + requesterAgentId, requesterSessionKey: effectiveRequesterKey, visibility: sessionVisibility, a2aPolicy, callGateway: gatewayCall, }); - const access = visibilityGuard.check(resolvedKey); + const authorizationTargetKey = mayUseRequesterForLiteralSentinel + ? effectiveRequesterKey + : targetAgentId && !parseAgentSessionKey(resolvedKey) + ? `agent:${targetAgentId}:${resolvedKey}` + : resolvedKey; + const access = visibilityGuard.check(authorizationTargetKey); if (!access.allowed) { return jsonResult({ runId: crypto.randomUUID(), @@ -765,6 +863,7 @@ export function createSessionsSendTool(opts?: { return await runWithScopedSessionAccess({ cfg, + agentId: targetAgentId, expectedSessionId, ...(opts?.signal ? { signal: opts.signal } : {}), targetSessionKey: resolvedKey, @@ -773,6 +872,7 @@ export function createSessionsSendTool(opts?: { const createdSession = await createConfiguredAgentMainSession({ cfg, callGateway: gatewayCall, + ...(targetAgentId ? { agentId: targetAgentId } : {}), sessionKey: resolvedKey, requesterSessionKey, useTrustedInProcessCreation: opts?.callGateway === undefined, @@ -788,7 +888,8 @@ export function createSessionsSendTool(opts?: { } const requesterChannel = opts?.agentChannel; - const sameSessionA2A = requesterSessionKey === resolvedKey; + const sameSessionA2A = + requesterSessionKey === resolvedKey && targetAgentId === requesterAgentId; const isIsolatedCronRequester = isCronRunSessionKey(requesterSessionKey); // Watch registration follows successful dispatch: a failed send must not leave // a hidden watch, and cron run-scoped sends can fall back to the durable parent @@ -803,6 +904,7 @@ export function createSessionsSendTool(opts?: { ? registerSessionStateWatch({ watcherSessionKey: replyRequesterSessionKey, targetSessionKey, + targetAgentId, }) : false; return watchRequested ? { watched } : {}; @@ -823,12 +925,14 @@ export function createSessionsSendTool(opts?: { timeoutSeconds !== 0 ? await readLatestAssistantReplySnapshot({ sessionKey: resolvedKey, + agentId: targetAgentId, limit: SESSIONS_SEND_REPLY_HISTORY_LIMIT, callGateway: gatewayCall, }) : sameSessionA2A || isIsolatedCronRequester ? await readLatestAssistantReplySnapshot({ sessionKey: resolvedKey, + agentId: targetAgentId, limit: SESSIONS_SEND_REPLY_HISTORY_LIMIT, callGateway: gatewayCall, }).catch(() => undefined) @@ -839,6 +943,7 @@ export function createSessionsSendTool(opts?: { fallbackA2ASessionKey && fallbackA2ASessionKey !== resolvedKey ? await readLatestAssistantReplySnapshot({ sessionKey: fallbackA2ASessionKey, + agentId: targetAgentId, limit: SESSIONS_SEND_REPLY_HISTORY_LIMIT, callGateway: gatewayCall, }).catch(() => undefined) @@ -857,6 +962,7 @@ export function createSessionsSendTool(opts?: { }; const sendParams = { message: annotateInterSessionPromptText(message, inputProvenance), + agentId: targetAgentId, sessionKey: resolvedKey, idempotencyKey, deliver: false, @@ -884,8 +990,12 @@ export function createSessionsSendTool(opts?: { // unrelated sender that can see the same target (e.g. under // `tools.sessions.visibility=all`) must still go through the normal A2A // path so it actually receives a follow-up delivery. - const targetSessionEntry = loadSessionEntryByKey(resolvedKey); - const targetAcpMeta = readAcpSessionMeta({ sessionKey: resolvedKey }); + const targetSessionEntry = loadSessionEntryByKey(resolvedKey, targetAgentId); + const targetAcpMeta = readAcpSessionMeta({ + sessionKey: resolvedKey, + agentId: targetAgentId, + cfg, + }); const targetSessionEntryWithAcp = targetAcpMeta && targetSessionEntry ? { ...targetSessionEntry, acp: targetAcpMeta } @@ -935,6 +1045,7 @@ export function createSessionsSendTool(opts?: { runSessionsSendA2AFlow({ callGateway: gatewayCall, targetSessionKey: flowTargetSessionKey, + targetAgentId, displayKey: flowDisplayKey, message, announceTimeoutMs, @@ -942,6 +1053,7 @@ export function createSessionsSendTool(opts?: { // requester turns, but the target-side announce still runs. maxPingPongTurns: isIsolatedCronRequester ? 0 : maxPingPongTurns, requesterSessionKey: replyRequesterSessionKey, + requesterAgentId, requesterChannel, baseline: flowBaseline, roundOneReply, @@ -1003,6 +1115,7 @@ export function createSessionsSendTool(opts?: { const result = await waitForAgentRunAndReadUpdatedAssistantReply({ runId, sessionKey: resolvedKey, + agentId: targetAgentId, timeoutMs, limit: SESSIONS_SEND_REPLY_HISTORY_LIMIT, baseline: baselineReply, diff --git a/src/agents/tools/sessions-spawn-visible.ts b/src/agents/tools/sessions-spawn-visible.ts index bb6280b4b442..0f3f8a5fba3d 100644 --- a/src/agents/tools/sessions-spawn-visible.ts +++ b/src/agents/tools/sessions-spawn-visible.ts @@ -6,14 +6,10 @@ import { import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isPathInside } from "../../infra/path-guards.js"; -import { - isValidAgentId, - normalizeAgentId, - parseAgentSessionKey, -} from "../../routing/session-key.js"; +import { isValidAgentId, normalizeAgentId } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; -import { listAgentIds, resolveAgentConfig } from "../agent-scope.js"; +import { listAgentIds, resolveAgentConfig, resolveSessionAgentId } from "../agent-scope.js"; import { reserveChildAdmissionSlot } from "../child-admission.js"; import { resolveSubagentSpawnModelSelection } from "../model-selection.js"; import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js"; @@ -169,7 +165,10 @@ export async function maybeSpawnVisibleSession(params: { completionOwnerKey: params.options?.completionOwnerKey, }); const requesterKey = ownership.controllerSessionKey; - const callerDepth = getSubagentDepthFromSessionStore(requesterKey, { cfg }); + const callerDepth = getSubagentDepthFromSessionStore(requesterKey, { + cfg, + agentId: params.options?.requesterAgentIdOverride, + }); const maxDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; if (callerDepth >= maxDepth) { @@ -186,9 +185,11 @@ export async function maybeSpawnVisibleSession(params: { error: `Invalid agentId "${params.requestedAgentId}". Use agents_list.`, }; } - const requesterAgentId = normalizeAgentId( - params.options?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterKey)?.agentId, - ); + const requesterAgentId = resolveSessionAgentId({ + config: cfg, + sessionKey: requesterKey, + agentId: params.options?.requesterAgentIdOverride, + }); const requireAgentId = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.requireAgentId ?? cfg.agents?.defaults?.subagents?.requireAgentId ?? diff --git a/src/agents/tools/sessions-tool.test-helpers.ts b/src/agents/tools/sessions-tool.test-helpers.ts new file mode 100644 index 000000000000..cb54af088eff --- /dev/null +++ b/src/agents/tools/sessions-tool.test-helpers.ts @@ -0,0 +1,63 @@ +import { expect } from "vitest"; + +const overlongUnicode = (unit: string, maxLength: number) => `${unit.repeat(maxLength - 1)}🦞tail`; + +export const adversarialResolved = { + modelProvider: overlongUnicode("界", 48), + model: overlongUnicode("模", 96), + agentRuntime: { + id: overlongUnicode("運", 48), + fallback: "openclaw" as const, + source: "session-key" as const, + }, + thinkingLevel: overlongUnicode("考", 16), + thinkingLevels: Array.from({ length: 12 }, (_, index) => ({ + id: `${index}:${overlongUnicode("識", 12)}`, + label: `${index}:${overlongUnicode("思", 16)}`, + })), +}; + +const escapedControlText = "\0".repeat(10_000); +export const escapeHeavyResolved = { + modelProvider: escapedControlText, + model: escapedControlText, + agentRuntime: { + id: escapedControlText, + fallback: "none" as const, + source: "provider" as const, + }, + thinkingLevel: escapedControlText, + thinkingLevels: Array.from({ length: 12 }, (_, index) => ({ + id: `${index}:${escapedControlText}`, + label: `${index}:${escapedControlText}`, + })), +}; + +export const expectedResolvedOmission = { reason: "response_budget_exceeded" } as const; + +export function expectExactResolvedAcknowledgement( + result: { content: Array<{ type: string; text?: string }>; details: unknown }, + expectedResolved: unknown, +) { + expect((result.details as { resolved?: unknown }).resolved).toEqual(expectedResolved); + const text = result.content[0]?.text ?? ""; + expect(JSON.parse(text)).toEqual(result.details); + expect(text).not.toContain('"entry"'); + expect(text).not.toContain('"path"'); + expect(text).not.toContain("skillsSnapshot"); + expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(3_840); +} + +export function expectOmittedResolvedAcknowledgement(result: { + content: Array<{ type: string; text?: string }>; + details: unknown; +}) { + expect(result.details).toMatchObject({ resolvedOmitted: expectedResolvedOmission }); + expect((result.details as { resolved?: unknown }).resolved).toBeUndefined(); + const text = result.content[0]?.text ?? ""; + expect(JSON.parse(text)).toEqual(result.details); + expect(text).not.toContain('"entry"'); + expect(text).not.toContain('"path"'); + expect(text).not.toContain("skillsSnapshot"); + expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(3_840); +} diff --git a/src/agents/tools/sessions-tool.test.ts b/src/agents/tools/sessions-tool.test.ts index 6adc9de67dda..2460fabc362a 100644 --- a/src/agents/tools/sessions-tool.test.ts +++ b/src/agents/tools/sessions-tool.test.ts @@ -12,80 +12,122 @@ import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../../security/dangerous-tools.js import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js"; import { withTestDir } from "../../test-helpers/temp-dir.js"; import { createAgentPatchedSessionModelRunGuard } from "../session-model-auto-revert.js"; +import type { AgentToolGatewayRequestCaller } from "./in-process-gateway.js"; import { createSessionsTool } from "./sessions-tool.js"; +import { + adversarialResolved, + escapeHeavyResolved, + expectExactResolvedAcknowledgement, + expectOmittedResolvedAcknowledgement, + expectedResolvedOmission, +} from "./sessions-tool.test-helpers.js"; -const overlongUnicode = (unit: string, maxLength: number) => `${unit.repeat(maxLength - 1)}🦞tail`; - -const adversarialResolved = { - modelProvider: overlongUnicode("界", 48), - model: overlongUnicode("模", 96), - agentRuntime: { - id: overlongUnicode("運", 48), - fallback: "openclaw" as const, - source: "session-key" as const, - }, - thinkingLevel: overlongUnicode("考", 16), - thinkingLevels: Array.from({ length: 12 }, (_, index) => ({ - id: `${index}:${overlongUnicode("識", 12)}`, - label: `${index}:${overlongUnicode("思", 16)}`, - })), -}; - -const escapedControlText = "\0".repeat(10_000); -const escapeHeavyResolved = { - modelProvider: escapedControlText, - model: escapedControlText, - agentRuntime: { - id: escapedControlText, - fallback: "none" as const, - source: "provider" as const, - }, - thinkingLevel: escapedControlText, - thinkingLevels: Array.from({ length: 12 }, (_, index) => ({ - id: `${index}:${escapedControlText}`, - label: `${index}:${escapedControlText}`, - })), -}; - -const expectedResolvedOmission = { - reason: "response_budget_exceeded", -} as const; - -function expectExactResolvedAcknowledgement( - result: { - content: Array<{ type: string; text?: string }>; - details: unknown; - }, - expectedResolved: unknown, -) { - expect((result.details as { resolved?: unknown }).resolved).toEqual(expectedResolved); - const text = result.content[0]?.text ?? ""; - expect(JSON.parse(text)).toEqual(result.details); - expect(text).not.toContain('"entry"'); - expect(text).not.toContain('"path"'); - expect(text).not.toContain("skillsSnapshot"); - expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(3_840); -} - -function expectOmittedResolvedAcknowledgement(result: { - content: Array<{ type: string; text?: string }>; - details: unknown; -}) { - expect(result.details).toMatchObject({ resolvedOmitted: expectedResolvedOmission }); - expect((result.details as { resolved?: unknown }).resolved).toBeUndefined(); - const text = result.content[0]?.text ?? ""; - expect(JSON.parse(text)).toEqual(result.details); - expect(text).not.toContain('"entry"'); - expect(text).not.toContain('"path"'); - expect(text).not.toContain("skillsSnapshot"); - expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(3_840); -} +type AgentToolGatewayRequest = Parameters[0]; describe("sessions tool", () => { it("uses the core owner gate", () => { expect(GATEWAY_OWNER_ONLY_CORE_TOOLS).toContain("sessions"); }); + it("carries the persisted fixed-store owner for a bare patch key", async () => { + const callGateway = vi.fn().mockResolvedValue({}); + const tool = createSessionsTool({ + agentSessionKey: "global", + config: { + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }, + callGateway, + }); + + await tool.execute("owned-patch", { action: "patch", label: "Ops" }); + + expect(callGateway).toHaveBeenCalledWith({ + method: "sessions.patch", + params: { key: "global", agentId: "ops", label: "Ops" }, + }); + }); + + it("resolves current under the requester instead of the persisted bare-row owner", async () => { + const requests: AgentToolGatewayRequest[] = []; + const callGateway: AgentToolGatewayRequestCaller = async ( + request: AgentToolGatewayRequest, + ) => { + requests.push(request); + return { ok: true } as T; + }; + const tool = createSessionsTool({ + agentSessionKey: "agent:research:main", + requesterAgentIdOverride: "research", + config: { + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }, + callGateway, + }); + + await tool.execute("research-current", { + action: "patch", + sessionKey: "current", + label: "Research", + }); + + expect(requests).toContainEqual({ + method: "sessions.patch", + params: { key: "agent:research:main", label: "Research" }, + }); + expect(requests.some((request) => request.method === "sessions.resolve")).toBe(false); + }); + + it.each(["patch", "reset", "delete"] as const)( + "does not treat another agent's bare global row as self for %s", + async (action) => { + const requests: AgentToolGatewayRequest[] = []; + const callGateway: AgentToolGatewayRequestCaller = async ( + request: AgentToolGatewayRequest, + ) => { + requests.push(request); + if (request.method === "sessions.resolve") { + return { agentId: "ops", key: "global" } as T; + } + throw new Error(`unexpected gateway mutation: ${request.method}`); + }; + const tool = createSessionsTool({ + agentSessionKey: "global", + requesterAgentIdOverride: "research", + config: { + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }, + callGateway, + }); + + await expect( + tool.execute(`foreign-global-${action}`, { + action, + sessionKey: "2fb701ef-6425-4c48-9b6f-5a170aa2477e", + ...(action === "patch" ? { label: "Ops" } : {}), + }), + ).rejects.toThrow("Session status visibility is restricted"); + expect(requests).toContainEqual(expect.objectContaining({ method: "sessions.resolve" })); + expect( + requests.some((request) => + ["sessions.patch", "sessions.reset", "sessions.delete"].includes(request.method), + ), + ).toBe(false); + }, + ); + it("cannot patch an incognito session through the cross-session tool", async () => { const sessionKey = "agent:main:dashboard:incognito-private"; const callGateway = vi.fn(); diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index 12d718384292..87ebecf8ccad 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -14,12 +14,12 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { boundedJsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; import { isTransientNetworkError } from "../../infra/unhandled-rejections.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { isIncognitoSessionKey, parseAgentSessionKey } from "../../routing/session-key.js"; import { getCurrentSessionWorkAdmissionRelease, getSessionWorkAdmissionRelease, } from "../../sessions/session-lifecycle-admission.js"; -import { resolveDefaultAgentId } from "../agent-scope-config.js"; +import { resolveSessionAgentIds } from "../agent-scope.js"; import { stringEnum } from "../schema/typebox.js"; import type { AnyAgentTool } from "./common.js"; import { @@ -33,13 +33,14 @@ import { hasInProcessGatewayToolContext, type AgentToolGatewayRequestCaller, } from "./in-process-gateway.js"; +import { resolveSessionToolTargetAgentId } from "./scoped-session-access.js"; import { createAgentToAgentPolicy, createSessionVisibilityGuard, resolveEffectiveSessionToolsVisibility, } from "./sessions-access.js"; import { resolveSessionToolContext } from "./sessions-helpers.js"; -import { resolveSessionReference } from "./sessions-resolution.js"; +import { resolveSessionReference, shouldResolveSessionIdInput } from "./sessions-resolution.js"; const ACTIONS = [ "patch", @@ -138,6 +139,7 @@ const SessionsToolSchema = Type.Object( type SessionsToolOptions = { agentSessionKey?: string; agentSessionId?: string; + requesterAgentIdOverride?: string; sandboxed?: boolean; config?: OpenClawConfig; callGateway?: AgentToolGatewayRequestCaller; @@ -202,11 +204,39 @@ async function resolvePatchTarget( opts: SessionsToolOptions, sessionKey: string | undefined, callGateway: AgentToolGatewayRequestCaller, -): Promise<{ cfg: OpenClawConfig; key: string; requesterKey: string }> { +): Promise<{ + agentId: string; + cfg: OpenClawConfig; + isRequesterSession: boolean; + key: string; +}> { const context = resolveSessionToolContext(opts); const rawKey = sessionKey ?? context.effectiveRequesterKey; + const requesterAgentId = resolveSessionAgentIds({ + config: context.cfg, + sessionKey: context.effectiveRequesterKey, + agentId: opts.requesterAgentIdOverride, + }).sessionAgentId; + const normalizedRawKey = rawKey.trim(); + const isCurrentSession = normalizedRawKey === "current"; + const isConfiguredMainAlias = + normalizedRawKey === "main" || + normalizedRawKey === "global" || + normalizedRawKey === context.mainKey || + normalizedRawKey === context.alias; + const inputAgentId = isCurrentSession + ? requesterAgentId + : shouldResolveSessionIdInput(rawKey) && !isConfiguredMainAlias + ? undefined + : resolveSessionToolTargetAgentId({ + cfg: context.cfg, + targetSessionKey: rawKey, + requesterAgentId, + }); const resolved = await resolveSessionReference({ sessionKey: rawKey, + agentId: inputAgentId, + keyAgentId: requesterAgentId, alias: context.alias, mainKey: context.mainKey, requesterInternalKey: context.effectiveRequesterKey, @@ -219,17 +249,22 @@ async function resolvePatchTarget( if (isIncognitoSessionKey(resolved.key)) { throw new ToolAuthorizationError(`Session not visible from session tools: ${rawKey}`); } - if (resolved.key !== context.effectiveRequesterKey) { + const agentId = resolveSessionToolTargetAgentId({ + cfg: context.cfg, + targetSessionKey: resolved.key, + resolvedAgentId: resolved.agentId, + requesterAgentId, + }); + const isRequesterSession = + resolved.key === context.effectiveRequesterKey && agentId === requesterAgentId; + if (!isRequesterSession) { // Session visibility is the configured read/write scope for session tools; // the action only selects error copy. Owner gating remains separate. const guard = await createSessionVisibilityGuard({ action: "status", - defaultAgentId: resolveDefaultAgentId(context.cfg), + defaultAgentId: requesterAgentId, requesterSessionKey: context.effectiveRequesterKey, - requesterAgentId: resolveAgentIdFromSessionKey( - context.effectiveRequesterKey, - resolveDefaultAgentId(context.cfg), - ), + requesterAgentId, visibility: resolveEffectiveSessionToolsVisibility({ cfg: context.cfg, sandboxed: opts.sandboxed === true, @@ -237,15 +272,20 @@ async function resolvePatchTarget( a2aPolicy: createAgentToAgentPolicy(context.cfg), callGateway, }); - const access = guard.check(resolved.key); + const authorizationKey = + agentId !== requesterAgentId && !parseAgentSessionKey(resolved.key) + ? `agent:${agentId}:${resolved.key}` + : resolved.key; + const access = guard.check(authorizationKey); if (!access.allowed) { throw new ToolAuthorizationError(access.error); } } return { + agentId, cfg: context.cfg, + isRequesterSession, key: resolved.key, - requesterKey: context.effectiveRequesterKey, }; } @@ -266,20 +306,19 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool const action = readToolStringParam(params, "action", { required: true }); if (action === "reset" || action === "delete") { const rawKey = readToolStringParam(params, "sessionKey", { required: true }); - const { key } = await resolvePatchTarget( + const { agentId, isRequesterSession, key } = await resolvePatchTarget( { ...opts, config: opts.config ?? getRuntimeConfig() }, rawKey, gatewayRequest, ); - const context = resolveSessionToolContext({ - ...opts, - config: opts.config ?? getRuntimeConfig(), - }); - if (key === context.effectiveRequesterKey) { + if (isRequesterSession) { throw new ToolInputError(`Cannot ${action} the session running this tool`); } + const agentScope = parseAgentSessionKey(key) ? {} : { agentId }; if (action === "reset") { - return jsonResult(await callGateway("sessions.reset", { key, reason: "reset" })); + return jsonResult( + await callGateway("sessions.reset", { key, ...agentScope, reason: "reset" }), + ); } // Archive returns the exact row generation. Carry it into the locked // delete so a concurrent reset cannot delete a replacement session. @@ -293,6 +332,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool entry?: { sessionId?: string; lifecycleRevision?: string }; }>("sessions.patch", { key, + ...agentScope, expectedSessionId, archived: true, }); @@ -306,6 +346,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool return jsonResult( await callGateway("sessions.delete", { key, + ...agentScope, archivedOnly: true, expectedSessionId: archivedSessionId, ...(expectedLifecycleRevision ? { expectedLifecycleRevision } : {}), @@ -340,7 +381,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool throw new ToolInputError(`Unknown action: ${action}`); } - const { cfg, key, requesterKey } = await resolvePatchTarget( + const { agentId, cfg, isRequesterSession, key } = await resolvePatchTarget( { ...opts, config: opts.config ?? getRuntimeConfig() }, normalizeOptionalString(readToolStringParam(params, "sessionKey")), gatewayRequest, @@ -353,7 +394,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool if (typeof archived === "boolean") { const expectedSessionId = normalizeOptionalString(readToolStringParam(params, "expectedSessionId")) ?? - (key === requesterKey ? normalizeOptionalString(opts.agentSessionId) : undefined); + (isRequesterSession ? normalizeOptionalString(opts.agentSessionId) : undefined); if (!expectedSessionId) { throw new ToolInputError("Session lifecycle action requires a durable session identity"); } @@ -398,16 +439,18 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool error: "Model patch needs in-process gateway.", }); } - const callSessionPatch = async (sessionPatch: typeof patch): Promise => + const callSessionPatch = async ( + sessionPatch: typeof patch & { agentId?: string }, + ): Promise => sessionPatch.model === undefined ? await callGateway("sessions.patch", sessionPatch) : await withAgentSessionModelPatchOrigin( async () => await callGateway("sessions.patch", sessionPatch), ); const includeResolved = patch.model !== undefined || patch.thinkingLevel !== undefined; + const agentScope = parseAgentSessionKey(key) ? {} : { agentId }; - if (patch.archived === true && key === requesterKey && key !== "global") { - const agentId = resolveAgentIdFromSessionKey(key, resolveDefaultAgentId(cfg)); + if (patch.archived === true && isRequesterSession && key !== "global") { if (key !== resolveAgentMainSessionKey({ cfg, agentId })) { const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId }); const currentEntry = loadSessionEntry({ agentId, sessionKey: key, storePath }); @@ -432,6 +475,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool if (Object.keys(immediatePatch).length > 1) { immediateResult = await callSessionPatch({ ...immediatePatch, + ...agentScope, ...expectedSessionIdentity, }); } @@ -444,6 +488,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool const archiveIdentities = [key, expectedSessionIdentity.expectedSessionId]; const archivePatch = { key, + ...agentScope, archived: true, ...expectedSessionIdentity, }; @@ -533,7 +578,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool } } - const result = await callSessionPatch(patch); + const result = await callSessionPatch({ ...patch, ...agentScope }); return jsonResult( withBoundedSessionsResolved( { diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index a108c9e6a9bd..1e090adb98ec 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -7,6 +7,7 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelMessagingAdapter } from "../../channels/plugins/types.public.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/io.js"; +import { retainLegacyDefaultAgentId } from "../../config/legacy.default-agent-owner.js"; import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; import { parseSessionThreadInfo } from "../../config/sessions/thread-info.js"; import { @@ -405,6 +406,195 @@ afterEach(() => { clearRuntimeConfigSnapshot(); }); +it("fails closed for cross-agent and resolution-derived bare keys", async () => { + const bareKey = "b0d79b63-0f73-4bc9-a6b5-6d8e20f42c3c"; + const config = { + agents: { ownership: "explicit" as const, entries: { main: {}, other: {} } }, + tools: { agentToAgent: { enabled: false }, sessions: { visibility: "all" as const } }, + }; + const send = async (retained: boolean) => + requireDetails( + await createSessionsSendTool({ + agentId: "main", + agentSessionKey: MAIN_AGENT_SESSION_KEY, + config: retained ? retainLegacyDefaultAgentId(config, "main") : config, + }).execute("authorization", { + sessionKey: bareKey, + message: "status?", + timeoutSeconds: 0, + }), + ); + callGatewayMock + .mockReset() + .mockImplementation(async (request: { method?: string }) => + request.method === "sessions.resolve" ? { key: "incident-42", agentId: "other" } : {}, + ); + expect(await send(false)).toMatchObject({ + status: "forbidden", + error: expect.stringContaining("Agent-to-agent messaging is disabled"), + }); + callGatewayMock + .mockReset() + .mockImplementation(async (request: { method?: string; params?: Record }) => { + if (request.method !== "sessions.resolve") { + return {}; + } + if (request.params?.key) { + throw new Error("not a session key"); + } + return request.params?.sessionId ? { key: "incident-42" } : {}; + }); + expect(await send(true)).toMatchObject({ + status: "forbidden", + error: expect.stringContaining("Upgrade the gateway"), + }); +}); + +it("authorizes literal sentinels against their persisted fixed-store owner", async () => { + const config = { + session: { store: "/tmp/shared-sessions.sqlite" }, + agents: { + ownership: "explicit" as const, + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + tools: { agentToAgent: { enabled: false }, sessions: { visibility: "all" as const } }, + }; + const createTool = (ownerAgentId: string) => + createSessionsSendTool({ + agentId: "research", + agentSessionKey: "agent:research:main", + config: { + ...config, + agents: { + ...config.agents, + defaults: { sessionStore: { agentId: ownerAgentId } }, + }, + }, + }); + + const denied = requireDetails( + await createTool("ops").execute("foreign-global", { + sessionKey: "global", + message: "status?", + timeoutSeconds: 0, + }), + ); + expect(denied).toMatchObject({ + status: "forbidden", + error: expect.stringContaining("Agent-to-agent messaging is disabled"), + }); + expect(callGatewayMock.mock.calls).not.toContainEqual([ + expect.objectContaining({ method: "agent" }), + ]); + + callGatewayMock.mockReset().mockResolvedValue({ runId: "self-global", acceptedAt: 1 }); + const allowed = requireDetails( + await createTool("research").execute("self-global", { + sessionKey: "global", + message: "note", + timeoutSeconds: 0, + }), + ); + expect(allowed.status).toBe("accepted"); +}); + +it("authorizes a custom main alias against its persisted fixed-store owner", async () => { + const config = { + session: { mainKey: "work", store: "/tmp/custom-main-shared.sqlite" }, + agents: { + ownership: "explicit" as const, + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + tools: { agentToAgent: { enabled: false }, sessions: { visibility: "all" as const } }, + }; + const createTool = (ownerAgentId: string) => + createSessionsSendTool({ + agentId: "research", + agentSessionKey: "agent:research:work", + config: { + ...config, + agents: { + ...config.agents, + defaults: { sessionStore: { agentId: ownerAgentId } }, + }, + }, + }); + + callGatewayMock.mockImplementation(async (request: { method?: string }) => + request.method === "sessions.resolve" ? { key: "work", agentId: "ops" } : {}, + ); + expect( + requireDetails( + await createTool("ops").execute("foreign-work", { + sessionKey: "work", + message: "status?", + timeoutSeconds: 0, + }), + ), + ).toMatchObject({ + status: "forbidden", + error: expect.stringContaining("Agent-to-agent messaging is disabled"), + }); + + callGatewayMock + .mockReset() + .mockImplementation(async (request: { method?: string }) => + request.method === "sessions.resolve" + ? { key: "work", agentId: "research" } + : { runId: "self-work", acceptedAt: 1 }, + ); + expect( + requireDetails( + await createTool("research").execute("self-work", { + sessionKey: "work", + message: "note", + timeoutSeconds: 0, + }), + ).status, + ).toBe("accepted"); +}); + +it("authorizes an arbitrary bare key against its persisted fixed-store owner", async () => { + const config = { + session: { store: "/tmp/arbitrary-shared.sqlite" }, + agents: { + ownership: "explicit" as const, + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + tools: { agentToAgent: { enabled: false }, sessions: { visibility: "all" as const } }, + }; + callGatewayMock + .mockReset() + .mockImplementation(async (request: { method?: string }) => + request.method === "sessions.resolve" + ? { key: "incident-42" } + : { runId: "arbitrary-bare", acceptedAt: 1 }, + ); + + const result = requireDetails( + await createSessionsSendTool({ + agentId: "research", + agentSessionKey: "agent:research:main", + config, + }).execute("foreign-arbitrary", { + sessionKey: "incident-42", + message: "status?", + timeoutSeconds: 0, + }), + ); + + expect(result).toMatchObject({ + status: "forbidden", + error: expect.stringContaining("Agent-to-agent messaging is disabled"), + }); + expect(callGatewayMock.mock.calls).not.toContainEqual([ + expect.objectContaining({ method: "agent" }), + ]); +}); + describe("extractStoredAssistantText", () => { it("sanitizes blocks without injecting newlines", () => { const message = { @@ -750,7 +940,7 @@ describe("sessions_list gating", () => { expect(callGatewayMock).toHaveBeenLastCalledWith({ method: "chat.history", - params: { sessionKey: "current", limit: 1 }, + params: { sessionKey: "current", agentId: "main", limit: 1 }, }); }); }); @@ -1158,8 +1348,7 @@ describe("sessions_send gating", () => { error: "sessions_send cannot target the calling session; use your own reply instead", sessionKey: "current", }); - expect(callGatewayMock).toHaveBeenCalledTimes(1); - expect(requireGatewayRequest().method).toBe("sessions.resolve"); + expect(callGatewayMock).not.toHaveBeenCalled(); expect(callGatewayMock.mock.calls).not.toContainEqual([ expect.objectContaining({ method: "agent" }), ]); diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts index e040fc28d838..12e949e362e6 100644 --- a/src/agents/tools/subagents-tool.ts +++ b/src/agents/tools/subagents-tool.ts @@ -10,6 +10,7 @@ import { listTaskRecordsUnsorted } from "../../tasks/runtime-internal.js"; import { cancelDetachedTaskRunById } from "../../tasks/task-executor.js"; import type { TaskRecord, TaskStatus } from "../../tasks/task-registry.types.js"; import { TASK_STATUS_DETAIL_MAX_CHARS, sanitizeTaskStatusText } from "../../tasks/task-status.js"; +import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalPositiveIntegerSchema, optionalStringEnum } from "../schema/typebox.js"; import { DEFAULT_RECENT_MINUTES, @@ -19,7 +20,12 @@ import { } from "../subagents/registry/subagent-control.js"; import { buildSubagentList } from "../subagents/registry/subagent-list.js"; import type { AnyAgentTool } from "./common.js"; -import { jsonResult, readPositiveIntegerParam, readToolStringParam } from "./common.js"; +import { + jsonResult, + readPositiveIntegerParam, + readToolStringParam, + ToolInputError, +} from "./common.js"; const SUBAGENT_ACTIONS = ["list", "cancel"] as const; type SubagentAction = (typeof SUBAGENT_ACTIONS)[number]; @@ -42,6 +48,7 @@ const STATUS_MAP: Record = { type SubagentsToolOptions = { agentSessionKey?: string; + agentId?: string; config?: OpenClawConfig; listTasks?: typeof listTaskRecordsUnsorted; cancelTask?: typeof cancelDetachedTaskRunById; @@ -51,8 +58,29 @@ function taskUpdatedAt(task: TaskRecord): number { return task.lastEventAt ?? task.endedAt ?? task.startedAt ?? task.createdAt; } -function listTreeTasks(tasks: TaskRecord[], rootSessionKey: string): TaskRecord[] { - const visibleKeys = new Set([rootSessionKey]); +function resolveTaskRequesterAgentId(task: TaskRecord, cfg: OpenClawConfig): string | undefined { + if (task.requesterAgentId) { + return task.requesterAgentId; + } + return resolveSessionAgentId({ sessionKey: task.ownerKey, config: cfg }); +} + +function taskOwnerMatches( + task: TaskRecord, + sessionKey: string, + agentId: string, + cfg: OpenClawConfig, +): boolean { + return task.ownerKey === sessionKey && resolveTaskRequesterAgentId(task, cfg) === agentId; +} + +function listTreeTasks( + tasks: TaskRecord[], + rootSessionKey: string, + rootAgentId: string, + cfg: OpenClawConfig, +): TaskRecord[] { + const visibleSessions = new Set([`${rootAgentId}\0${rootSessionKey}`]); const visibleTasks = new Set(); let changed = true; while (changed) { @@ -61,13 +89,17 @@ function listTreeTasks(tasks: TaskRecord[], rootSessionKey: string): TaskRecord[ if (task.scopeKind !== "session" || visibleTasks.has(task.taskId)) { continue; } - if (!visibleKeys.has(task.ownerKey)) { + const taskRequesterAgentId = resolveTaskRequesterAgentId(task, cfg); + if (!visibleSessions.has(`${taskRequesterAgentId ?? ""}\0${task.ownerKey}`)) { continue; } visibleTasks.add(task.taskId); - if (task.childSessionKey && !visibleKeys.has(task.childSessionKey)) { - visibleKeys.add(task.childSessionKey); - changed = true; + if (task.childSessionKey) { + const childIdentity = `${task.agentId ?? taskRequesterAgentId ?? ""}\0${task.childSessionKey}`; + if (!visibleSessions.has(childIdentity)) { + visibleSessions.add(childIdentity); + changed = true; + } } } } @@ -114,12 +146,23 @@ export function createSubagentsTool(opts: SubagentsToolOptions = {}): AnyAgentTo const controller = resolveSubagentController({ cfg, agentSessionKey: opts?.agentSessionKey, + agentId: opts.agentId, }); + const controllerAgentId = controller.controllerAgentId; + if (!controllerAgentId) { + throw new ToolInputError("subagent controller agent required"); + } // The caller only sees subagents controlled by its effective controller session. - const runs = listControlledSubagentRuns(controller.controllerSessionKey); + const runs = listControlledSubagentRuns( + controller.controllerSessionKey, + controllerAgentId, + cfg, + ); const treeTasks = listTreeTasks( (opts.listTasks ?? listTaskRecordsUnsorted)(), controller.controllerSessionKey, + controllerAgentId, + cfg, ); if (action === "list") { @@ -163,7 +206,7 @@ export function createSubagentsTool(opts: SubagentsToolOptions = {}): AnyAgentTo // control-scope gate every other cross-session subagent mutation enforces. if ( controller.controlScope !== "children" && - target.ownerKey !== controller.callerSessionKey + !taskOwnerMatches(target, controller.callerSessionKey, controllerAgentId, cfg) ) { return jsonResult({ status: "forbidden", diff --git a/src/agents/tools/terminal-tool.test.ts b/src/agents/tools/terminal-tool.test.ts index c68190d62ff6..eb48355d9c7e 100644 --- a/src/agents/tools/terminal-tool.test.ts +++ b/src/agents/tools/terminal-tool.test.ts @@ -113,6 +113,7 @@ describe("terminal tool", () => { const sessionId = (opened.details as { sessionId: string }).sessionId; expect(backend.writes).toEqual(["echo ready\r"]); expect(callGateway).toHaveBeenCalledWith("ui.command", { + agentId: "main", command: { kind: "panel", panel: "terminal", @@ -194,7 +195,7 @@ describe("terminal tool", () => { expect(firstBackend.killed).toBe(true); expect(secondBackend.killed).toBe(false); expect(persistentBackend.killed).toBe(false); - expect(manager.listAgent(agentSessionKey)).toHaveLength(2); + expect(manager.listAgent(agentSessionKey, "main")).toHaveLength(2); }); it("maps a cron agent run to its detached task before terminal lookup", async () => { diff --git a/src/agents/tools/terminal-tool.ts b/src/agents/tools/terminal-tool.ts index d4d7e1ecf27a..5b9ff5f9bcbc 100644 --- a/src/agents/tools/terminal-tool.ts +++ b/src/agents/tools/terminal-tool.ts @@ -176,6 +176,7 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool if (!agentSessionKey) { throw new ToolInputError("agent session required"); } + const agentId = opts.agentId?.trim() || resolveAgentIdFromSessionKey(agentSessionKey); const context = getContext(); const manager = context?.terminalSessions; if (!context || !manager) { @@ -183,7 +184,7 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool } if (action === "list") { - return jsonResult({ sessions: manager.listAgent(agentSessionKey) }); + return jsonResult({ sessions: manager.listAgent(agentSessionKey, agentId) }); } if (action === "open") { @@ -195,7 +196,6 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool if (!context.isTerminalEnabled()) { throw new ToolInputError("terminal disabled"); } - const agentId = opts.agentId?.trim() || resolveAgentIdFromSessionKey(agentSessionKey); const launch = context.resolveTerminalLaunchPolicy(agentId); if (!launch.ok) { throw new ToolInputError(launchBlockMessage(launch.block)); @@ -213,7 +213,12 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool throw new ToolInputError("terminal task already ended"); } const taskId = task?.taskId; - const owner = { kind: "agent", agentSessionKey, ...(taskId ? { taskId } : {}) } as const; + const owner = { + kind: "agent", + agentSessionKey, + agentId, + ...(taskId ? { taskId } : {}), + } as const; const deadline = createTerminalOpenDeadline(); const cancelOpen = () => { if (!deadline.controller.signal.aborted) { @@ -247,7 +252,7 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool void openingTerminal.then( (lateOutcome) => { if (lateOutcome.ok) { - manager.closeAgent(agentSessionKey, lateOutcome.sessionId); + manager.closeAgent(agentSessionKey, lateOutcome.sessionId, agentId); } }, () => undefined, @@ -265,9 +270,9 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool } if ( command !== undefined && - !manager.writeAgent(agentSessionKey, outcome.sessionId, `${command}\r`) + !manager.writeAgent(agentSessionKey, outcome.sessionId, `${command}\r`, agentId) ) { - manager.closeAgent(agentSessionKey, outcome.sessionId); + manager.closeAgent(agentSessionKey, outcome.sessionId, agentId); throw new ToolInputError("terminal command failed"); } if (show) { @@ -279,6 +284,7 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool terminalSessionId: outcome.sessionId, }, sessionKey: agentSessionKey, + agentId, }; try { await gatewayCall("ui.command", uiCommand); @@ -291,7 +297,7 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool const sessionId = requireSessionId(params); if (action === "read") { - const raw = manager.snapshotAgent(agentSessionKey, sessionId); + const raw = manager.snapshotAgent(agentSessionKey, sessionId, agentId); if (raw === undefined) { throw new ToolInputError("terminal not owned by this agent session"); } @@ -303,7 +309,9 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool trim: false, allowEmpty: true, }); - return jsonResult({ ok: manager.writeAgent(agentSessionKey, sessionId, data) }); + return jsonResult({ + ok: manager.writeAgent(agentSessionKey, sessionId, data, agentId), + }); } if (action === "resize") { return jsonResult({ @@ -312,11 +320,12 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool sessionId, readDimension(params, "cols"), readDimension(params, "rows"), + agentId, ), }); } if (action === "close") { - return jsonResult({ ok: manager.closeAgent(agentSessionKey, sessionId) }); + return jsonResult({ ok: manager.closeAgent(agentSessionKey, sessionId, agentId) }); } throw new ToolInputError(`Unknown action: ${action}`); }, diff --git a/src/agents/tools/video-generate-tool.actions.ts b/src/agents/tools/video-generate-tool.actions.ts index 3d70f7a1fc54..a91f7d8f8525 100644 --- a/src/agents/tools/video-generate-tool.actions.ts +++ b/src/agents/tools/video-generate-tool.actions.ts @@ -133,7 +133,8 @@ export const { createDuplicateGuardResult: createVideoGenerateDuplicateGuardResult, } = createMediaGenerateTaskActions({ inactiveText: "No active video generation task is currently running for this session.", - findActiveTask: findActiveVideoGenerationTaskForSession, + findActiveTask: (sessionKey, agentId) => + findActiveVideoGenerationTaskForSession(sessionKey, { agentId }), findDuplicateTask: (sessionKey, request) => findDuplicateGuardVideoGenerationTaskForSession(sessionKey, request), buildStatusText: buildVideoGenerationTaskStatusText, diff --git a/src/agents/tools/video-generate-tool.ts b/src/agents/tools/video-generate-tool.ts index 9192591b665d..4fcc394020fb 100644 --- a/src/agents/tools/video-generate-tool.ts +++ b/src/agents/tools/video-generate-tool.ts @@ -894,6 +894,7 @@ export function createVideoGenerateTool(options?: { agentDir?: string; authProfileStore?: AuthProfileStore; agentSessionKey?: string; + requesterAgentId?: string; requesterOrigin?: DeliveryContext; workspaceDir?: string; preparedModelRuntime?: PreparedModelRuntimeSnapshot; @@ -959,7 +960,10 @@ export function createVideoGenerateTool(options?: { } if (action === "status") { - return createVideoGenerateStatusActionResult(options?.agentSessionKey); + return createVideoGenerateStatusActionResult( + options?.agentSessionKey, + options?.requesterAgentId, + ); } const videoGenerationModelConfig = resolveVideoGenerationModelConfigForTool({ @@ -979,7 +983,7 @@ export function createVideoGenerateTool(options?: { const activeDuplicateGuardResult = createVideoGenerateDuplicateGuardResult( options?.agentSessionKey, - { prompt }, + { prompt, agentId: options?.requesterAgentId }, ); if (activeDuplicateGuardResult) { return activeDuplicateGuardResult; @@ -1089,7 +1093,7 @@ export function createVideoGenerateTool(options?: { }); const duplicateGuardResult = createVideoGenerateDuplicateGuardResult( options?.agentSessionKey, - { prompt, requestKey }, + { prompt, requestKey, agentId: options?.requesterAgentId }, ); if (duplicateGuardResult) { return duplicateGuardResult; @@ -1144,17 +1148,20 @@ export function createVideoGenerateTool(options?: { signal?.throwIfAborted(); const taskHandle = createVideoGenerationTaskRun({ sessionKey: options?.agentSessionKey, + requesterAgentId: options?.requesterAgentId, requesterOrigin: options?.requesterOrigin, prompt, providerId: selectedProvider?.id, }); const shouldDetach = Boolean( - taskHandle && shouldDetachMediaGenerationTask(options?.agentSessionKey), + taskHandle && + shouldDetachMediaGenerationTask(options?.agentSessionKey, options?.requesterAgentId), ); if (shouldDetach && taskHandle) { recordRecentMediaGenerationTaskStartForSession({ sessionKey: options?.agentSessionKey, + agentId: options?.requesterAgentId, taskKind: "video_generation", sourcePrefix: "video_generate", taskId: taskHandle.taskId, diff --git a/src/agents/workspace-dirs.ts b/src/agents/workspace-dirs.ts index e11a9e9ce93c..924aa8765627 100644 --- a/src/agents/workspace-dirs.ts +++ b/src/agents/workspace-dirs.ts @@ -6,19 +6,22 @@ */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; -import { - listAgentEntries, - resolveAgentWorkspaceDir, - resolveDefaultAgentId, -} from "./agent-scope.js"; +import { tryResolveSoleAgentId } from "./agent-scope-config.js"; +import { listAgentEntries, resolveAgentWorkspaceDir } from "./agent-scope.js"; /** Lists unique workspace directories for configured agents and the default agent. */ -export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] { +export function listAgentWorkspaceDirs( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { const dirs = new Set(); for (const entry of listAgentEntries(cfg)) { - dirs.add(resolveAgentWorkspaceDir(cfg, entry.id)); + dirs.add(resolveAgentWorkspaceDir(cfg, entry.id, env)); + } + const soleAgentId = tryResolveSoleAgentId(cfg); + if (soleAgentId) { + dirs.add(resolveAgentWorkspaceDir(cfg, soleAgentId, env)); } - dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg))); return [...dirs]; } diff --git a/src/agents/workspace-run.test.ts b/src/agents/workspace-run.test.ts index 90b3ddd12e66..a7aff24716f2 100644 --- a/src/agents/workspace-run.test.ts +++ b/src/agents/workspace-run.test.ts @@ -189,4 +189,28 @@ describe("resolveRunWorkspaceDir", () => { expect(result.agentIdSource).toBe("default"); expect(result.workspaceDir).toBe(path.resolve(fallbackWorkspace)); }); + + it("uses the persisted fixed-store owner for a bare global workspace", () => { + const opsWorkspace = path.join(process.cwd(), "tmp", "workspace-ops-global"); + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { + ops: { workspace: opsWorkspace }, + research: { workspace: path.join(process.cwd(), "tmp", "workspace-research-global") }, + }, + }, + session: { scope: "global", store: "/tmp/openclaw-shared-sessions.sqlite" }, + } satisfies OpenClawConfig; + + const result = resolveRunWorkspaceDir({ + workspaceDir: undefined, + sessionKey: "global", + config: cfg, + }); + + expect(result.agentId).toBe("ops"); + expect(result.workspaceDir).toBe(path.resolve(opsWorkspace)); + }); }); diff --git a/src/agents/workspace-run.ts b/src/agents/workspace-run.ts index d689ab7e0692..279cfc4e8b83 100644 --- a/src/agents/workspace-run.ts +++ b/src/agents/workspace-run.ts @@ -15,8 +15,8 @@ import { resolveUserPath } from "../utils.js"; import { hasAgentRosterProperty } from "./agent-scope-config.js"; import { resolveAgentConfig, + resolveSessionAgentId, resolveAgentWorkspaceDir, - resolveDefaultAgentId, } from "./agent-scope.js"; import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; @@ -72,27 +72,16 @@ function resolveRunAgentId(params: { typeof params.agentId === "string" && params.agentId.trim() ? normalizeAgentId(params.agentId) : undefined; - if (explicit) { - return { agentId: explicit, agentIdSource: "explicit" }; - } - - if (shape === "missing" || shape === "legacy_or_alias") { - return { - agentId: resolveDefaultAgentId(params.config), - agentIdSource: "default", - }; - } - const parsed = parseAgentSessionKey(rawSessionKey); - if (parsed?.agentId) { - return { - agentId: normalizeAgentId(parsed.agentId), - agentIdSource: "session_key", - }; - } - - // Defensive fallback, should be unreachable for non-malformed shapes. - throw new Error("Session key does not resolve to a configured agent."); + const agentId = resolveSessionAgentId({ + sessionKey: rawSessionKey || undefined, + agentId: explicit, + config: params.config, + }); + return { + agentId, + agentIdSource: explicit ? "explicit" : parsed?.agentId ? "session_key" : "default", + }; } /** Redacts a run/session identifier for logs and prompts. */ diff --git a/src/auto-reply/reply/agent-runner-execution-context-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-context-failures.test.ts index 0b38a8a9fe4e..a786664fb3d4 100644 --- a/src/auto-reply/reply/agent-runner-execution-context-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-context-failures.test.ts @@ -32,10 +32,13 @@ describe("executeAgentTurn: context failures", () => { const activeSessionEntry = { sessionId: "session", updatedAt: 1 } as SessionEntry; const activeSessionStore = { "agent:main:main": activeSessionEntry }; + const followupRun = createFollowupRun(); + followupRun.run.agentId = "main"; const { replyOperation, failMock, updateSessionIdMock } = createMockReplyOperation(); const executeAgentTurn = await getExecuteAgentTurnForTest(); const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ + followupRun, sessionCtx: { Provider: "webchat", MessageSid: "msg", @@ -75,10 +78,13 @@ describe("executeAgentTurn: context failures", () => { const activeSessionEntry = { sessionId: "session", updatedAt: 1 } as SessionEntry; const activeSessionStore = { "agent:main:main": activeSessionEntry }; + const followupRun = createFollowupRun(); + followupRun.run.agentId = "main"; const { replyOperation, failMock, updateSessionIdMock } = createMockReplyOperation(); const executeAgentTurn = await getExecuteAgentTurnForTest(); const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ + followupRun, sessionCtx: { Provider: "webchat", MessageSid: "msg", diff --git a/src/auto-reply/reply/bash-command.stop.test.ts b/src/auto-reply/reply/bash-command.stop.test.ts index 62e9fc6ee0ec..afd352fae5ed 100644 --- a/src/auto-reply/reply/bash-command.stop.test.ts +++ b/src/auto-reply/reply/bash-command.stop.test.ts @@ -62,7 +62,7 @@ function buildElevatedDeniedParams(commandBody: string) { ...base.ctx, SessionKey: "agent:main:telegram:slash-session", } as MsgContext, - agentId: "main", + agentId: "target", sessionKey: "agent:target:telegram:direct:target-session", elevated: { enabled: true, @@ -193,6 +193,8 @@ describe("handleBashChatCommand stop", () => { .mockReturnValue({ agentId: "target", sessionKey: "agent:target:telegram:direct:target-session", + classificationAgentId: "target", + classificationSessionKey: "agent:target:telegram:direct:target-session", mainSessionKey: "agent:target:main", mode: "non-main", sandboxed: true, diff --git a/src/auto-reply/reply/bash-command.ts b/src/auto-reply/reply/bash-command.ts index 0385ad930b4b..d31fb917beb0 100644 --- a/src/auto-reply/reply/bash-command.ts +++ b/src/auto-reply/reply/bash-command.ts @@ -192,6 +192,7 @@ export async function handleBashChatCommand(params: { const runtimeSandboxed = resolveSandboxRuntimeStatus({ cfg: params.cfg, sessionKey: resolveRuntimePolicySessionKey({ + agentId, cfg: params.cfg, ctx: params.ctx, sessionKey: params.sessionKey, diff --git a/src/auto-reply/reply/commands-acp/diagnostics.ts b/src/auto-reply/reply/commands-acp/diagnostics.ts index 81e6dbc2d929..8c8d91f80fe2 100644 --- a/src/auto-reply/reply/commands-acp/diagnostics.ts +++ b/src/auto-reply/reply/commands-acp/diagnostics.ts @@ -191,7 +191,11 @@ export async function handleAcpSessionsAction( const bindingService = getSessionBindingService(); const currentEntry = params.command.senderIsOwner ? null - : readAcpSessionEntry({ cfg: params.cfg, sessionKey: currentSessionKey }); + : readAcpSessionEntry({ + cfg: params.cfg, + sessionKey: currentSessionKey, + agentId: params.agentId, + }); const visibleEntries = params.command.senderIsOwner ? await listAcpSessionEntries({ cfg: params.cfg }) : currentEntry?.entry && currentEntry.acp diff --git a/src/auto-reply/reply/commands-acp/runtime-options.ts b/src/auto-reply/reply/commands-acp/runtime-options.ts index a1d94955a701..f78d65319a2b 100644 --- a/src/auto-reply/reply/commands-acp/runtime-options.ts +++ b/src/auto-reply/reply/commands-acp/runtime-options.ts @@ -165,6 +165,8 @@ export async function handleAcpStatusAction( const linkedTask = findLatestTaskForRelatedSessionKeyForOwner({ relatedSessionKey: status.sessionKey, callerOwnerKey: params.sessionKey, + callerAgentId: params.agentId, + config: params.cfg, }); const sessionIdentifierLines = resolveAcpSessionIdentifierLinesFromIdentity({ backend: status.backend, diff --git a/src/auto-reply/reply/commands-learn.ts b/src/auto-reply/reply/commands-learn.ts index 249877f91639..b18c272a156d 100644 --- a/src/auto-reply/reply/commands-learn.ts +++ b/src/auto-reply/reply/commands-learn.ts @@ -51,6 +51,7 @@ function workshopIsAvailable(params: HandleCommandsParams): boolean { } const policySessionKey = resolveRuntimePolicySessionKey({ + agentId: params.agentId, cfg: params.cfg, ctx: params.ctx, sessionKey: params.sessionKey, diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index c1531c3c432a..a84872eeb97a 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -204,7 +204,7 @@ describe("handleCommands /plugins install", () => { spec: "@acme/policy-plugin@1.0.0", config: { ...policyConfig, - agents: { entries: { main: { default: true } } }, + agents: { entries: { main: {} } }, }, mode: "update", }); @@ -267,7 +267,7 @@ describe("handleCommands /plugins install", () => { spec: "@openclaw/brave-plugin", config: { ...policyConfig, - agents: { entries: { main: { default: true } } }, + agents: { entries: { main: {} } }, }, expectedPluginId: "brave", trustedSourceLinkedOfficialInstall: true, diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts index d4f0070268e0..b246fea04d19 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -155,6 +155,7 @@ export async function handleSubagentsFocusAction( ? readAcpSessionEntry({ cfg: params.cfg, sessionKey: focusTarget.targetSessionKey, + agentId: focusTarget.agentId, })?.acp : undefined; if (!capabilities.placements.includes(bindingContext.placement)) { diff --git a/src/auto-reply/reply/commands-subagents/action-info.ts b/src/auto-reply/reply/commands-subagents/action-info.ts index 4a374890e41e..6a01424ac745 100644 --- a/src/auto-reply/reply/commands-subagents/action-info.ts +++ b/src/auto-reply/reply/commands-subagents/action-info.ts @@ -65,6 +65,8 @@ export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): Command const linkedTask = findTaskByRunIdForOwner({ runId: run.runId, callerOwnerKey: requesterKey, + callerAgentId: params.agentId, + config: params.cfg, }); const taskText = sanitizeTaskStatusText(run.task) || "n/a"; const progressText = sanitizeTaskStatusText(linkedTask?.progressSummary); diff --git a/src/auto-reply/reply/commands-system-prompt.test.ts b/src/auto-reply/reply/commands-system-prompt.test.ts index 8ad29a3e7fa0..09c234e98845 100644 --- a/src/auto-reply/reply/commands-system-prompt.test.ts +++ b/src/auto-reply/reply/commands-system-prompt.test.ts @@ -46,6 +46,7 @@ vi.mock("../../skills/runtime/session-snapshot.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentConfig: vi.fn(() => undefined), + resolveSessionAgentId: vi.fn(({ agentId }: { agentId?: string }) => agentId ?? "main"), resolveSessionAgentIds: vi.fn(() => ({ sessionAgentId: "main" })), })); diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index be2dab872e29..b87b4d561bc4 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -176,6 +176,7 @@ export async function resolveCommandsSystemPromptBundle( agentId: sessionAgentId, }); const toolPolicySessionKey = resolveRuntimePolicySessionKey({ + agentId: sessionAgentId, cfg: params.cfg, ctx: params.ctx, sessionKey: params.sessionKey, diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index feff56f6f71b..2ba9dc32d116 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -1,6 +1,5 @@ /** Applies directive-only command state changes without running the agent. */ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { renderExecTargetLabel } from "../../agents/bash-tools.exec-runtime.js"; import { resolveExecDefaults } from "../../agents/exec-defaults.js"; import { @@ -9,7 +8,6 @@ import { formatFastModeValue, resolveFastModeState, } from "../../agents/fast-mode.js"; -import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { persistStickyModelSelectionBestEffort } from "../../agents/sticky-model-selection.js"; import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js"; import { resolveSessionAuthProfileOverrideSource } from "../../config/sessions/auth-profile-override-provenance.js"; @@ -54,9 +52,9 @@ import { resolveDirectiveTouchedSessionFields, withOptions, } from "./directive-handling.shared.js"; +import { resolveDirectiveRuntimeContext } from "./directive-runtime-context.js"; import type { ReasoningLevel, ThinkLevel } from "./directives.js"; import { refreshQueuedFollowupSession } from "./queue.js"; -import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; /** Handles inline directives that can be acknowledged without a model turn. */ export async function handleDirectiveOnly( @@ -110,20 +108,8 @@ export async function handleDirectiveOnly( "hasTraceDirective", ); } - const activeAgentId = resolveSessionAgentId({ - sessionKey: params.sessionKey, - config: params.cfg, - }); - const agentDir = resolveAgentDir(params.cfg, activeAgentId); - const runtimePolicySessionKey = resolveRuntimePolicySessionKey({ - cfg: params.cfg, - ctx: params.ctx, - sessionKey: params.sessionKey, - }); - const runtimeIsSandboxed = resolveSandboxRuntimeStatus({ - cfg: params.cfg, - sessionKey: runtimePolicySessionKey, - }).sandboxed; + const { activeAgentId, agentDir, runtimePolicySessionKey, runtimeIsSandboxed } = + resolveDirectiveRuntimeContext(params); const shouldHintDirectRuntime = directives.hasElevatedDirective && !runtimeIsSandboxed; const thinkingCatalog = params.thinkingCatalog && params.thinkingCatalog.length > 0 diff --git a/src/auto-reply/reply/directive-runtime-context.ts b/src/auto-reply/reply/directive-runtime-context.ts new file mode 100644 index 000000000000..cb50ca3d5d87 --- /dev/null +++ b/src/auto-reply/reply/directive-runtime-context.ts @@ -0,0 +1,25 @@ +import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; +import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; +import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; + +export function resolveDirectiveRuntimeContext( + params: Pick, +) { + const activeAgentId = resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + const agentDir = resolveAgentDir(params.cfg, activeAgentId); + const runtimePolicySessionKey = resolveRuntimePolicySessionKey({ + agentId: activeAgentId, + cfg: params.cfg, + ctx: params.ctx, + sessionKey: params.sessionKey, + }); + const runtimeIsSandboxed = resolveSandboxRuntimeStatus({ + cfg: params.cfg, + sessionKey: runtimePolicySessionKey, + }).sandboxed; + return { activeAgentId, agentDir, runtimePolicySessionKey, runtimeIsSandboxed }; +} diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 0735ebcc3536..feb5a9436632 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -407,6 +407,7 @@ async function finalizeAcpTurnOutput(params: { const currentMeta = readAcpSessionEntry({ cfg: params.cfg, sessionKey: params.sessionKey, + agentId: params.agentId, })?.acp; const identityAfterTurn = resolveSessionIdentityFromMeta(currentMeta); if (!isSessionIdentityPending(identityAfterTurn)) { diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index f55e4263ef9d..6f8d875c73d5 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -326,7 +326,12 @@ export async function resolveReplyDirectives(params: { typing.cleanup(); const runtimeSandboxed = resolveSandboxRuntimeStatus({ cfg, - sessionKey: resolveRuntimePolicySessionKey({ cfg, ctx, sessionKey: ctx.SessionKey }), + sessionKey: resolveRuntimePolicySessionKey({ + agentId, + cfg, + ctx, + sessionKey: ctx.SessionKey, + }), }).sandboxed; return { kind: "reply", @@ -541,7 +546,7 @@ export async function resolveReplyDirectives(params: { provider, modelId: model, agentId, - sessionKey: resolveRuntimePolicySessionKey({ cfg, ctx, sessionKey }), + sessionKey: resolveRuntimePolicySessionKey({ agentId, cfg, ctx, sessionKey }), sessionEntry: targetSessionEntry, }); const resolvedThinkLevelWithDefault = diff --git a/src/auto-reply/reply/get-reply-run-context.ts b/src/auto-reply/reply/get-reply-run-context.ts index 40971b686ff7..a4cc176392a5 100644 --- a/src/auto-reply/reply/get-reply-run-context.ts +++ b/src/auto-reply/reply/get-reply-run-context.ts @@ -87,7 +87,7 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { sessionEntryHandle, sessionStore, } = params; - const runtimePolicySessionKey = resolveRuntimePolicySessionKey({ cfg, ctx, sessionKey }); + const runtimePolicySessionKey = resolveRuntimePolicySessionKey({ agentId, cfg, ctx, sessionKey }); const { resolvedElevatedLevel, execOverrides, abortedLastRun } = params; let { sessionEntry } = params; const isHeartbeat = opts?.isHeartbeat === true; diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 1eedef5d8458..4eb27b0e6ead 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -828,6 +828,7 @@ describe("runPreparedReply media-only handling", () => { it("does not borrow target-session silence for native commands sent from direct chats", async () => { await runPrepared({ + agentId: "main", sessionKey: "agent:main:telegram:group:target", ctx: { ...createInboundBody(""), @@ -928,6 +929,7 @@ describe("runPreparedReply media-only handling", () => { vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunStreaming).mockReturnValueOnce(true); const params = baseParams({ + agentId: "main", sessionKey: `agent:main:${channel}:direct:steer-smoke`, }); params.ctx = { @@ -2494,6 +2496,7 @@ describe("runPreparedReply media-only handling", () => { previousRun.setPhase("running"); const runPromise = runPrepared({ + agentId: "main", isNewSession: false, sessionId: "session-before-wait", sessionKey: dispatchSessionKey, @@ -3859,6 +3862,7 @@ describe("runPreparedReply media-only handling", () => { }); await runPrepared({ + agentId: "main", ctx: createInboundBody("report queued reactions"), opts: withReplySystemEventSessionKey({}, "agent:main:slack:channel:c123"), provider: "", diff --git a/src/auto-reply/reply/runtime-policy-session-key.test.ts b/src/auto-reply/reply/runtime-policy-session-key.test.ts index 6b2610b5e530..eca2bde3796a 100644 --- a/src/auto-reply/reply/runtime-policy-session-key.test.ts +++ b/src/auto-reply/reply/runtime-policy-session-key.test.ts @@ -1,5 +1,6 @@ // Tests runtime policy session-key derivation for routed replies. import { describe, expect, it } from "vitest"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { MsgContext } from "../templating.js"; @@ -117,4 +118,48 @@ describe("resolveRuntimePolicySessionKey", () => { }), ).toBe("agent:main:telegram:default:direct:alice"); }); + + it("uses the persisted fixed-store owner for a bare global policy key", () => { + const explicitConfig: OpenClawConfig = { + session: { scope: "global", store: "/tmp/shared-sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "research" } }, + entries: { ops: {}, research: {} }, + }, + }; + + expect( + resolveRuntimePolicySessionKey({ + cfg: explicitConfig, + sessionKey: "global", + ctx: { + OriginatingChannel: "slack" as MsgContext["OriginatingChannel"], + ChatType: "direct", + SenderId: "U123", + }, + }), + ).toBe("agent:research:slack:default:direct:u123"); + expect(() => + resolveRuntimePolicySessionKey({ + cfg: explicitConfig, + sessionKey: "global", + ctx: { AgentId: "ops" }, + }), + ).toThrow(AgentSelectionRequiredError); + }); + + it("uses an explicit agent for a bare main alias without config", () => { + expect( + resolveRuntimePolicySessionKey({ + agentId: "research", + sessionKey: "main", + ctx: { + OriginatingChannel: "slack" as MsgContext["OriginatingChannel"], + ChatType: "direct", + SenderId: "U123", + }, + }), + ).toBe("agent:research:slack:default:direct:u123"); + }); }); diff --git a/src/auto-reply/reply/runtime-policy-session-key.ts b/src/auto-reply/reply/runtime-policy-session-key.ts index a5c2fce3d6cd..32e78fd42dc6 100644 --- a/src/auto-reply/reply/runtime-policy-session-key.ts +++ b/src/auto-reply/reply/runtime-policy-session-key.ts @@ -3,7 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -11,12 +11,13 @@ import { buildAgentPeerSessionKey, normalizeAgentId, normalizeMainKey, - resolveAgentIdFromSessionKey, + parseAgentSessionKey, } from "../../routing/session-key.js"; import type { MsgContext } from "../templating.js"; type RuntimePolicyContext = Pick< MsgContext, + | "AgentId" | "AccountId" | "ChatType" | "CommandTargetSessionKey" @@ -85,9 +86,9 @@ function isMainSessionAlias(params: { ); } -/** Resolves the session key used for runtime policy checks and direct-message scoping. */ /** Resolves the session key used for sandbox/tool/runtime policy lookups. */ export function resolveRuntimePolicySessionKey(params: { + agentId?: string; cfg?: OpenClawConfig; ctx?: RuntimePolicyContext; sessionKey?: string | null; @@ -103,10 +104,18 @@ export function resolveRuntimePolicySessionKey(params: { return undefined; } - const agentId = resolveAgentIdFromSessionKey( - sessionKey, - params.cfg ? resolveDefaultAgentId(params.cfg) : undefined, - ); + const agentId = params.cfg + ? resolveSessionAgentId({ + config: params.cfg, + sessionKey, + agentId: params.agentId ?? normalizeOptionalString(params.ctx?.AgentId), + }) + : (parseAgentSessionKey(sessionKey)?.agentId ?? + normalizeOptionalString(params.agentId) ?? + normalizeOptionalString(params.ctx?.AgentId)); + if (!agentId) { + return sessionKey; + } if (!isMainSessionAlias({ cfg: params.cfg, agentId, sessionKey })) { return sessionKey; } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a054ff349d10..021d4c2a95a4 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -1102,7 +1102,12 @@ async function initSessionStateAttemptLocked( // Direct-message browser tabs use a peer-scoped runtime identity even when // their transcript aliases main; cleanup must carry both exact keys. const runtimePolicySessionKey = - resolveRuntimePolicySessionKey({ cfg, ctx: sessionCtxForState, sessionKey }) ?? sessionKey; + resolveRuntimePolicySessionKey({ + agentId, + cfg, + ctx: sessionCtxForState, + sessionKey, + }) ?? sessionKey; void runWithGatewayIndependentRootWorkContinuation(async () => { await cleanupBrowserSessionsForLifecycleEnd({ cfg, diff --git a/src/canvas/widget-tool.test.ts b/src/canvas/widget-tool.test.ts index 79d951dc8f89..41281fec5a11 100644 --- a/src/canvas/widget-tool.test.ts +++ b/src/canvas/widget-tool.test.ts @@ -296,7 +296,10 @@ describe("show_widget", () => { client: null, isWebchatConnect: () => false, respond, - context: { broadcast } as unknown as GatewayRequestContext, + context: { + broadcast, + getRuntimeConfig: () => ({ agents: { list: [{ id: "main" }] } }), + } as unknown as GatewayRequestContext, }); if (failure) { throw failure; @@ -355,7 +358,10 @@ describe("show_widget", () => { failure = new Error(error?.message ?? "board request failed"); } }, - context: { broadcast: vi.fn() } as unknown as GatewayRequestContext, + context: { + broadcast: vi.fn(), + getRuntimeConfig: () => ({ agents: { list: [{ id: "main" }] } }), + } as unknown as GatewayRequestContext, }); if (failure) { throw failure; @@ -484,7 +490,10 @@ describe("show_widget", () => { failure = new Error(error?.message ?? "board request failed"); } }, - context: { broadcast: vi.fn() } as unknown as GatewayRequestContext, + context: { + broadcast: vi.fn(), + getRuntimeConfig: () => ({ agents: { list: [{ id: "main" }] } }), + } as unknown as GatewayRequestContext, }); if (failure) { throw failure; diff --git a/src/channels/plugins/acp-configured-binding-consumer.ts b/src/channels/plugins/acp-configured-binding-consumer.ts index 98f07b8f1ce8..3f5188a35eff 100644 --- a/src/channels/plugins/acp-configured-binding-consumer.ts +++ b/src/channels/plugins/acp-configured-binding-consumer.ts @@ -17,7 +17,6 @@ import { resolveAgentConfig, resolveAgentExplicitModelPrimary, resolveAgentWorkspaceDir, - resolveDefaultAgentId, } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { @@ -59,11 +58,8 @@ function resolveConfiguredBindingWorkspaceCwd(params: { if (explicitAgentWorkspace) { return resolveAgentWorkspaceDir(params.cfg, params.agentId); } - if (params.agentId === resolveDefaultAgentId(params.cfg)) { - const defaultWorkspace = normalizeText(params.cfg.agents?.defaults?.workspace); - if (defaultWorkspace) { - return resolveAgentWorkspaceDir(params.cfg, params.agentId); - } + if (normalizeText(params.cfg.agents?.defaults?.workspace)) { + return resolveAgentWorkspaceDir(params.cfg, params.agentId); } return undefined; } diff --git a/src/channels/plugins/read-only.legacy-workspace.test.ts b/src/channels/plugins/read-only.legacy-workspace.test.ts new file mode 100644 index 000000000000..4b7b67d03124 --- /dev/null +++ b/src/channels/plugins/read-only.legacy-workspace.test.ts @@ -0,0 +1,97 @@ +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { retainLegacyDefaultAgentId } from "../../config/legacy.default-agent-owner.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js"; +import { resetPluginRuntimeStateForTest } from "../../plugins/runtime.js"; +import { resolveReadOnlyChannelPluginsForConfig } from "./read-only.js"; + +const mocks = vi.hoisted(() => ({ + resolvePluginMetadataSnapshot: vi.fn((_params: { workspaceDir?: string }) => { + const plugins: PluginManifestRecord[] = []; + return { plugins, manifestRegistry: { plugins, diagnostics: [] } }; + }), +})); + +vi.mock("../../plugins/plugin-metadata-snapshot.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolvePluginMetadataSnapshot: mocks.resolvePluginMetadataSnapshot, +})); + +afterEach(() => { + mocks.resolvePluginMetadataSnapshot.mockClear(); + clearPluginMetadataLifecycleCaches(); + resetPluginRuntimeStateForTest(); +}); + +describe("read-only channel plugin legacy workspace discovery", () => { + it("scans the retained compatibility owner's explicit workspace", () => { + const cfg = retainLegacyDefaultAgentId( + { + agents: { + ownership: "explicit", + entries: { + research: {}, + ops: { workspace: "/srv/ops" }, + }, + }, + }, + "ops", + ); + + resolveReadOnlyChannelPluginsForConfig(cfg, { + env: { ...process.env }, + includePersistedAuthState: false, + }); + + expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + workspaceDir: path.resolve("/srv/ops"), + }), + ); + }); + + it("discovers plugins from every explicit agent workspace", () => { + const researchPlugin = { + id: "research-chat-plugin", + name: "Research Chat", + description: "Research workspace channel", + version: "1.0.0", + source: "/srv/research/.openclaw/extensions/research-chat-plugin", + origin: "workspace", + channels: ["research-chat"], + } as PluginManifestRecord; + mocks.resolvePluginMetadataSnapshot.mockImplementation(({ workspaceDir }) => { + const plugins = workspaceDir === path.resolve("/srv/research") ? [researchPlugin] : []; + return { plugins, manifestRegistry: { plugins, diagnostics: [] } }; + }); + const cfg = { + agents: { + ownership: "explicit" as const, + entries: { + ops: { workspace: "/srv/ops" }, + research: { workspace: "/srv/research" }, + }, + }, + channels: { "research-chat": { enabled: true } }, + plugins: { + allow: ["research-chat-plugin"], + entries: { "research-chat-plugin": { enabled: true } }, + }, + }; + + const resolution = resolveReadOnlyChannelPluginsForConfig(cfg, { + env: { ...process.env }, + includePersistedAuthState: false, + }); + + expect(resolution.plugins.map((plugin) => plugin.id)).toContain("research-chat"); + expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: path.resolve("/srv/ops") }), + ); + expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: path.resolve("/srv/research") }), + ); + }); +}); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 0c64aed141cf..e4a5060ac167 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -9,7 +9,8 @@ import { uniqueStrings, } from "@openclaw/normalization-core/string-normalization"; import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { tryResolveConfiguredAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { resolveConfigWidePluginManifestRegistry } from "../../config/io.plugin-metadata.js"; import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -640,7 +641,7 @@ function resolveReadOnlyWorkspaceDir( cfg: OpenClawConfig, options: ReadOnlyChannelPluginOptions, ): string | undefined { - return options.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + return options.workspaceDir ?? tryResolveConfiguredAgentWorkspaceDir(cfg, options.env); } function listExternalChannelManifestRecords( @@ -717,13 +718,19 @@ export function resolveReadOnlyChannelPluginsForConfig( } const manifestRecords = options.metadataSnapshot?.plugins ?? - resolvePluginMetadataSnapshot({ - config: cfg, - stateDir: options.stateDir, - workspaceDir, - env, - allowWorkspaceScopedCurrent: true, - }).plugins; + (options.workspaceDir !== undefined + ? resolvePluginMetadataSnapshot({ + config: cfg, + stateDir: options.stateDir, + workspaceDir: options.workspaceDir, + env, + allowWorkspaceScopedCurrent: true, + }).plugins + : resolveConfigWidePluginManifestRegistry({ + config: cfg, + stateDir: options.stateDir, + env, + }).plugins); const bundledManifestRecords = listBundledChannelManifestRecords(manifestRecords); const externalManifestRecords = listExternalChannelManifestRecords(manifestRecords); const activationSourceConfig = options.activationSourceConfig ?? cfg; diff --git a/src/cli/channels-list-catalog-row-discovery.test.ts b/src/cli/channels-list-catalog-row-discovery.test.ts index 7f63879bba97..f3a472189e48 100644 --- a/src/cli/channels-list-catalog-row-discovery.test.ts +++ b/src/cli/channels-list-catalog-row-discovery.test.ts @@ -48,6 +48,7 @@ vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({ vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: vi.fn(() => undefined), resolveDefaultAgentId: vi.fn(() => "main"), + tryResolveConfiguredAgentWorkspaceDir: vi.fn(() => undefined), })); vi.mock("../runtime.js", () => ({ diff --git a/src/cli/channels-list-route-cold-imports.test.ts b/src/cli/channels-list-route-cold-imports.test.ts index a0266ff592cc..76496785cc6c 100644 --- a/src/cli/channels-list-route-cold-imports.test.ts +++ b/src/cli/channels-list-route-cold-imports.test.ts @@ -34,6 +34,7 @@ vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({ vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: vi.fn(() => undefined), resolveDefaultAgentId: vi.fn(() => "main"), + tryResolveConfiguredAgentWorkspaceDir: vi.fn(() => undefined), })); vi.mock("../runtime.js", () => ({ diff --git a/src/cli/config-model-validation.test.ts b/src/cli/config-model-validation.test.ts index be81f5159df7..1dfbb928b07a 100644 --- a/src/cli/config-model-validation.test.ts +++ b/src/cli/config-model-validation.test.ts @@ -93,7 +93,7 @@ describe("config model validation", () => { fallbacks: ["anthropic/claude-sonnet-4-6"], }, }, - entries: { main: { default: true }, ops: {} }, + entries: { main: {}, ops: {} }, }, }, touchedPaths: [["agents", "defaults", "model"]], @@ -107,9 +107,9 @@ describe("config model validation", () => { agentId: call.ref.agentId, })), ).toEqual([ - { path: "agents.defaults.model.primary", agentId: undefined }, + { path: "agents.defaults.model.primary", agentId: "main" }, { path: "agents.defaults.model.primary", agentId: "ops" }, - { path: "agents.defaults.model.fallbacks.0", agentId: undefined }, + { path: "agents.defaults.model.fallbacks.0", agentId: "main" }, { path: "agents.defaults.model.fallbacks.0", agentId: "ops" }, ]); }); @@ -122,7 +122,7 @@ describe("config model validation", () => { agents: { defaults: { model: { primary: "provider-a/default" } }, entries: { - main: { default: true, model: "provider-b/override" }, + main: { model: "provider-b/override" }, ops: {}, }, }, @@ -150,7 +150,7 @@ describe("config model validation", () => { config: { agents: { defaults: { model: { primary: "openai/gpt-5.4-mini@work" } }, - entries: { main: { default: true } }, + entries: { main: {} }, }, }, touchedPaths: [["agents", "defaults", "model", "primary"]], @@ -162,7 +162,7 @@ describe("config model validation", () => { config: { agents: { defaults: { model: { primary: "openai/gpt-5.4-mini@work" } }, - entries: { main: { default: true } }, + entries: { main: {} }, }, }, ref: { @@ -530,7 +530,7 @@ describe("config model validation", () => { }, }, entries: { - main: { default: true }, + main: {}, ops: { model: { primary: "provider-c/main", @@ -746,6 +746,7 @@ describe("config model validation", () => { { path: "agents.defaults.model.fallbacks.0", value: "anthropic/claude-sonnet-4-6", + agentId: "main", fallback: true, }, { diff --git a/src/cli/config-model-validation.ts b/src/cli/config-model-validation.ts index 3e71d4c751e7..4f4cd569f233 100644 --- a/src/cli/config-model-validation.ts +++ b/src/cli/config-model-validation.ts @@ -4,8 +4,7 @@ import { listAgentEntriesWithSource, resolveAgentExplicitModelPrimary, resolveAgentModelFallbacksOverride, - resolveDefaultAgentId, - tryResolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, } from "../agents/agent-scope.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; @@ -139,11 +138,6 @@ function collectTouchedTextModelRefs(params: { touchedPaths: readonly (readonly string[])[]; }): TouchedModelRef[] { const listedAgentEntries = listAgentEntriesWithSource(params.config); - const agentEntries = listedAgentEntries.map(({ entry }) => entry); - if (agentEntries.filter((entry) => entry.default === true).length !== 1) { - // Draft validation runs before roster schema errors are reported. - return []; - } const defaultPrimaryPath = ["agents", "defaults", "model", "primary"]; const defaultPrimaryTouched = params.touchedPaths.some( (touchedPath) => @@ -158,7 +152,7 @@ function collectTouchedTextModelRefs(params: { ? new Map(previousRefs.map((ref) => [modelRefComparisonKey(ref), ref])) : undefined; const previousDefaultAgentId = params.previousConfig - ? tryResolveDefaultAgentId(params.previousConfig) + ? tryResolveLegacyCompatibilityAgentId(params.previousConfig) : undefined; const defaultPrimaryProviderChanged = defaultPrimaryTouched && @@ -312,10 +306,7 @@ function expandInheritedDefaultRefs( refs: TouchedModelRef[], ): TouchedModelRef[] { const agentEntries = listAgentEntries(config); - if (agentEntries.filter((entry) => entry.default === true).length !== 1) { - return refs; - } - const defaultAgentId = resolveDefaultAgentId(config); + const defaultAgentId = tryResolveLegacyCompatibilityAgentId(config); const expanded: TouchedModelRef[] = []; const seen = new Set(); const push = (ref: TouchedModelRef) => { @@ -330,19 +321,21 @@ function expandInheritedDefaultRefs( push(ref); continue; } - const defaultAgentConfigured = agentEntries.some( - (entry) => normalizeAgentId(entry.id) === normalizeAgentId(defaultAgentId), - ); - const defaultAgentInherits = - !defaultAgentConfigured || - (ref.fallback - ? resolveAgentModelFallbacksOverride(config, defaultAgentId) === undefined - : resolveAgentExplicitModelPrimary(config, defaultAgentId) === undefined); - if (defaultAgentInherits) { - push(ref); + if (defaultAgentId) { + const defaultAgentConfigured = agentEntries.some( + (entry) => normalizeAgentId(entry.id) === normalizeAgentId(defaultAgentId), + ); + const defaultAgentInherits = + !defaultAgentConfigured || + (ref.fallback + ? resolveAgentModelFallbacksOverride(config, defaultAgentId) === undefined + : resolveAgentExplicitModelPrimary(config, defaultAgentId) === undefined); + if (defaultAgentInherits) { + push(ref); + } } for (const { id: agentId } of agentEntries) { - if (normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId)) { + if (defaultAgentId && normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId)) { continue; } const inherits = ref.fallback @@ -403,7 +396,10 @@ async function createRuntimeModelRefResolver(): Promise if (modelSelection.isCliProvider(resolvedRef.provider, config)) { return undefined; } - const targetAgentId = ref.agentId ?? agentScope.resolveDefaultAgentId(config); + const targetAgentId = + ref.agentId ?? + agentScope.tryResolveLegacyCompatibilityAgentId(config) ?? + agentScope.resolveDefaultAgentId(config); const agentDir = agentScope.resolveAgentDir(config, targetAgentId); const workspaceDir = agentScope.resolveAgentWorkspaceDir(config, targetAgentId); const [modelRuntime, preparedCatalog] = await loadModelModules(); diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index faa71e0abfad..238e30d9ed2e 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -11,6 +11,7 @@ import { hasExecutionIdentityAdmissionSink, } from "../audit/execution-identity-admission.js"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { acquireGatewayLock, type GatewayLockOptions } from "../infra/gateway-lock.js"; import { loggingState } from "../logging/state.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -72,6 +73,7 @@ function mockConfig(storePath: string, overrides?: Partial) { timeoutSeconds: 600, ...overrides?.agents?.defaults, }, + ...(overrides?.agents?.ownership ? { ownership: overrides.agents.ownership } : {}), ...(overrides?.agents?.list ? { list: overrides.agents.list } : {}), }, session: { @@ -101,17 +103,38 @@ async function withTempStore( } } -function mockGatewaySuccessReply(text = "hello") { - callGateway.mockResolvedValue({ +function gatewaySuccessReply(text: string) { + return { runId: "idem-1", status: "ok", - result: { - payloads: [{ text }], - meta: { stub: true }, - }, + result: { payloads: [{ text }], meta: { stub: true } }, + }; +} + +function mockGatewaySuccessReply(text = "hello") { + callGateway.mockResolvedValue(gatewaySuccessReply(text)); +} + +function mockRemoteGatewayRoster(ownership: "sole" | "legacy" | "explicit", agents = ["ops"]) { + callGateway.mockImplementation(async (requestValue) => { + const request = requireRecord(requestValue, "gateway request"); + return request.method === "agents.list" + ? { + defaultId: "ops", + ownership, + selectionRequired: ownership === "explicit", + mainKey: "remote-main", + scope: "per-sender", + agents: agents.map((id) => ({ id })), + } + : gatewaySuccessReply("remote"); }); } +const remoteGatewayConfig = { + gateway: { mode: "remote" as const, remote: { url: "wss://gateway.example" } }, +}; + function mockLocalAgentReply(text = "local") { agentCommand.mockImplementationOnce(async (_opts, rt) => { rt?.log?.(text); @@ -431,18 +454,279 @@ describe("agentCliCommand", () => { vi.stubEnv("OPENCLAW_GATEWAY_URL", gatewayUrl); } await withTempStore(async () => { - mockGatewaySuccessReply(); + mockRemoteGatewayRoster("sole"); await agentCliCommand({ message: "hi", to: "+1555" }, runtime); - expect(callGateway).toHaveBeenCalledTimes(1); - const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "gateway request"); + expect(callGateway).toHaveBeenCalledTimes(2); + const request = requireRecord(callGateway.mock.calls[1]?.[0], "gateway request"); expect(request.clientName).toBe("cli"); expect(request.mode).toBe("cli"); expect(request).not.toHaveProperty("scopes"); }, overrides); }); + it("uses the explicit remote selection and session-id contract", async () => { + mockRemoteGatewayRoster("explicit", ["ops", "research"]); + await withTempStore(async () => { + await expect(agentCliCommand({ message: "hi" }, runtime)).rejects.toMatchObject({ + code: "AGENT_SELECTION_REQUIRED", + agentIds: ["ops", "research"], + }); + expect(callGateway).toHaveBeenCalledOnce(); + }, remoteGatewayConfig); + + mockRemoteGatewayRoster("explicit", ["ops", "research"]); + await withTempStore(async () => { + await agentCliCommand({ message: "hi", sessionId: "remote-session" }, runtime); + const request = requireRecord(callGateway.mock.calls.at(-1)?.[0], "agent request"); + expect(request.params).toMatchObject({ + agentId: undefined, + sessionId: "remote-session", + sessionKey: undefined, + }); + expect(loadAgentSessionModuleMock).not.toHaveBeenCalled(); + }, remoteGatewayConfig); + }); + + it("skips remote roster loading for an explicit agent", async () => { + mockRemoteGatewayRoster("explicit", ["ops", "research"]); + + await withTempStore(async () => { + await agentCliCommand({ message: "hi", agent: "ops" }, runtime); + + const methods = callGateway.mock.calls.map( + ([requestValue]) => requireRecord(requestValue, "gateway request").method, + ); + expect(methods).toEqual(["agent"]); + }, remoteGatewayConfig); + }); + + it.each([ + { ownership: "sole" as const, agents: ["ops"] }, + { ownership: "legacy" as const, agents: ["ops", "research"] }, + ])( + "delegates a remote $ownership sentinel owner to the gateway", + async ({ ownership, agents }) => { + mockRemoteGatewayRoster(ownership, agents); + await withTempStore(async () => { + await agentCliCommand({ message: "hi", sessionKey: "global" }, runtime); + + expect(callGateway).toHaveBeenCalledOnce(); + const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "agent request"); + expect(request.params).toMatchObject({ agentId: undefined, sessionKey: "global" }); + }, remoteGatewayConfig); + }, + ); + + it.each(["global", "work"])( + "delegates remote bare session key %s ownership to the gateway", + async (sessionKey) => { + mockRemoteGatewayRoster("explicit", ["ops", "research"]); + await withTempStore( + async () => { + await agentCliCommand({ message: "hi", sessionKey }, runtime); + + expect(callGateway).toHaveBeenCalledOnce(); + const request = requireRecord( + requireFirstCallArg(callGateway, "gateway"), + "agent request", + ); + expect(request.method).toBe("agent"); + expect(request.params).toMatchObject({ agentId: undefined, sessionKey }); + expect(loadAgentSessionModuleMock).not.toHaveBeenCalled(); + }, + { + ...remoteGatewayConfig, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }, + ); + }, + ); + + it("forwards a remote bare key unchanged with an explicit agent", async () => { + await withTempStore(async () => { + await agentCliCommand({ message: "hi", agent: "ops", sessionKey: "incident-42" }, runtime); + + const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "agent request"); + expect(request.params).toMatchObject({ agentId: "ops", sessionKey: "incident-42" }); + expect(loadAgentSessionModuleMock).not.toHaveBeenCalled(); + }, remoteGatewayConfig); + }); + + it("still resolves a remote recipient through the remote roster", async () => { + mockRemoteGatewayRoster("explicit", ["ops", "research"]); + await withTempStore(async () => { + await expect(agentCliCommand({ message: "hi", to: "+1555" }, runtime)).rejects.toMatchObject({ + code: "AGENT_SELECTION_REQUIRED", + }); + expect(callGateway).toHaveBeenCalledOnce(); + expect(requireRecord(requireFirstCallArg(callGateway, "gateway"), "request").method).toBe( + "agents.list", + ); + }, remoteGatewayConfig); + }); + + it("dispatches a bare retained-owner turn to the scoped main session", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi" }, runtime); + + const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "agent request"); + expect(request.params).toMatchObject({ + agentId: undefined, + sessionKey: "agent:ops:work", + }); + }, + { + agents: { list: [{ id: "ops", default: true }, { id: "research" }] }, + session: { mainKey: "work", scope: "per-sender" }, + }, + ); + }); + + it("dispatches a bare retained-owner turn to the local gateway global session", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi" }, runtime); + + const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "agent request"); + expect(request.params).toMatchObject({ + agentId: undefined, + sessionKey: undefined, + }); + }, + { + agents: { list: [{ id: "ops", default: true }, { id: "research" }] }, + session: { scope: "global" }, + }, + ); + }); + + it("dispatches an implicit global turn through its persisted fixed-store owner", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi" }, runtime); + + const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "agent request"); + expect(request.params).toMatchObject({ + agentId: undefined, + sessionKey: "global", + }); + }, + { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + list: [{ id: "ops" }, { id: "research" }], + }, + session: { scope: "global" }, + }, + ); + }); + + it("dispatches a retained-owner global session through --local", async () => { + await withTempStore( + async () => { + const cfg = retainLegacyDefaultAgentId( + { + ...loadRuntimeConfig(), + agents: { + ...loadRuntimeConfig().agents, + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }, + "ops", + ); + loadRuntimeConfig.mockReturnValue(cfg); + mockLocalAgentReply(); + + await agentCliCommand({ message: "hi", local: true, sessionKey: "global" }, runtime); + + expect(agentCommand).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "ops", sessionKey: "global" }), + runtime, + undefined, + ); + }, + { + agents: { list: [{ id: "ops" }, { id: "research" }] }, + session: { scope: "global" }, + }, + ); + }); + + it("uses the local global session through --local despite remote gateway settings", async () => { + await withTempStore( + async () => { + vi.stubEnv("OPENCLAW_GATEWAY_URL", "wss://gateway.example.test"); + const cfg = retainLegacyDefaultAgentId( + { + ...loadRuntimeConfig(), + gateway: { mode: "remote" }, + agents: { + ...loadRuntimeConfig().agents, + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }, + "ops", + ); + loadRuntimeConfig.mockReturnValue(cfg); + mockLocalAgentReply(); + + await agentCliCommand({ message: "hi", local: true }, runtime); + + expect(agentCommand).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "ops" }), + runtime, + undefined, + ); + expect(requireFirstCallArg(agentCommand, "embedded agent")).not.toHaveProperty( + "sessionKey", + ); + }, + { + agents: { list: [{ id: "ops" }, { id: "research" }] }, + session: { scope: "global" }, + }, + ); + }); + + it("keeps an ownerless explicit global session fail-closed through --local", async () => { + await withTempStore( + async () => { + loadRuntimeConfig.mockReturnValue({ + ...loadRuntimeConfig(), + agents: { + ...loadRuntimeConfig().agents, + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }); + + await expect( + agentCliCommand({ message: "hi", local: true, sessionKey: "global" }, runtime), + ).rejects.toMatchObject({ code: "AGENT_SELECTION_REQUIRED" }); + expect(agentCommand).not.toHaveBeenCalled(); + }, + { + agents: { list: [{ id: "ops" }, { id: "research" }] }, + session: { scope: "global" }, + }, + ); + }); + it("reads a UTF-8 message file for gateway dispatch", async () => { await withTempStore(async ({ dir }) => { const messageFile = path.join(dir, "task.md"); @@ -1002,6 +1286,30 @@ describe("agentCliCommand", () => { ); }); + it("dispatches a restart-shaped fixed-store sentinel under its persisted owner", async () => { + await withTempStore( + async () => { + mockLocalAgentReply(); + + await agentCliCommand({ message: "hi", local: true, sessionKey: "global" }, runtime); + + expect(agentCommand).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "ops", sessionKey: "global" }), + runtime, + undefined, + ); + }, + { + session: { store: "/tmp/restart-shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + list: [{ id: "ops" }, { id: "research" }], + }, + }, + ); + }); + it("preserves unscoped unknown session keys when no agent is requested", async () => { await withTempStore( async () => { @@ -1097,6 +1405,7 @@ describe("agentCliCommand", () => { status: "accepted", runId: "run-signal", sessionKey: "agent:main:explicit:reset-run", + agentId: "main", }); return await new Promise((_, reject) => { signal?.addEventListener( @@ -1135,6 +1444,7 @@ describe("agentCliCommand", () => { expect(sameConnectionAbort?.params).toEqual({ sessionKey: "agent:main:explicit:reset-run", runId: "run-signal", + agentId: "main", }); }); }, diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 53eb6d7f5cf8..36d8f471e22d 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -10,7 +10,12 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../packages/gateway-protocol/src/client-info.js"; -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import type { AgentsListResult } from "../../packages/gateway-protocol/src/index.js"; +import { + AgentSelectionRequiredError, + listAgentIds, + tryResolveSoleAgentId, +} from "../agents/agent-scope-config.js"; import { measureAgentStartup } from "../agents/startup-timing.js"; import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -20,6 +25,13 @@ import { readGatewayDispatchConfig, readGatewayDispatchConfigWithShellEnvFallback, } from "../config/gateway-dispatch-config.js"; +import { + inheritLegacyDefaultAgentId, + tryGetLegacyDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, +} from "../config/legacy.default-agent-owner.js"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway, @@ -30,7 +42,7 @@ import { type GatewayRequestFunction, } from "../gateway/call.js"; import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js"; -import { ADMIN_SCOPE } from "../gateway/operator-scopes.js"; +import { ADMIN_SCOPE, READ_SCOPE } from "../gateway/operator-scopes.js"; import { createAbortError } from "../infra/abort-signal.js"; import { readFileDescriptorBounded } from "../infra/boundary-file-read.js"; import { @@ -45,6 +57,7 @@ import { type OneShotDiagnosticsHandle, } from "../plugins/one-shot-diagnostics.js"; import { + buildAgentMainSessionKey, classifySessionKeyShape, isUnscopedSessionKeySentinel, normalizeAgentId, @@ -99,8 +112,19 @@ type AgentCliOpts = { extraSystemPrompt?: string; local?: boolean; }; +type RemoteGatewayRoster = { + agentIds: string[]; + defaultId: string; + ownership?: AgentsListResult["ownership"]; + selectionRequired: boolean; + mainKey: string; + scope: AgentsListResult["scope"]; +}; type AgentDispatchOpts = Omit & { message: string; + gatewayDispatchConfig?: OpenClawConfig; + remoteGatewayRoster?: RemoteGatewayRoster; + localGatewayCompatibilityAgentId?: string; }; type AgentCliSignal = EmbeddedStateSignal; @@ -118,6 +142,38 @@ type AgentGatewayCallIdentity = Pick< type AgentSessionModule = typeof import("./agent/session.runtime.js"); type AgentSessionModuleLoader = () => Promise; +function usesImplicitRemoteCompatibilityDefault(roster: RemoteGatewayRoster): boolean { + return ( + !roster.selectionRequired && + (roster.ownership === "legacy" || (!roster.ownership && roster.agentIds.length > 1)) + ); +} + +function resolveImplicitCliAgentId(cfg: OpenClawConfig, remote?: RemoteGatewayRoster): string { + const migratedConfig = remote + ? cfg + : (migratePersistedImplicitMainRoster(cfg).config as OpenClawConfig); + const selectionCfg = remote + ? cfg + : inheritLegacyDefaultAgentId( + tryGetLegacyDefaultAgentId(cfg) ? cfg : migratedConfig, + migratedConfig, + ); + const selected = remote + ? remote.selectionRequired + ? undefined + : remote.defaultId + : tryResolveLegacyCompatibilityAgentId(selectionCfg); + if (selected) { + return selected; + } + const agentIds = remote?.agentIds ?? listAgentIds(selectionCfg); + throw new AgentSelectionRequiredError(agentIds, { + surface: "agent turn", + hint: `Pass --agent to select one of: ${agentIds.join(", ")}.`, + }); +} + const GATEWAY_ABORT_RETRY_DELAYS_MS = [50, 150, 300, 600] as const; const GATEWAY_ABORT_REQUEST_TIMEOUT_MS = 2_000; const AGENT_CLI_SIGNAL_EXIT_CODES: Record = { @@ -223,6 +279,51 @@ async function loadRuntimeConfig(): Promise { return getRuntimeConfig(); } +function usesRemoteGateway(cfg: OpenClawConfig): boolean { + return Boolean( + cfg.gateway?.mode === "remote" || normalizeOptionalString(process.env.OPENCLAW_GATEWAY_URL), + ); +} + +async function loadRemoteGatewayRoster(cfg: OpenClawConfig): Promise { + const result = await callGateway({ + method: "agents.list", + params: {}, + config: cfg, + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + scopes: [READ_SCOPE], + }); + const agentIds = result.agents + .filter((entry) => entry.kind !== "system") + .map((entry) => normalizeAgentId(entry.id)); + return { + agentIds, + defaultId: normalizeAgentId(result.defaultId), + ownership: result.ownership, + selectionRequired: result.selectionRequired ?? result.ownership === "explicit", + mainKey: result.mainKey, + scope: result.scope, + }; +} + +async function loadRemoteGatewayRosterWithShellEnvFallback( + cfg: OpenClawConfig, +): Promise<{ config: OpenClawConfig; roster: RemoteGatewayRoster }> { + try { + return { config: cfg, roster: await loadRemoteGatewayRoster(cfg) }; + } catch (error) { + if (!shouldRetryGatewayDispatchWithShellEnvFallback(error)) { + throw error; + } + const fallbackConfig = await readGatewayDispatchConfigWithShellEnvFallback(); + return { + config: fallbackConfig, + roster: await loadRemoteGatewayRoster(fallbackConfig), + }; + } +} + function formatActiveGatewayLocalRefusal(identity: GatewayLockIdentity): string { return `A Gateway is running for this state directory (pid ${identity.pid}, port ${identity.port}). Run without --local to use it, or stop the Gateway first (${formatCliCommand("openclaw gateway stop")}).`; } @@ -457,36 +558,137 @@ function validateExplicitSessionKeyForDispatch( async function normalizeSessionKeyOptsForDispatch( opts: AgentDispatchOpts, ): Promise { + let normalizedOpts = opts; const rawSessionKey = opts.sessionKey?.trim(); const rawTo = opts.to?.trim(); if (!rawSessionKey && !opts.sessionId?.trim() && classifySessionKeyShape(rawTo) === "agent") { - return { - ...opts, - to: undefined, - sessionKey: rawTo, - }; + return normalizeSessionKeyOptsForDispatch({ ...opts, to: undefined, sessionKey: rawTo }); } const isLegacySessionKey = rawSessionKey && classifySessionKeyShape(rawSessionKey) === "legacy_or_alias"; - const agentIdRaw = opts.agent?.trim(); + const explicitAgentIdRaw = opts.agent?.trim(); + let agentIdRaw = explicitAgentIdRaw; + const hasExplicitSessionTarget = + Boolean(opts.sessionId?.trim()) || + [rawSessionKey, rawTo].some((value) => classifySessionKeyShape(value) === "agent"); + let selectionCfg: OpenClawConfig | undefined; + let remoteGatewayRoster: RemoteGatewayRoster | undefined; + if (opts.local !== true) { + const cfg = readGatewayDispatchConfig(); + normalizedOpts = { ...normalizedOpts, gatewayDispatchConfig: cfg }; + selectionCfg = cfg; + if ( + rawSessionKey && + usesRemoteGateway(cfg) && + classifySessionKeyShape(rawSessionKey) !== "agent" + ) { + // The remote gateway owns its roster and durable session-store metadata. Forward bare keys + // unchanged, even with an explicit agent, so stale local state cannot rewrite the target. + return normalizedOpts; + } + } + if (!agentIdRaw && !hasExplicitSessionTarget && !(opts.local === true && rawTo)) { + let cfg = + opts.local === true + ? await loadRuntimeConfig() + : (selectionCfg ?? readGatewayDispatchConfig()); + if (opts.local !== true && usesRemoteGateway(cfg)) { + const loaded = await loadRemoteGatewayRosterWithShellEnvFallback(cfg); + cfg = loaded.config; + remoteGatewayRoster = loaded.roster; + normalizedOpts = { ...normalizedOpts, gatewayDispatchConfig: cfg, remoteGatewayRoster }; + } + selectionCfg = cfg; + const effectiveOwnerSessionKey = + rawSessionKey ?? (cfg.session?.scope === "global" ? "global" : undefined); + const persistedKeyOwner = remoteGatewayRoster + ? ({ kind: "none" } as const) + : resolvePersistedSessionStoreOwnerForKey(cfg, effectiveOwnerSessionKey); + if (persistedKeyOwner.kind === "retired") { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: `session key "${rawSessionKey}"`, + hint: `The shared fixed-store row belongs to retired agent "${persistedKeyOwner.agentId}".`, + }); + } + if ( + persistedKeyOwner.kind === "configured" && + rawSessionKey === undefined && + effectiveOwnerSessionKey === "global" + ) { + normalizedOpts = { ...normalizedOpts, sessionKey: "global" }; + } + const selectedAgentId = + persistedKeyOwner.kind === "configured" + ? persistedKeyOwner.agentId + : resolveImplicitCliAgentId(cfg, remoteGatewayRoster); + const implicitSoleAgent = remoteGatewayRoster + ? remoteGatewayRoster.ownership === "sole" || + (!remoteGatewayRoster.ownership && remoteGatewayRoster.agentIds.length === 1) + : tryResolveSoleAgentId(cfg) === selectedAgentId; + const implicitCompatibilityDefault = remoteGatewayRoster + ? usesImplicitRemoteCompatibilityDefault(remoteGatewayRoster) + : !implicitSoleAgent; + const implicitGlobalSession = + !explicitAgentIdRaw && + rawSessionKey === undefined && + (remoteGatewayRoster + ? remoteGatewayRoster.scope === "global" + : (opts.local === true || !usesRemoteGateway(cfg)) && cfg.session?.scope === "global"); + const unscopedSession = isUnscopedSessionKeySentinel(rawSessionKey) || implicitGlobalSession; + const implicitAgentSelection = implicitSoleAgent || implicitCompatibilityDefault; + agentIdRaw = implicitAgentSelection && unscopedSession ? undefined : selectedAgentId; + if (!remoteGatewayRoster && implicitCompatibilityDefault) { + // The retained owner lives on the migrated config sidecar, so carry it past + // normalization rather than re-deriving ownership from the raw dispatch config. + normalizedOpts = { + ...normalizedOpts, + localGatewayCompatibilityAgentId: selectedAgentId, + }; + } + if (agentIdRaw && implicitCompatibilityDefault && !rawSessionKey && !rawTo) { + // Legacy multi-agent owners stay implicit, but a bare per-sender turn still + // needs their canonical main session to reach gateway dispatch. + normalizedOpts = { + ...normalizedOpts, + sessionKey: buildAgentMainSessionKey({ + agentId: selectedAgentId, + mainKey: remoteGatewayRoster?.mainKey ?? cfg.session?.mainKey, + }), + }; + } else if (agentIdRaw && !implicitCompatibilityDefault) { + normalizedOpts = { + ...normalizedOpts, + agent: selectedAgentId, + }; + } + } const shouldScopeDefaultAgentKey = isLegacySessionKey && !agentIdRaw && !isUnscopedSessionKeySentinel(rawSessionKey); const cfg = isLegacySessionKey && (agentIdRaw || shouldScopeDefaultAgentKey) - ? opts.local === true + ? normalizedOpts.local === true ? await loadRuntimeConfig() - : readGatewayDispatchConfig() + : (selectionCfg ?? readGatewayDispatchConfig()) : undefined; + const persistedBareOwner = + cfg && rawSessionKey && isLegacySessionKey && !isUnscopedSessionKeySentinel(rawSessionKey) + ? resolvePersistedSessionStoreOwnerForKey(cfg, rawSessionKey) + : undefined; + if (persistedBareOwner?.kind === "configured") { + // Fixed-store rows keep their durable bare key. The selected owner travels separately so + // request-time resolution can validate it without changing the storage identity. + return normalizedOpts; + } const sessionKey = scopeLegacySessionKeyToAgent({ - agentId: agentIdRaw ?? (shouldScopeDefaultAgentKey ? resolveDefaultAgentId(cfg!) : undefined), - sessionKey: opts.sessionKey, - mainKey: cfg?.session?.mainKey, + agentId: agentIdRaw, + sessionKey: normalizedOpts.sessionKey, + mainKey: remoteGatewayRoster?.mainKey ?? cfg?.session?.mainKey, }); - if (sessionKey === opts.sessionKey) { - return opts; + if (sessionKey === normalizedOpts.sessionKey) { + return normalizedOpts; } return { - ...opts, + ...normalizedOpts, sessionKey, }; } @@ -498,12 +700,14 @@ function isAbortError(err: unknown): boolean { function readAcceptedRunContext(payload: unknown): { runId?: string; sessionKey?: string; + agentId?: string; } { if (!payload || typeof payload !== "object") { return {}; } const runId = (payload as { runId?: unknown }).runId; const sessionKey = (payload as { sessionKey?: unknown }).sessionKey; + const agentId = (payload as { agentId?: unknown }).agentId; const status = (payload as { status?: unknown }).status; if (status !== "accepted") { return {}; @@ -511,6 +715,7 @@ function readAcceptedRunContext(payload: unknown): { return { runId: typeof runId === "string" && runId.trim() ? runId.trim() : undefined, sessionKey: typeof sessionKey === "string" && sessionKey.trim() ? sessionKey.trim() : undefined, + agentId: typeof agentId === "string" && agentId.trim() ? agentId.trim() : undefined, }; } @@ -580,6 +785,7 @@ function isConfirmedChatAbortResponseForRun(value: unknown, runId: string): bool async function abortAcceptedGatewayAgentRunWithRequest(params: { runId: string | undefined; sessionKey: string | undefined; + agentId?: string; signal: AgentCliSignal | undefined; runtime: RuntimeEnv; request: GatewayRequestFunction; @@ -594,6 +800,7 @@ async function abortAcceptedGatewayAgentRunWithRequest(params: { { sessionKey: params.sessionKey, runId: params.runId, + ...(params.agentId ? { agentId: params.agentId } : {}), }, { timeoutMs: GATEWAY_ABORT_REQUEST_TIMEOUT_MS }, ); @@ -621,6 +828,7 @@ async function abortAcceptedGatewayAgentRunWithRequest(params: { async function abortAcceptedGatewayAgentRunWithGatewayCall(params: { runId: string | undefined; sessionKey: string | undefined; + agentId?: string; signal: AgentCliSignal | undefined; runtime: RuntimeEnv; gatewayIdentity: AgentGatewayCallIdentity; @@ -645,6 +853,7 @@ async function abortAcceptedGatewayAgentRunWithGatewayCall(params: { const aborted = await abortAcceptedGatewayAgentRunWithRequest({ runId: params.runId, sessionKey: params.sessionKey, + agentId: params.agentId, signal: params.signal, runtime: params.runtime, request, @@ -660,6 +869,7 @@ async function abortAcceptedGatewayAgentRunWithGatewayCall(params: { async function abortAcceptedGatewayAgentRunOnActiveConnection(params: { runId: string | undefined; sessionKey: string | undefined; + agentId?: string; signal: AgentCliSignal | undefined; runtime: RuntimeEnv; request: GatewayRequestFunction; @@ -670,6 +880,7 @@ async function abortAcceptedGatewayAgentRunOnActiveConnection(params: { const aborted = await abortAcceptedGatewayAgentRunWithRequest({ runId: params.runId, sessionKey: params.sessionKey, + agentId: params.agentId, signal: params.signal, runtime: params.runtime, request: params.request, @@ -741,7 +952,29 @@ async function agentViaGatewayCommand( ) { const body = opts.message; const explicitSessionKey = opts.sessionKey?.trim(); - if (!opts.to && !opts.sessionId && !opts.agent && !explicitSessionKey) { + let cfg: OpenClawConfig = opts.gatewayDispatchConfig ?? readGatewayDispatchConfig(); + const remoteGateway = usesRemoteGateway(cfg); + const remoteRosterIsSole = + opts.remoteGatewayRoster?.ownership === "sole" || + (!opts.remoteGatewayRoster?.ownership && opts.remoteGatewayRoster?.agentIds.length === 1); + const remoteRosterUsesCompatibilityDefault = Boolean( + opts.remoteGatewayRoster && usesImplicitRemoteCompatibilityDefault(opts.remoteGatewayRoster), + ); + const hasImplicitGlobalTarget = + (opts.remoteGatewayRoster?.scope ?? cfg.session?.scope) === "global" && + (opts.remoteGatewayRoster + ? !opts.remoteGatewayRoster.selectionRequired && + (remoteRosterIsSole || remoteRosterUsesCompatibilityDefault) + : !remoteGateway && + (tryResolveSoleAgentId(cfg) !== undefined || + opts.localGatewayCompatibilityAgentId !== undefined)); + if ( + !opts.to && + !opts.sessionId && + !opts.agent && + !explicitSessionKey && + !hasImplicitGlobalTarget + ) { throw new Error( `No target session selected. Use --agent , --session-key , --session-id , or --to . Run ${formatCliCommand("openclaw agents list")} to see agents.`, ); @@ -749,12 +982,12 @@ async function agentViaGatewayCommand( // Scoped gateway turns need core agent/session/gateway fields only. The // running gateway owns plugin validation and plugin metadata freshness. - let cfg: OpenClawConfig = readGatewayDispatchConfig(); const agentIdRaw = opts.agent?.trim(); const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined; if (agentId) { - const knownAgents = listAgentIds(cfg); - if (!knownAgents.includes(agentId)) { + const knownAgents = + opts.remoteGatewayRoster?.agentIds ?? (remoteGateway ? undefined : listAgentIds(cfg)); + if (knownAgents && !knownAgents.includes(agentId)) { throw new Error( `Unknown agent id "${agentIdRaw}". Use "${formatCliCommand("openclaw agents list")}" to see configured agents.`, ); @@ -772,30 +1005,44 @@ async function agentViaGatewayCommand( opts.to?.trim() && classifySessionKeyShape(opts.to) !== "agent", ); + const deferRemoteSessionId = Boolean( + remoteGateway && opts.sessionId?.trim() && !explicitSessionKey, + ); + const deferRemoteBareSessionKey = Boolean( + remoteGateway && explicitSessionKey && classifySessionKeyShape(explicitSessionKey) !== "agent", + ); + const deferAgentDefaultSession = Boolean( + agentId && !explicitSessionKey && !opts.sessionId?.trim() && !opts.to?.trim(), + ); + const preserveImplicitCompatibilitySession = + (remoteRosterIsSole || remoteRosterUsesCompatibilityDefault) && + !agentId && + (isUnscopedSessionKeySentinel(explicitSessionKey) || hasImplicitGlobalTarget); - const sessionKey = deferExplicitRecipientSession - ? undefined - : classifySessionKeyShape(explicitSessionKey) === "agent" + const sessionKey = + preserveImplicitCompatibilitySession || deferRemoteBareSessionKey ? explicitSessionKey - : explicitSessionKey || opts.to || opts.sessionId - ? (await loadAgentSessionModule()).resolveSessionKeyForRequest({ - cfg, - agentId, - to: opts.to, - sessionId: opts.sessionId, - sessionKey: explicitSessionKey, - }).sessionKey - : undefined; - const abortSessionKey = deferExplicitRecipientSession - ? (await loadAgentSessionModule()).resolveSessionKeyForRequest({ cfg, agentId }).sessionKey - : sessionKey; + : deferAgentDefaultSession || deferExplicitRecipientSession || deferRemoteSessionId + ? undefined + : classifySessionKeyShape(explicitSessionKey) === "agent" + ? explicitSessionKey + : (await loadAgentSessionModule()).resolveSessionKeyForRequest({ + cfg, + agentId, + to: opts.to, + sessionId: opts.sessionId, + sessionKey: explicitSessionKey, + }).sessionKey; + const abortSessionKey = deferRemoteSessionId + ? undefined + : deferExplicitRecipientSession + ? (await loadAgentSessionModule()).resolveSessionKeyForRequest({ cfg, agentId }).sessionKey + : sessionKey; const idempotencyKey = normalizeOptionalString(opts.runId) || randomIdempotencyKey(); const modelOverride = normalizeOptionalString(opts.model); const hasModelOverride = Boolean(modelOverride); const needsAdminGatewayIdentity = hasModelOverride || isSessionResetCommand(body); - const hasGatewayUrlOverride = Boolean(normalizeOptionalString(process.env.OPENCLAW_GATEWAY_URL)); - const usesRemoteGateway = cfg.gateway?.mode === "remote" || hasGatewayUrlOverride; const gatewayIdentity: AgentGatewayCallIdentity = needsAdminGatewayIdentity ? { clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, @@ -807,11 +1054,12 @@ async function agentViaGatewayCommand( mode: GATEWAY_CLIENT_MODES.CLI, // The local CLI is the Gateway owner. Keep owner-only run tools available; // remote clients retain the agent method's least-privilege scope. - ...(usesRemoteGateway ? {} : { scopes: [ADMIN_SCOPE] }), + ...(remoteGateway ? {} : { scopes: [ADMIN_SCOPE] }), }; let acceptedRunId: string | undefined = idempotencyKey; let acceptedSessionKey: string | undefined = abortSessionKey; + let acceptedAgentId: string | undefined; let acceptedGatewayRun = false; let activeConnectionAbortAttempted = false; let activeConnectionAbortSucceeded = false; @@ -855,12 +1103,14 @@ async function agentViaGatewayCommand( const accepted = readAcceptedRunContext(payload); acceptedRunId = accepted.runId ?? acceptedRunId; acceptedSessionKey = accepted.sessionKey ?? acceptedSessionKey; + acceptedAgentId = accepted.agentId; }, onSignalAbort: async (request) => { activeConnectionAbortAttempted = true; activeConnectionAbortSucceeded = await abortAcceptedGatewayAgentRunOnActiveConnection({ runId: acceptedRunId, sessionKey: acceptedSessionKey, + agentId: acceptedAgentId, signal: signalBridge.getReceivedSignal(), runtime, request, @@ -893,6 +1143,7 @@ async function agentViaGatewayCommand( await abortAcceptedGatewayAgentRunWithGatewayCall({ runId: acceptedRunId, sessionKey: acceptedSessionKey, + agentId: acceptedAgentId, signal: signalBridge.getReceivedSignal(), runtime, gatewayIdentity, @@ -1003,7 +1254,8 @@ export async function agentCliCommand( result = await runEmbeddedAgentCommand( { ...gatewayDispatchOpts, - agentId: gatewayDispatchOpts.agent, + agentId: + gatewayDispatchOpts.agent ?? gatewayDispatchOpts.localGatewayCompatibilityAgentId, replyAccountId: gatewayDispatchOpts.replyAccount, cleanupBundleMcpOnRunEnd: true, cleanupCliLiveSessionOnRunEnd: true, diff --git a/src/commands/agent.session.test.ts b/src/commands/agent.session.test.ts index 564585db21ec..5cbd229f72a7 100644 --- a/src/commands/agent.session.test.ts +++ b/src/commands/agent.session.test.ts @@ -112,6 +112,32 @@ describe("agent session resolution", () => { }); }); + it("finds a session-id-only target in another explicit agent store", async () => { + await withTempHome(async (home) => { + const storePattern = path.join(home, "agents", "{agentId}", "sessions", "sessions.json"); + const researchStore = path.join(home, "agents", "research", "sessions", "sessions.json"); + const base = mockConfig(home, storePattern); + const cfg = { + ...base, + agents: { + ...base.agents, + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + await replaceSessionEntry( + { agentId: "research", sessionKey: "main", storePath: researchStore }, + { sessionId: "research-session", updatedAt: Date.now() }, + ); + + const resolution = resolveSession({ cfg, sessionId: "research-session" }); + + expect(resolution.sessionId).toBe("research-session"); + expect(resolution.sessionKey).toBe("agent:research:main"); + expect(resolution.storePath).toBe(researchStore); + }); + }); + it("resolves duplicate cross-agent sessionIds deterministically", async () => { await withTempHome(async (home) => { const storePattern = path.join(home, "agents", "{agentId}", "sessions", "sessions.json"); diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index 9cccedd95400..ae6b3a5a4fa4 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -3,8 +3,9 @@ import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety. import { resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + tryResolveSoleAgentId, } from "../agents/agent-scope.js"; +import { resolveLegacyInheritedAuthAgentId } from "../agents/legacy-inherited-auth-dir.js"; import { prepareLegacyWorkspaceStateReset, removeLegacyWorkspaceStateForReset, @@ -49,6 +50,12 @@ type AgentsDeleteGatewayResult = { failed?: Array<{ path: string; reason: string }>; }; +function logClearedOwnerRefs(runtime: RuntimeEnv, clearedOwnerRefs: readonly string[]): void { + if (clearedOwnerRefs.length > 0) { + runtime.log(`Cleared owner references: ${clearedOwnerRefs.join(", ")}`); + } +} + async function maybeDeleteAgentThroughGateway(params: { agentId: string; deleteFiles: boolean; @@ -111,9 +118,15 @@ export async function agentsDeleteCommand( runtime.exit(1); return; } - if (agentId === resolveDefaultAgentId(cfg)) { + if (agentId === tryResolveSoleAgentId(cfg)) { + runtime.error(`Agent "${agentId}" is the only configured agent and cannot be deleted.`); + runtime.exit(1); + return; + } + if (agentId === normalizeAgentId(resolveLegacyInheritedAuthAgentId(cfg))) { + // H2-2 owns credential relocation; deleting this directory first destroys the shared store. runtime.error( - `Agent "${agentId}" is the default and cannot be deleted. Reassign default first.`, + `Agent "${agentId}" owns inherited credentials through agents.defaults.authInheritance.agentId and cannot be deleted. Relocate those credentials, then re-point or remove that binding before retrying.`, ); runtime.exit(1); return; @@ -159,12 +172,14 @@ export async function agentsDeleteCommand( sessionsDir, removedBindings: gatewayResult.removedBindings, removedAllow: result.removedAllow, + clearedOwnerRefs: result.clearedOwnerRefs.length > 0 ? result.clearedOwnerRefs : undefined, removed: gatewayResult.removed, failed: gatewayResult.failed, transport: "gateway", }); } else { runtime.log(`Deleted agent: ${agentId}`); + logClearedOwnerRefs(runtime, result.clearedOwnerRefs); for (const failure of gatewayResult.failed ?? []) { runtime.error( `Warning: path could not be moved to Trash: ${failure.reason}; remove it manually at ${failure.path}`, @@ -231,8 +246,10 @@ export async function agentsDeleteCommand( sessionsDir, removedBindings: result.removedBindings, removedAllow: result.removedAllow, + clearedOwnerRefs: result.clearedOwnerRefs.length > 0 ? result.clearedOwnerRefs : undefined, }); } else { runtime.log(`Deleted agent: ${agentId}`); + logClearedOwnerRefs(runtime, result.clearedOwnerRefs); } } diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index e7e5a62c0de2..d675fcd3dd21 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -8,12 +8,14 @@ import { listAgentEntries, resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, toAgentEntriesRecord, } from "../agents/agent-scope.js"; import { resolveAgentAvatarUrlFromSource } from "../agents/identity-avatar-file.js"; import type { AgentIdentityFile } from "../agents/identity-file.js"; import { identityHasValues, loadAgentIdentityFromWorkspace } from "../agents/identity-file.js"; +import { pinLegacyInheritedAuthOwnerForRosterTransition } from "../agents/legacy-inherited-auth-dir.js"; +import { pinSurvivorWorkspaceForRosterCollapse } from "../config/agent-workspace-roster-transition.js"; import { listRouteBindings } from "../config/bindings.js"; import type { IdentityConfig } from "../config/types.base.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -69,12 +71,14 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null { /** Build config-derived summaries for text/JSON agent listing. */ export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] { - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const defaultAgentId = tryResolveLegacyCompatibilityAgentId(cfg); const configuredAgents = listAgentEntries(cfg); const orderedIds = configuredAgents.length > 0 ? configuredAgents.map((agent) => normalizeAgentId(agent.id)) - : [defaultAgentId]; + : defaultAgentId + ? [defaultAgentId] + : []; const bindingCounts = new Map(); for (const binding of listRouteBindings(cfg)) { const agentId = normalizeAgentId(binding.agentId); @@ -113,7 +117,7 @@ export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] { agentDir: resolveAgentDir(cfg, id), model: resolveAgentModel(cfg, id), bindings: bindingCounts.get(id) ?? 0, - isDefault: id === defaultAgentId, + isDefault: defaultAgentId !== undefined && id === normalizeAgentId(defaultAgentId), }; if (identityAvatarUrl) { summary.identityAvatarUrl = identityAvatarUrl; @@ -122,7 +126,6 @@ export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] { }); } -/** Add or update one agent entry. The first roster entry becomes the explicit default. */ export function applyAgentConfig( cfg: OpenClawConfig, params: { @@ -138,10 +141,7 @@ export function applyAgentConfig( const name = params.name?.trim(); const list = listAgentEntries(cfg); const index = findAgentEntryIndex(list, agentId); - const base = (index >= 0 ? list[index] : undefined) ?? { - id: agentId, - ...(list.length === 0 ? { default: true } : {}), - }; + const base = (index >= 0 ? list[index] : undefined) ?? { id: agentId }; const mergedIdentity = params.identity ? { ...base.identity, ...params.identity } : undefined; const nextEntry: AgentEntry = { ...base, @@ -162,14 +162,18 @@ export function applyAgentConfig( } else { nextList.push(nextEntry); } - const { list: _legacyList, ...agentsConfig } = cfg.agents ?? {}; - return { + const { list: _legacyList, ownership: _ownership, ...agentsConfig } = cfg.agents ?? {}; + const nextConfig: OpenClawConfig = { ...cfg, agents: { ...agentsConfig, + ...(nextList.length > 1 ? { ownership: "explicit" as const } : {}), entries: toAgentEntriesRecord(nextList), }, }; + return list.length === 1 && nextList.length > 1 + ? pinLegacyInheritedAuthOwnerForRosterTransition(cfg, nextConfig) + : nextConfig; } /** Remove an agent and any config references that route or allow traffic to it. */ @@ -180,8 +184,19 @@ export function pruneAgentConfig( config: OpenClawConfig; removedBindings: number; removedAllow: number; + clearedOwnerRefs: string[]; } { const id = normalizeAgentId(agentId); + const clearedOwnerRefs: string[] = []; + const clearOwnerRef = (value: T | undefined, path: string) => { + const owner = normalizeOptionalString(value?.agentId); + if (!value || !owner || normalizeAgentId(owner) !== id) { + return value; + } + clearedOwnerRefs.push(path); + const { agentId: _agentId, ...rest } = value; + return Object.keys(rest).length > 0 ? (rest as T) : undefined; + }; const agents = listAgentEntries(cfg); const pruneAllowAgents = (allowAgents: string[] | undefined) => allowAgents?.filter((entry) => { @@ -213,7 +228,7 @@ export function pruneAgentConfig( const allow = cfg.tools?.agentToAgent?.allow ?? []; const filteredAllow = allow.filter((entry) => entry !== id); - const nextDefaults = cfg.agents?.defaults?.subagents?.allowAgents + const prunedDefaults = cfg.agents?.defaults?.subagents?.allowAgents ? { ...cfg.agents.defaults, subagents: { @@ -222,11 +237,40 @@ export function pruneAgentConfig( }, } : cfg.agents?.defaults; - const { list: _legacyList, ...agentsConfig } = cfg.agents ?? {}; + const deletedAgentOwnedHeartbeat = + normalizeOptionalString(prunedDefaults?.heartbeat?.agentId) !== undefined && + normalizeAgentId(prunedDefaults?.heartbeat?.agentId) === id; + const nextHeartbeat = + deletedAgentOwnedHeartbeat && nextAgentsList.length > 1 + ? undefined + : clearOwnerRef(prunedDefaults?.heartbeat, "agents.defaults.heartbeat.agentId"); + if (deletedAgentOwnedHeartbeat && nextAgentsList.length > 1) { + clearedOwnerRefs.push("agents.defaults.heartbeat"); + } + const nextDefaults = prunedDefaults + ? { + ...prunedDefaults, + heartbeat: nextHeartbeat, + systemAgent: clearOwnerRef( + prunedDefaults.systemAgent, + "agents.defaults.systemAgent.agentId", + ), + } + : undefined; + const nextTalk = clearOwnerRef(cfg.talk, "talk.agentId"); + const { list: _legacyList, ownership: _ownership, ...agentsConfig } = cfg.agents ?? {}; const nextAgentsConfig = cfg.agents - ? { ...agentsConfig, defaults: nextDefaults, entries: nextAgents } + ? { + ...agentsConfig, + ...(nextAgentsList.length > 1 ? { ownership: "explicit" as const } : {}), + defaults: nextDefaults, + entries: nextAgents, + } : nextAgents - ? { entries: nextAgents } + ? { + ...(nextAgentsList.length > 1 ? { ownership: "explicit" as const } : {}), + entries: nextAgents, + } : undefined; const nextTools = cfg.tools?.agentToAgent ? { @@ -238,14 +282,26 @@ export function pruneAgentConfig( } : cfg.tools; + const preliminaryConfig: OpenClawConfig = { + ...cfg, + agents: nextAgentsConfig, + bindings: filteredBindings.length > 0 ? filteredBindings : undefined, + talk: nextTalk, + tools: nextTools, + }; + const workspacePinnedConfig = pinSurvivorWorkspaceForRosterCollapse( + cfg, + preliminaryConfig, + ).config; + const transitionPinnedConfig = + agents.length > 1 && nextAgentsList.length === 1 + ? pinLegacyInheritedAuthOwnerForRosterTransition(cfg, workspacePinnedConfig) + : workspacePinnedConfig; + return { - config: { - ...cfg, - agents: nextAgentsConfig, - bindings: filteredBindings.length > 0 ? filteredBindings : undefined, - tools: nextTools, - }, + config: transitionPinnedConfig, removedBindings: bindings.length - filteredBindings.length, removedAllow: allow.length - filteredAllow.length, + clearedOwnerRefs, }; } diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index bc9e508a04f7..18100f43b6b4 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -4,9 +4,13 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { listAgentEntries, - resolveDefaultAgentId, toAgentEntriesRecord, + tryResolveSoleAgentId, } from "../agents/agent-scope-config.js"; +import { + retainLegacyDefaultAgentId, + tryGetLegacyDefaultAgentId, +} from "../config/legacy.default-agent-owner.js"; import { resolveSessionStorePathCore } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js"; import { @@ -77,7 +81,12 @@ const runtime = createTestRuntime(); function resolveFixtureStoreAgentId(cfg: OpenClawConfig, deletedAgentId: string): string { const storeConfig = cfg.session?.store; if (typeof storeConfig === "string" && !storeConfig.includes("{agentId}")) { - return resolveDefaultAgentId(cfg); + return ( + tryGetLegacyDefaultAgentId(cfg) ?? + listAgentEntries(cfg).find((entry) => entry.default === true)?.id ?? + tryResolveSoleAgentId(cfg) ?? + deletedAgentId + ); } return deletedAgentId; } @@ -228,6 +237,56 @@ describe("agents delete command", () => { }); }); + it("refuses deleting the auth-inheritance owner until credentials are relocated", async () => { + await withStateDirEnv("openclaw-agents-delete-auth-owner-", async ({ stateDir }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { authInheritance: { agentId: "ops" } }, + list: [{ id: "ops" }, { id: "research" }], + }, + }; + await arrangeAgentsDeleteTest({ stateDir, cfg, deletedAgentId: "ops", sessions: {} }); + + await agentsDeleteCommand({ id: "ops", force: true }, runtime); + + expect(runtime.error).toHaveBeenCalledWith( + 'Agent "ops" owns inherited credentials through agents.defaults.authInheritance.agentId and cannot be deleted. Relocate those credentials, then re-point or remove that binding before retrying.', + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(gatewayMocks.callGateway).not.toHaveBeenCalled(); + expect(configMocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(fsSafeMocks.movePathToTrash).not.toHaveBeenCalled(); + }); + }); + + it("refuses deleting the retained inherited-auth owner", async () => { + const cfg = retainLegacyDefaultAgentId( + { + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }, + "ops", + ); + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: cfg, + runtimeConfig: cfg, + sourceConfig: cfg, + resolved: cfg, + }); + + await agentsDeleteCommand({ id: "ops", force: true }, runtime); + + expect(runtime.error).toHaveBeenCalledWith( + 'Agent "ops" owns inherited credentials through agents.defaults.authInheritance.agentId and cannot be deleted. Relocate those credentials, then re-point or remove that binding before retrying.', + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(gatewayMocks.callGateway).not.toHaveBeenCalled(); + expect(configMocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + it("warns about Gateway cleanup failures without failing committed deletion", async () => { await withStateDirEnv("openclaw-agents-delete-gateway-warning-", async ({ stateDir }) => { const workspace = path.join(stateDir, "workspace-ops"); @@ -258,11 +317,16 @@ describe("agents delete command", () => { const now = Date.now(); const cfg: OpenClawConfig = { agents: { + defaults: { + heartbeat: { agentId: "ops" }, + systemAgent: { agentId: "ops" }, + }, list: [ { id: "main", workspace: path.join(stateDir, "workspace-shared") }, { id: "ops", workspace: path.join(stateDir, "workspace-shared") }, ], }, + talk: { agentId: "ops", provider: "test-provider" }, } satisfies OpenClawConfig; await arrangeAgentsDeleteTest({ stateDir, @@ -294,6 +358,21 @@ describe("agents delete command", () => { expect(output?.workspaceRetained).toBe(true); expect(output?.workspaceRetainedReason).toBe("shared"); expect(output?.transport).toBeUndefined(); + expect(output?.clearedOwnerRefs).toEqual([ + "agents.defaults.heartbeat.agentId", + "agents.defaults.systemAgent.agentId", + "talk.agentId", + ]); + const replaceConfigFileCalls = configMocks.replaceConfigFile.mock.calls as unknown as Array< + [{ nextConfig: OpenClawConfig }] + >; + expect(replaceConfigFileCalls[0]?.[0].nextConfig.agents?.defaults?.heartbeat).toBeUndefined(); + expect( + replaceConfigFileCalls[0]?.[0].nextConfig.agents?.defaults?.systemAgent, + ).toBeUndefined(); + expect(replaceConfigFileCalls[0]?.[0].nextConfig.talk).toEqual({ + provider: "test-provider", + }); }); }); @@ -399,7 +478,7 @@ describe("agents delete command", () => { }); }); - it("refuses deleting the configured default until it is reassigned", async () => { + it("refuses deleting the sole configured agent", async () => { await withStateDirEnv("openclaw-agents-delete-main-alias-", async ({ stateDir }) => { const now = Date.now(); const cfg: OpenClawConfig = { @@ -424,7 +503,7 @@ describe("agents delete command", () => { await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime); expect(runtime.error).toHaveBeenCalledWith( - 'Agent "ops" is the default and cannot be deleted. Reassign default first.', + 'Agent "ops" is the only configured agent and cannot be deleted.', ); expect(runtime.exit).toHaveBeenCalledWith(1); expectSessionStore(cfg, { diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index 1e3e5b9e3877..ddd1675608cb 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -19,7 +19,7 @@ function requireAgentSummary( } describe("agents helpers", () => { - it("buildAgentSummaries includes default + configured agents", () => { + it("buildAgentSummaries includes configured agents without inventing a fleet default", () => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -29,7 +29,6 @@ describe("agents helpers", () => { entries: { main: {}, work: { - default: true, name: "Work", workspace: "/work-ws", agentDir: "/state/agents/work/agent", @@ -59,7 +58,8 @@ describe("agents helpers", () => { expect(work.workspace).toBe(path.resolve("/work-ws")); expect(work.agentDir).toBe(path.resolve("/state/agents/work/agent")); expect(work.bindings).toBe(1); - expect(work.isDefault).toBe(true); + expect(main.isDefault).toBe(false); + expect(work.isDefault).toBe(false); }); it("buildAgentSummaries renders local avatars and omits absent avatars", () => { @@ -69,7 +69,7 @@ describe("agents helpers", () => { const cfg: OpenClawConfig = { agents: { entries: { - main: { default: true, workspace }, + main: { workspace }, work: { workspace, identity: { avatar: "avatar.png" } }, }, }, @@ -106,10 +106,11 @@ describe("agents helpers", () => { expect(work?.model).toBe("anthropic/claude"); }); - it("applyAgentConfig marks the first roster entry as default", () => { + it("applyAgentConfig leaves a first roster entry trivially sole", () => { const next = applyAgentConfig({}, { agentId: "work", name: "Work" }); - expect(next.agents?.entries).toEqual({ work: { name: "Work", default: true } }); + expect(next.agents?.entries).toEqual({ work: { name: "Work" } }); + expect(requireAgentSummary(buildAgentSummaries(next), "work").isDefault).toBe(true); }); it("applyAgentConfig clears a model override", () => { @@ -117,7 +118,7 @@ describe("agents helpers", () => { agents: { defaults: { model: { primary: "openai/gpt-5.6-luna" } }, entries: { - work: { default: true, workspace: "/work-ws", model: "anthropic/claude" }, + work: { workspace: "/work-ws", model: "anthropic/claude" }, }, }, }; @@ -421,9 +422,13 @@ describe("agents helpers", () => { it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => { const cfg: OpenClawConfig = { agents: { - defaults: { subagents: { allowAgents: ["work", "home"] } }, + defaults: { + heartbeat: { agentId: "work", every: "5m" }, + systemAgent: { agentId: "WORK" }, + subagents: { allowAgents: ["work", "home"] }, + }, entries: { - work: { default: true, workspace: "/work-ws" }, + work: { workspace: "/work-ws" }, home: { workspace: "/home-ws", subagents: { allowAgents: ["WORK", "home"] }, @@ -437,6 +442,7 @@ describe("agents helpers", () => { tools: { agentToAgent: { enabled: true, allow: ["work", "home"] }, }, + talk: { agentId: "work", provider: "test-provider" }, }; const result = pruneAgentConfig(cfg, "work"); @@ -447,8 +453,48 @@ describe("agents helpers", () => { ]); expect(result.config.tools?.agentToAgent?.allow).toEqual(["home"]); expect(result.config.agents?.defaults?.subagents?.allowAgents).toEqual(["home"]); + expect(result.config.agents?.defaults?.heartbeat).toEqual({ every: "5m" }); + expect(result.config.agents?.defaults?.systemAgent).toBeUndefined(); + expect(result.config.talk).toEqual({ provider: "test-provider" }); expect(result.config.agents?.entries?.home?.subagents?.allowAgents).toEqual(["home"]); expect(result.removedBindings).toBe(1); expect(result.removedAllow).toBe(1); + expect(result.clearedOwnerRefs).toEqual([ + "agents.defaults.heartbeat.agentId", + "agents.defaults.systemAgent.agentId", + "talk.agentId", + ]); + }); + + it("pruneAgentConfig pins a survivor's workspace before the roster becomes sole", () => { + const cfg: OpenClawConfig = { + agents: { + ownership: "explicit", + defaults: { workspace: "/srv/fleet" }, + entries: { ops: {}, research: {} }, + }, + }; + + const result = pruneAgentConfig(cfg, "ops"); + + expect(result.config.agents?.entries).toEqual({ + research: { workspace: "/srv/fleet/research" }, + }); + }); + + it("removes ambient heartbeat policy when its owner leaves a surviving fleet", () => { + const result = pruneAgentConfig( + { + agents: { + ownership: "explicit", + defaults: { heartbeat: { agentId: "ops", every: "5m" } }, + entries: { ops: {}, research: {}, writer: {} }, + }, + }, + "ops", + ); + + expect(result.config.agents?.defaults?.heartbeat).toBeUndefined(); + expect(result.clearedOwnerRefs).toContain("agents.defaults.heartbeat"); }); }); diff --git a/src/commands/cleanup-utils.test.ts b/src/commands/cleanup-utils.test.ts index 889513d6f620..80586c139b0d 100644 --- a/src/commands/cleanup-utils.test.ts +++ b/src/commands/cleanup-utils.test.ts @@ -52,7 +52,9 @@ describe("buildCleanupPlan", () => { expect(plan.configInsideState).toBe(true); expect(plan.oauthInsideState).toBe(false); - expect(new Set(plan.workspaceDirs)).toEqual(new Set([defaultWorkspace, opsWorkspace])); + expect(new Set(plan.workspaceDirs)).toEqual( + new Set([path.join(defaultWorkspace, "main"), opsWorkspace]), + ); }); test("includes implicit per-agent workspaces under the state dir", () => { @@ -80,7 +82,7 @@ describe("buildCleanupPlan", () => { }); expect(new Set(plan.workspaceDirs)).toEqual( - new Set([path.join(stateDir, "workspace"), path.join(stateDir, "workspace-work")]), + new Set([path.join(stateDir, "workspace-main"), path.join(stateDir, "workspace-work")]), ); }, ); diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 4cb157bf25c6..1386e765b668 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -60,6 +60,11 @@ vi.mock("../daemon/service-env.js", () => ({ buildServiceEnvironment: mocks.buildServiceEnvironment, })); +vi.mock("../config/io.plugin-metadata.js", () => ({ + resolveConfigWidePluginManifestRegistry: (...args: unknown[]) => + mocks.loadPluginManifestRegistryCore(...args), +})); + vi.mock("../daemon/launchd-exec.js", async (importActual) => ({ ...(await importActual()), execLaunchctl: mocks.execLaunchctl, @@ -228,6 +233,8 @@ async function buildPluginConfigExecSecretRefPlan(home: string) { const pluginRoot = path.join(home, "acme-secrets"); createSecurePluginRoot(pluginRoot); writeSecurePluginEntrypoint(path.join(pluginRoot, "secret-ref-resolver.js")); + const configuredPluginRoot = path.join(home, "acme-plugin"); + createSecurePluginRoot(configuredPluginRoot); mocks.loadPluginManifestRegistryCore.mockReturnValue({ diagnostics: [], plugins: [ @@ -245,6 +252,17 @@ async function buildPluginConfigExecSecretRefPlan(home: string) { }, }, }, + { + id: "acme-plugin", + origin: "global", + rootDir: configuredPluginRoot, + channels: [], + configContracts: { + secretInputs: { + paths: [{ path: "apiKey", expected: "string" }], + }, + }, + }, ], }); mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ @@ -253,6 +271,7 @@ async function buildPluginConfigExecSecretRefPlan(home: string) { { id: "acme-plugin", origin: "global", + rootDir: configuredPluginRoot, channels: [], configContracts: { secretInputs: { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index ce52eca6595b..d703ccc27308 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { resolveConfigWidePluginManifestRegistry } from "../config/io.plugin-metadata.js"; import { collectDurableServiceEnvVarSources } from "../config/state-dir-dotenv.js"; import type { OpenClawConfig } from "../config/types.js"; import { coerceSecretRef, resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; @@ -34,10 +35,7 @@ import { isDangerousHostEnvVarName, normalizeEnvVarKey, } from "../infra/host-env-security.js"; -import { - loadPluginManifestRegistryCore, - type PluginManifestRegistry, -} from "../plugins/manifest-registry.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { isPluginIntegrationSecretProviderConfig, resolveSecretProviderIntegrationConfig, @@ -300,7 +298,7 @@ function collectExecSecretRefPassEnvServiceEnvVars(params: { } const execProvider = isPluginIntegrationSecretProviderConfig(provider) ? (() => { - manifestRegistry ??= loadPluginManifestRegistryCore({ + manifestRegistry ??= resolveConfigWidePluginManifestRegistry({ config: params.config, env: params.env, }); diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index a34958ce2783..b1c8836c2e4f 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -175,6 +175,53 @@ afterEach(async () => { }); describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { + it("migrates the inherited auth owner after it leaves the explicit roster", async () => { + const state = await makeTestState(); + const authPath = await writeLegacyAuthProfilesJson( + state, + { + version: 1, + profiles: { + "openai:retired-owner": { + type: "oauth", + provider: "openai", + access: "retired-owner-access", + refresh: "retired-owner-refresh", + expires: 1_900_000_000_000, + }, + }, + }, + "retired-ops", + ); + + const result = await maybeMigrateAuthProfileJsonStoresToSqlite({ + cfg: { + agents: { + ownership: "explicit", + defaults: { authInheritance: { agentId: "retired-ops" } }, + entries: { research: {}, writer: {} }, + }, + }, + prompter: makePrompter(true), + env: state.env, + now: () => Date.parse("2026-08-09T12:00:00.000Z"), + }); + + expect(result.warnings).toStrictEqual([]); + expect(loadPersistedAuthProfileStore(state.agentDir("retired-ops"))).toMatchObject({ + profiles: { + "openai:retired-owner": { + type: "oauth", + provider: "openai", + access: "retired-owner-access", + refresh: "retired-owner-refresh", + }, + }, + }); + expect(fs.existsSync(authPath)).toBe(false); + expectMigratedArchive(authPath); + }); + it("imports shared oauth.json into shared-main only and records its archive", async () => { const state = await makeTestState(); const oauthPath = await state.writeJson("credentials/oauth.json", { diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 4eb073a808d7..36a8561e9af6 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -7,7 +7,7 @@ import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configu import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; -import { resolveAgentDir, resolveDefaultAgentDir, listAgentIds } from "../agents/agent-scope.js"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; import { clearAuthProfileMigrationDiagnostics, @@ -42,6 +42,7 @@ import type { AuthProfileState, AuthProfileStore, } from "../agents/auth-profiles/types.js"; +import { resolveLegacyInheritedAuthDir } from "../agents/legacy-inherited-auth-dir.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveStateDir } from "../config/paths.js"; @@ -310,7 +311,7 @@ function listAuthProfileRepairCandidates( env: NodeJS.ProcessEnv, ): AuthProfileRepairCandidate[] { const candidates = new Map(); - addCandidate(candidates, resolveDefaultAgentDir(cfg, env)); + addCandidate(candidates, resolveLegacyInheritedAuthDir(cfg, env)); const envAgentDir = readNonEmptyString(env.OPENCLAW_AGENT_DIR) ?? readNonEmptyString(env.PI_CODING_AGENT_DIR); if (envAgentDir) { @@ -485,7 +486,9 @@ function isDefaultAgentCandidate( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, ): boolean { - return path.resolve(candidate.agentDir ?? "") === path.resolve(resolveDefaultAgentDir(cfg, env)); + return ( + path.resolve(candidate.agentDir ?? "") === path.resolve(resolveLegacyInheritedAuthDir(cfg, env)) + ); } function stripImportedConfigAuthProfileCredentials( diff --git a/src/commands/doctor-auth-oauth-sidecar.test.ts b/src/commands/doctor-auth-oauth-sidecar.test.ts index 3e9025aa9680..abbf3ff3bdde 100644 --- a/src/commands/doctor-auth-oauth-sidecar.test.ts +++ b/src/commands/doctor-auth-oauth-sidecar.test.ts @@ -1,4 +1,5 @@ // Doctor OAuth sidecar tests cover encrypted sidecar detection and auth repair guidance. +import { createCipheriv, hash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -52,6 +53,32 @@ function writeLegacyAuthProfiles( return state.writeJson(path.join("agents", agentId, "agent", "auth-profiles.json"), store); } +function encryptLegacySidecarMaterial(params: { + ref: { id: string }; + profileId: string; + provider: string; + seed: string; + material: Record; +}) { + const iv = Buffer.alloc(12, 7); + const cipher = createCipheriv( + "aes-256-gcm", + hash("sha256", `openclaw:auth-profile-oauth:${params.seed}`, "buffer"), + iv, + ); + cipher.setAAD(Buffer.from(`${params.ref.id}\0${params.profileId}\0${params.provider}`, "utf8")); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(params.material), "utf8"), + cipher.final(), + ]); + return { + algorithm: "aes-256-gcm", + iv: iv.toString("base64url"), + tag: cipher.getAuthTag().toString("base64url"), + ciphertext: ciphertext.toString("base64url"), + }; +} + afterEach(async () => { clearRuntimeAuthProfileStoreSnapshots(); for (const state of states.splice(0)) { @@ -172,6 +199,80 @@ describe("maybeRepairLegacyOAuthSidecarProfiles", () => { expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(auth); }); + it("repairs the inherited auth owner after it leaves the explicit roster", async () => { + const seed = "retired-owner-sidecar-seed"; + const state = await makeTestState(seed); + const profileId = "openai-codex:retired-owner"; + const ref = { + source: "openclaw-credentials" as const, + provider: "openai-codex" as const, + id: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }; + const authPath = await writeLegacyAuthProfiles( + state, + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + oauthRef: ref, + }, + }, + }, + "retired-ops", + ); + const sidecarPath = await state.writeJson( + path.join("credentials", "auth-profiles", `${ref.id}.json`), + { + version: 1, + profileId, + provider: "openai-codex", + encrypted: encryptLegacySidecarMaterial({ + ref, + profileId, + provider: "openai-codex", + seed, + material: { + access: "retired-owner-access", + refresh: "retired-owner-refresh", + }, + }), + }, + ); + + const result = await maybeRepairLegacyOAuthSidecarProfiles({ + cfg: { + agents: { + ownership: "explicit", + defaults: { authInheritance: { agentId: "retired-ops" } }, + entries: { research: {}, writer: {} }, + }, + }, + prompter: makePrompter(true), + now: () => 234, + env: state.env, + }); + + expect(result.detected).toEqual([authPath]); + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toEqual([ + `Migrated 1 legacy Codex OAuth profile in ${authPath} to inline credentials (backup: ${authPath}.oauth-ref.234.bak).`, + ]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({ + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "retired-owner-access", + refresh: "retired-owner-refresh", + }, + }, + }); + expect(fs.existsSync(sidecarPath)).toBe(false); + }); + it("leaves undecryptable legacy sidecars in place and reports re-authentication", async () => { const state = await makeTestState("wrong-seed"); const profileId = "openai-codex:default"; diff --git a/src/commands/doctor-auth-oauth-sidecar.ts b/src/commands/doctor-auth-oauth-sidecar.ts index f704d2015c6a..574d77dbe9ad 100644 --- a/src/commands/doctor-auth-oauth-sidecar.ts +++ b/src/commands/doctor-auth-oauth-sidecar.ts @@ -4,9 +4,10 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; -import { listAgentIds, resolveAgentDir, resolveDefaultAgentDir } from "../agents/agent-scope.js"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/runtime-snapshots.js"; +import { resolveLegacyInheritedAuthDir } from "../agents/legacy-inherited-auth-dir.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -84,7 +85,7 @@ function listAuthProfileRepairCandidates( env: NodeJS.ProcessEnv, ): AuthProfileRepairCandidate[] { const candidates = new Map(); - addCandidate(candidates, resolveDefaultAgentDir(cfg, env)); + addCandidate(candidates, resolveLegacyInheritedAuthDir(cfg, env)); const envAgentDir = readNonEmptyString(env.OPENCLAW_AGENT_DIR); if (envAgentDir) { addCandidate(candidates, envAgentDir); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 6ff8d42400a9..0e7fe446bee9 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { writeChannelPairingStateSnapshot } from "../pairing/pairing-store-sqlite.test-helpers.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; @@ -1656,7 +1658,7 @@ describe("doctor config flow", () => { const result = await runDoctorConfigWithInput({ config: { gateway: { auth: { mode: "token", token: 123 } }, - agents: { entries: { openclaw: { default: true } } }, + agents: { entries: { openclaw: {} } }, }, run: loadAndMaybeMigrateDoctorConfig, }); @@ -1670,7 +1672,7 @@ describe("doctor config flow", () => { const result = await runDoctorConfigWithInput({ config: { agents: { - entries: { main: { default: true, workspace: "/tmp/migrated-main" } }, + entries: { main: { workspace: "/tmp/migrated-main" } }, }, gateway: { mode: "local" }, }, @@ -1682,15 +1684,14 @@ describe("doctor config flow", () => { expect(result.shouldWriteConfig).toBe(true); expect(result.explicitSetPaths).toEqual([["agents", "entries"]]); expect(result.cfg.agents?.entries).toEqual({ - main: { default: true, workspace: "/tmp/migrated-main" }, + main: { workspace: "/tmp/migrated-main" }, }); expect(terminalNoteMock).toHaveBeenCalledWith( - "Prepared agents.entries with exactly one explicit default agent for persistence.", + "Prepared the canonical agent roster without retired default markers for persistence.", "Doctor changes", ); - expect(terminalNoteMock).not.toHaveBeenCalledWith( - expect.stringContaining("Persisted agents.entries"), - expect.anything(), + expect(terminalNoteMock.mock.calls.some(([message]) => message.includes("Persisted"))).toBe( + false, ); }); @@ -1707,7 +1708,7 @@ describe("doctor config flow", () => { it("removes a legacy list when Doctor persists keyed roster entries", async () => { const result = await runDoctorConfigWithInput({ - config: { agents: { list: [{ id: "ops", default: true, workspace: "/srv/ops" }] } }, + config: { agents: { entries: { ops: { workspace: "/srv/ops" } } } }, parsedConfig: { agents: { list: [{ id: "ops", default: true, workspace: "/srv/ops" }] }, }, @@ -1717,13 +1718,13 @@ describe("doctor config flow", () => { expect(result.shouldWriteConfig).toBe(true); expect(result.cfg.agents?.entries).toEqual({ - ops: { default: true, workspace: "/srv/ops" }, + ops: { workspace: "/srv/ops" }, }); expect(result.cfg.agents).not.toHaveProperty("list"); }); it("materializes ambient roles for a multi-agent configured default", async () => { - const config = { + const rawConfig = { agents: { entries: { ops: { default: true }, @@ -1733,9 +1734,10 @@ describe("doctor config flow", () => { channels: { telegram: { enabled: true } }, talk: { provider: "test" }, }; + const config = migratePersistedImplicitMainRoster(rawConfig).config as OpenClawConfig; const result = await runDoctorConfigWithInput({ config, - parsedConfig: config, + parsedConfig: rawConfig, repair: true, run: loadAndMaybeMigrateDoctorConfig, }); @@ -1747,25 +1749,26 @@ describe("doctor config flow", () => { expect(result.cfg.agents?.defaults).toMatchObject({ heartbeat: { agentId: "ops" }, systemAgent: { agentId: "ops" }, + authInheritance: { agentId: "ops" }, }); expect(result.cfg.talk).toMatchObject({ provider: "test", agentId: "ops" }); + expect(result.cfg.agents?.entries?.ops).not.toHaveProperty("default"); + expect(result.cfg.agents?.ownership).toBe("explicit"); }); it("preserves shared all-agent heartbeat enrollment during materialization", async () => { - const config = { + const rawConfig = { agents: { defaults: { heartbeat: { every: "1h" } }, - entries: { - ops: { default: true }, - research: {}, - }, + entries: { ops: { default: true }, research: {} }, }, channels: { telegram: { enabled: true } }, talk: { provider: "test" }, }; + const config = migratePersistedImplicitMainRoster(rawConfig).config as OpenClawConfig; const result = await runDoctorConfigWithInput({ config, - parsedConfig: config, + parsedConfig: rawConfig, repair: true, run: loadAndMaybeMigrateDoctorConfig, }); @@ -1779,11 +1782,12 @@ describe("doctor config flow", () => { it("does not rematerialize explicit roles or touch single-agent configs", async () => { const materialized = { agents: { + ownership: "explicit" as const, defaults: { heartbeat: { agentId: "ops" }, systemAgent: { agentId: "ops" }, }, - entries: { ops: { default: true }, research: {} }, + entries: { ops: { workspace: "/srv/ops" }, research: {} }, }, bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "*" } }], channels: { telegram: { enabled: true } }, @@ -1797,7 +1801,7 @@ describe("doctor config flow", () => { }); const singleAgent = await runDoctorConfigWithInput({ config: { - agents: { entries: { ops: { default: true } } }, + agents: { entries: { ops: {} } }, channels: { telegram: { enabled: true } }, talk: { provider: "test" }, }, @@ -1818,16 +1822,16 @@ describe("doctor config flow", () => { run: loadAndMaybeMigrateDoctorConfig, }); - expect(result.shouldWriteConfig).toBe(true); + expect(result.shouldWriteConfig).toBe(false); expect(result.cfg.agents?.entries).toEqual({ - main: { default: true }, + main: {}, broken: null, }); }); it("detects a legacy roster after environment resolution", async () => { const result = await runDoctorConfigWithInput({ - config: { agents: { entries: { ops: { default: true } } } }, + config: { agents: { entries: { ops: {} } } }, parsedConfig: { agents: { list: [{ id: "${AGENT_ID}", default: true }] } }, sourceConfigBeforeMigrations: { agents: { list: [{ id: "ops", default: true }] }, @@ -1837,12 +1841,12 @@ describe("doctor config flow", () => { }); expect(result.shouldWriteConfig).toBe(true); - expect(result.cfg.agents).toEqual({ entries: { ops: { default: true } } }); + expect(result.cfg.agents).toEqual({ entries: { ops: {} } }); }); it("preserves a roster supplied by an included config during repair", async () => { const result = await runDoctorConfigWithInput({ - config: { agents: { entries: { ops: { default: true } } } }, + config: { agents: { entries: { ops: {} } } }, parsedConfig: { $include: "./agents.json" }, agentRosterIncludeOwned: true, repair: true, @@ -1851,12 +1855,12 @@ describe("doctor config flow", () => { expect(result.shouldWriteConfig).toBe(false); expect(result.explicitSetPaths).toBeUndefined(); - expect(result.cfg.agents?.entries).toEqual({ ops: { default: true } }); + expect(result.cfg.agents?.entries).toEqual({ ops: {} }); }); it("preserves ownership of an explicitly empty included roster", async () => { const result = await runDoctorConfigWithInput({ - config: { agents: { entries: { main: { default: true } } } }, + config: { agents: { entries: { main: {} } } }, parsedConfig: { $include: "./agents.json" }, sourceConfigBeforeMigrations: { agents: { entries: {} } }, agentRosterIncludeOwned: true, @@ -1865,13 +1869,13 @@ describe("doctor config flow", () => { }); expect(result.shouldWriteConfig).toBe(false); - expect(result.cfg.agents?.entries).toEqual({ main: { default: true } }); + expect(result.cfg.agents?.entries).toEqual({ main: {} }); }); it("persists an injected roster when a root include contributes only channels", async () => { const result = await runDoctorConfigWithInput({ config: { - agents: { entries: { main: { default: true } } }, + agents: { entries: { main: {} } }, channels: { telegram: { enabled: true } }, }, parsedConfig: { $include: "./channels.json" }, @@ -1881,7 +1885,7 @@ describe("doctor config flow", () => { }); expect(result.shouldWriteConfig).toBe(true); - expect(result.cfg.agents?.entries).toEqual({ main: { default: true } }); + expect(result.cfg.agents?.entries).toEqual({ main: {} }); }); it("repairs a locally authored roster when unrelated includes exist", async () => { @@ -1889,7 +1893,7 @@ describe("doctor config flow", () => { config: { agents: { defaults: { workspace: "/tmp/ops" }, - entries: { main: { default: true } }, + entries: { main: {} }, }, }, parsedConfig: { $include: "./channels.json", agents: { entries: {} } }, @@ -1904,32 +1908,32 @@ describe("doctor config flow", () => { expect(result.shouldWriteConfig).toBe(true); expect(result.cfg.agents).toEqual({ defaults: { workspace: "/tmp/ops" }, - entries: { main: { default: true } }, + entries: { main: {} }, }); }); it("repairs a missing roster when only a nested channel include exists", async () => { const result = await runDoctorConfigWithInput({ - config: { agents: { entries: { main: { default: true } } } }, + config: { agents: { entries: { main: {} } } }, parsedConfig: { channels: { $include: "./channels.json" } }, repair: true, run: loadAndMaybeMigrateDoctorConfig, }); expect(result.shouldWriteConfig).toBe(true); - expect(result.cfg.agents?.entries).toEqual({ main: { default: true } }); + expect(result.cfg.agents?.entries).toEqual({ main: {} }); }); it("does not persist an implicit roster when no config file exists", async () => { const result = await runDoctorConfigWithInput({ - config: { agents: { entries: { main: { default: true } } } }, + config: { agents: { entries: { main: {} } } }, exists: false, repair: true, run: loadAndMaybeMigrateDoctorConfig, }); expect(result.shouldWriteConfig).toBe(false); - expect(result.cfg.agents?.entries).toEqual({ main: { default: true } }); + expect(result.cfg.agents?.entries).toEqual({ main: {} }); }); it("enables Doctor-only state migrations only for explicit repair", async () => { @@ -2137,7 +2141,7 @@ describe("doctor config flow", () => { it("emits warning-only stale channel cleanup without changing config", async () => { const input = { - agents: { entries: { ops: { default: true } } }, + agents: { entries: { ops: {} } }, channels: { matrix: { enabled: true } }, }; const channelDoctor = await import("./doctor/shared/channel-doctor.js"); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f8981bac4e55..5d368484469e 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,10 +1,11 @@ /** Main doctor config flow: preflight, migrations, previews, repairs, and final write decision. */ import path from "node:path"; import { note } from "../../packages/terminal-core/src/note.js"; -import { readAgentRosterProperty } from "../agents/agent-scope-config.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { readAgentRosterProperty, tryResolveSoleAgentId } from "../agents/agent-scope-config.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { formatCliCommand } from "../cli/command-format.js"; import { configIncludeOwnsAgentRoster } from "../config/agent-roster-provenance.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; import { CONFIG_PATH } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -30,7 +31,6 @@ import { type DoctorConfigMutationResult, type DoctorConfigMutationState, } from "./doctor/shared/config-mutation-state.js"; -import { materializeDefaultAgentRoles } from "./doctor/shared/default-agent-role-materialization.js"; import { isSingleTopLevelIncludeMigration } from "./doctor/shared/include-migration-ownership.js"; import { normalizeCompatibilityConfigValues } from "./doctor/shared/legacy-config-core-migrate.js"; import type { DoctorPluginMetadataSnapshotState } from "./doctor/shared/plugin-metadata-snapshot-scope.js"; @@ -169,14 +169,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { pluginMetadataSnapshotState.current = undefined; pluginMetadataSnapshotScope.invalidate(); }; - const runWithCurrentPluginMetadata = (config: OpenClawConfig, run: () => T): T => - runWithPluginMetadataSnapshot( + const runWithCurrentPluginMetadata = (config: OpenClawConfig, run: () => T): T => { + const soleAgentId = tryResolveSoleAgentId(config); + return runWithPluginMetadataSnapshot( { config, - workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)), + workspaceDir: soleAgentId ? resolveAgentWorkspaceDir(config, soleAgentId) : undefined, }, run, ); + }; let state: DoctorConfigMutationState = { cfg: baseCfg, candidate: structuredClone(baseCfg), @@ -210,6 +212,14 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { const sourceLastTouchedVersion = typeof sourceMeta?.lastTouchedVersion === "string" ? sourceMeta.lastTouchedVersion : undefined; + const rawRosterMigrations = [snapshot.sourceConfigBeforeMigrations, snapshot.parsed] + .filter((source) => source !== undefined) + .map((source) => migratePersistedImplicitMainRoster(source)); + const rosterMigrations = rawRosterMigrations.filter((migration) => migration.changed); + const rosterMigrationNeeded = rosterMigrations.length > 0; + const legacyDefaultAgentId = rawRosterMigrations + .map((migration) => migration.retainedLegacyDefaultAgentId) + .find((agentId) => agentId !== undefined); const legacyStep = runWithCurrentPluginMetadata(state.candidate, () => applyLegacyCompatibilityStep({ snapshot, @@ -219,27 +229,42 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { }), ); state = legacyStep.state; + if (legacyDefaultAgentId) { + retainLegacyDefaultAgentId(state.cfg, legacyDefaultAgentId); + retainLegacyDefaultAgentId(state.candidate, legacyDefaultAgentId); + } const legacyMigrationPartiallyValid = legacyStep.partiallyValid === true; const legacyMigrationBlocksWrite = legacyStep.blocksWrite === true; - const rosterMigrationNeeded = [snapshot.sourceConfigBeforeMigrations, snapshot.parsed].some( - (source) => source !== undefined && migratePersistedImplicitMainRoster(source).changed, - ); const includeOwnsRoster = configIncludeOwnsAgentRoster(snapshot); if (snapshot.exists && rosterMigrationNeeded && !includeOwnsRoster) { // Runtime roster normalization is read-only; doctor --fix owns persistence. const migrated = migratePersistedImplicitMainRoster(state.candidate).config as OpenClawConfig; const migratedRoster = readAgentRosterProperty(migrated); const migratedEntries = migratedRoster?.kind === "entries" ? migratedRoster.value : undefined; - const { list: _legacyList, ...candidateAgents } = state.candidate.agents ?? {}; + const { list: _legacyList, ...candidateAgents } = migrated.agents ?? {}; + const stampsExplicitOwnership = + legacyDefaultAgentId !== undefined && Object.keys(migratedEntries ?? {}).length > 1; const rosterRepair = { config: { - ...state.candidate, + ...migrated, agents: { ...candidateAgents, + ...(stampsExplicitOwnership ? { ownership: "explicit" as const } : {}), entries: migratedEntries as NonNullable["entries"], }, }, - changes: ["Prepared agents.entries with exactly one explicit default agent for persistence."], + changes: [ + ...new Set( + rosterMigrations + .flatMap((migration) => migration.diagnostics) + .concat( + "Prepared the canonical agent roster without retired default markers for persistence.", + ...(stampsExplicitOwnership + ? ["Stamped the multi-agent roster for explicit per-surface ownership."] + : []), + ), + ), + ], }; applyConfigMutation(rosterRepair, { fixHint: `Run "${doctorFixCommand}" to persist the explicit agent roster.`, @@ -247,10 +272,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { // Read-time normalization already exposes this roster in the runtime shape. // Preserve doctor's write intent so the atomic writer does not restore the authored omission. explicitSetPaths.push(["agents", "entries"]); + if (stampsExplicitOwnership) { + explicitSetPaths.push(["agents", "ownership"]); + } } - applyConfigMutation(materializeDefaultAgentRoles(state.candidate), { - fixHint: `Run "${doctorFixCommand}" to persist explicit ambient agent targets.`, - }); const { collectBlockedLegacyOpenAICodexProviderPlan } = await import("./doctor/shared/legacy-config-migrations.runtime.models.js"); const blockedCodexProviderPlan = collectBlockedLegacyOpenAICodexProviderPlan(state.candidate); diff --git a/src/commands/doctor-config-preflight.state-migration-input.test.ts b/src/commands/doctor-config-preflight.state-migration-input.test.ts index c5149e13cba9..9db7614cc63f 100644 --- a/src/commands/doctor-config-preflight.state-migration-input.test.ts +++ b/src/commands/doctor-config-preflight.state-migration-input.test.ts @@ -193,7 +193,7 @@ describe("runDoctorConfigPreflight state migration input", () => { }), agents: expect.objectContaining({ defaults: expect.objectContaining({}), - entries: { main: { default: true } }, + entries: { main: {} }, }), }), migrateCodexModelRefs: false, @@ -209,7 +209,7 @@ describe("runDoctorConfigPreflight state migration input", () => { }), agents: expect.objectContaining({ defaults: expect.objectContaining({}), - entries: { main: { default: true } }, + entries: { main: {} }, }), }), pluginDoctorConfig: resolvedConfig, diff --git a/src/commands/doctor-heartbeat-cadence-migration.test.ts b/src/commands/doctor-heartbeat-cadence-migration.test.ts index 75bb668b36db..5065a7f4ddef 100644 --- a/src/commands/doctor-heartbeat-cadence-migration.test.ts +++ b/src/commands/doctor-heartbeat-cadence-migration.test.ts @@ -147,7 +147,7 @@ describe("heartbeat cadence cron migration", () => { ); }); - it("keeps multi-agent updates and creates scoped to their declared monitors", async () => { + it("keeps ownerless multi-agent updates scoped to their declared monitors", async () => { const fixture = await createFixture(); const initialCfg = { agents: { diff --git a/src/commands/doctor-heartbeat-cadence-migration.ts b/src/commands/doctor-heartbeat-cadence-migration.ts index 7e2d0e6805e0..d42c069c53c7 100644 --- a/src/commands/doctor-heartbeat-cadence-migration.ts +++ b/src/commands/doctor-heartbeat-cadence-migration.ts @@ -1,8 +1,8 @@ /** Doctor-owned materialization of heartbeat cadence config into cron monitor rows. */ import { isDeepStrictEqual } from "node:util"; import { note } from "../../packages/terminal-core/src/note.js"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { heartbeatMonitorAgentId, @@ -42,7 +42,7 @@ function createDoctorCronService(storePath: string, cfg: OpenClawConfig): CronSe storePath, cronEnabled: false, cronConfig: cfg.cron, - defaultAgentId: resolveDefaultAgentId(cfg), + resolveDefaultAgentId: () => tryResolveLegacyCompatibilityAgentId(cfg), log, enqueueSystemEvent: () => false, requestHeartbeat: noop, diff --git a/src/commands/doctor-heartbeat-scratch-migration.test.ts b/src/commands/doctor-heartbeat-scratch-migration.test.ts index e73481ea84a6..e5baae05441c 100644 --- a/src/commands/doctor-heartbeat-scratch-migration.test.ts +++ b/src/commands/doctor-heartbeat-scratch-migration.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { heartbeatMonitorAgentId } from "../cron/heartbeat-monitor.js"; import { readCronJobScratchState, writeCronJobScratch } from "../cron/scratch-store.js"; @@ -148,15 +149,18 @@ describe("HEARTBEAT.md cron scratch migration", () => { it("imports a shared workspace file into every agent monitor before removing it", async () => { const fixture = await createFixture(); - const cfg = { - agents: { - defaults: { heartbeat: { every: "30m" } }, - list: [ - { id: "main", workspace: fixture.workspace }, - { id: "ops", workspace: fixture.workspace }, - ], - }, - } as OpenClawConfig; + const cfg = retainLegacyDefaultAgentId( + { + agents: { + defaults: { heartbeat: { every: "30m" } }, + list: [ + { id: "main", workspace: fixture.workspace }, + { id: "ops", workspace: fixture.workspace }, + ], + }, + } as OpenClawConfig, + "main", + ); await fs.writeFile(fixture.heartbeatPath, "shared checklist\n", "utf8"); const result = await maybeMigrateHeartbeatFilesToScratch({ cfg, shouldRepair: true }); diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index b4307d6ec7a9..0c5808ff3c52 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -224,14 +224,14 @@ describe("doctorCommand", () => { }); }); - it("rejects conflicting explicit-store selectors before taking maintenance ownership", async () => { + it("rejects an explicit store combined with all agents before taking maintenance ownership", async () => { await expect( doctorCommand(undefined, { sessionSqlite: "compact", - sessionSqliteAgent: "ops", + sessionSqliteAllAgents: true, sessionSqliteStore: path.resolve("stores", "{agentId}", "sessions.json"), }), - ).rejects.toThrow("--store cannot be combined with --agent or --all-agents"); + ).rejects.toThrow("--store cannot be combined with --all-agents"); expect(mocks.withDoctorSqliteMaintenanceLock).not.toHaveBeenCalled(); expect(mocks.runDoctorSessionSqlite).not.toHaveBeenCalled(); diff --git a/src/commands/doctor/cron/legacy-repair.ts b/src/commands/doctor/cron/legacy-repair.ts index b326dbab0ad1..e51fb8b30f19 100644 --- a/src/commands/doctor/cron/legacy-repair.ts +++ b/src/commands/doctor/cron/legacy-repair.ts @@ -64,6 +64,7 @@ export type LegacyCronRepairState = { legacyImportCount: number; sqliteProjectionBackfillCount: number; invalidConfigRows: QuarantinedCronConfigJob[]; + projectedOwnersByJobId: ReadonlyMap; rawJobs: Array>; }; @@ -90,10 +91,13 @@ function readLegacyCronStorePath(cfg: OpenClawConfig): string | undefined { export async function loadLegacyCronRepairState(params: { cfg: OpenClawConfig; + storePath?: string; + env?: NodeJS.ProcessEnv; onlyIfLegacyDetected?: boolean; readOnly?: boolean; }): Promise { - const storePath = resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg)); + const storePath = + params.storePath ?? resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg), params.env); const legacyStoreDetected = await legacyCronStoreFilesExist(storePath); const legacyRunLogDetected = await legacyCronRunLogFilesExist(storePath); const legacyQuarantine = await loadLegacyCronQuarantineForMigration(storePath); @@ -107,8 +111,17 @@ export async function loadLegacyCronRepairState(params: { } const loaded = params.readOnly - ? await loadCronJobsStoreWithConfigJobsReadOnly(storePath) + ? await loadCronJobsStoreWithConfigJobsReadOnly(storePath, params.env) : await loadCronJobsStoreWithConfigJobs(storePath); + const projectedOwnersByJobId = new Map( + loaded.store.jobs.map((job) => [ + job.id, + { + ...(Object.hasOwn(job, "agentId") ? { agentId: job.agentId } : {}), + ...(Object.hasOwn(job, "sessionKey") ? { sessionKey: job.sessionKey } : {}), + }, + ]), + ); const currentEntries = loaded.configJobs.map((job, index) => ({ sourceIndex: loaded.configJobIndexes[index] ?? index, job: mergeRuntimeEntryIntoConfigJob({ @@ -178,6 +191,7 @@ export async function loadLegacyCronRepairState(params: { legacyImportCount, sqliteProjectionBackfillCount, invalidConfigRows, + projectedOwnersByJobId, rawJobs, }; } diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index a1533fd35258..30b40ab1537b 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ repairStaleOAuthProfileShadows: vi.fn(), repairMissingConfiguredPluginInstalls: vi.fn(), repairStaleAgentModelRefs: vi.fn(), + resolveConfigWidePluginManifestRegistry: vi.fn(), resolveAuthProfileOrder: vi.fn(), resolveProviderInstallCatalogEntries: vi.fn(), resolveProfileUnusableUntilForDisplay: vi.fn(), @@ -39,6 +40,10 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ materializePluginAutoEnableCandidates: mocks.materializePluginAutoEnableCandidates, })); +vi.mock("../../config/io.plugin-metadata.js", () => ({ + resolveConfigWidePluginManifestRegistry: mocks.resolveConfigWidePluginManifestRegistry, +})); + vi.mock("../doctor-plugin-host-links.js", () => ({ maybeRepairPluginOpenClawHostLinks: mocks.maybeRepairPluginOpenClawHostLinks, })); @@ -90,7 +95,8 @@ vi.mock("../../plugins/installed-plugin-index.js", async (importOriginal) => ({ loadInstalledPluginIndex: mocks.loadInstalledPluginIndex, })); -vi.mock("../../plugins/plugin-metadata-snapshot.js", () => ({ +vi.mock("../../plugins/plugin-metadata-snapshot.js", async (importOriginal) => ({ + ...(await importOriginal()), loadPluginMetadataSnapshot: mocks.loadPluginMetadataSnapshot, })); @@ -314,6 +320,10 @@ describe("doctor repair sequencing", () => { mocks.collectChannelDoctorCompatibilityMutations.mockReturnValue([]); mocks.resolveAuthProfileOrder.mockReturnValue([]); mocks.resolveProviderInstallCatalogEntries.mockReturnValue([]); + mocks.resolveConfigWidePluginManifestRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); mocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(null); mocks.maybeRepairStalePluginConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg, @@ -573,7 +583,10 @@ describe("doctor repair sequencing", () => { expect(peerLinkCall?.prompter).toEqual({ shouldRepair: true }); expect(peerLinkCall?.env).toBe(process.env); expect(mocks.loadPluginMetadataSnapshot).toHaveBeenCalledOnce(); - expect(result.pluginMetadataSnapshot).toBe(refreshedSnapshot); + expect(result.pluginMetadataSnapshot).toMatchObject({ + manifestRegistry: refreshedSnapshot.manifestRegistry, + plugins: [], + }); }); it("repairs stale OAuth shadows before importing and removing auth JSON", async () => { @@ -737,6 +750,56 @@ describe("doctor repair sequencing", () => { ]); }); + it("uses plugins from every agent workspace after inventory repair", async () => { + const researchPlugin = { + id: "research-channel", + source: "/srv/research/.openclaw/extensions/research-channel/openclaw.plugin.json", + }; + const manifestRegistry = { plugins: [researchPlugin], diagnostics: [] }; + mocks.resolveConfigWidePluginManifestRegistry.mockReturnValue(manifestRegistry); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + manifestRegistry: { plugins: [], diagnostics: [] }, + plugins: [], + diagnostics: [], + byPluginId: new Map(), + }); + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({ + changes: ['Installed missing configured plugin "research-channel".'], + warnings: [], + pluginInventoryChanged: true, + }); + + await runDoctorRepairSequence({ + state: { + cfg: { + agents: { + ownership: "explicit", + entries: { + ops: { workspace: "/srv/ops" }, + research: { workspace: "/srv/research" }, + }, + }, + } as OpenClawConfig, + candidate: { + agents: { + ownership: "explicit", + entries: { + ops: { workspace: "/srv/ops" }, + research: { workspace: "/srv/research" }, + }, + }, + } as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith( + expect.objectContaining({ manifestRegistry }), + ); + }); + it("installs an external provider before validating configured model references", async () => { let mistralInstalled = false; mocks.repairMissingConfiguredPluginInstalls.mockImplementationOnce(async () => { @@ -990,6 +1053,18 @@ describe("doctor repair sequencing", () => { }, }) as unknown as PluginMetadataSnapshot; const refreshedSnapshot = createRefreshedSnapshot(true); + const configWideManifestRegistry = { + plugins: [ + { + id: "workspace-plugin", + source: + "/tmp/openclaw-doctor-workspace/.openclaw/extensions/workspace-plugin/openclaw.plugin.json", + providers: [workspaceProvider], + }, + ], + diagnostics: [], + }; + mocks.resolveConfigWidePluginManifestRegistry.mockReturnValue(configWideManifestRegistry); mocks.loadPluginMetadataSnapshot.mockImplementationOnce((params: { workspaceDir?: string }) => params.workspaceDir === workspaceDir ? refreshedSnapshot : createRefreshedSnapshot(false), ); @@ -1071,8 +1146,9 @@ describe("doctor repair sequencing", () => { }, }, env: process.env, - manifestRegistry: refreshedSnapshot.manifestRegistry, + manifestRegistry: configWideManifestRegistry, }); + const currentPluginMetadataSnapshot = pluginMetadataSnapshotState.current; expect(mocks.repairStaleAgentModelRefs).toHaveBeenCalledWith( { agents: { @@ -1084,13 +1160,19 @@ describe("doctor repair sequencing", () => { }, { env: process.env, - pluginMetadataSnapshot: refreshedSnapshot, + pluginMetadataSnapshot: currentPluginMetadataSnapshot, }, ); - expect(pluginMetadataSnapshotState.current).toBe(refreshedSnapshot); + expect(currentPluginMetadataSnapshot).toMatchObject({ + manifestRegistry: configWideManifestRegistry, + plugins: configWideManifestRegistry.plugins, + owners: { + providers: new Map([[workspaceProvider, ["workspace-plugin"]]]), + }, + }); expect(scopedSnapshots[0]).toBe(staleSnapshot); - expect(scopedSnapshots).toContain(refreshedSnapshot); - expect(result.pluginMetadataSnapshot).toBe(refreshedSnapshot); + expect(scopedSnapshots).toContain(currentPluginMetadataSnapshot); + expect(result.pluginMetadataSnapshot).toBe(currentPluginMetadataSnapshot); expect(result.state.candidate.agents?.defaults?.model).toBe(`${workspaceProvider}/model`); expect(result.changeNotes).not.toContain( expect.stringContaining(`provider "${workspaceProvider}" is unavailable`), diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index fb7c70858126..a0f9eaff6ac6 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -1,6 +1,6 @@ // Doctor repair sequence coordinator for config, auth, plugin, and warning repairs. import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, tryResolveSoleAgentId } from "../../agents/agent-scope.js"; import { applyPluginAutoEnable, materializePluginAutoEnableCandidates, @@ -44,7 +44,10 @@ import { maybeRepairLegacyToolsBySenderKeys } from "./shared/legacy-tools-by-sen import { repairMissingConfiguredPluginInstalls } from "./shared/missing-configured-plugin-install.js"; import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js"; import { cleanupLegacyPluginDependencyState } from "./shared/plugin-dependency-cleanup.js"; -import type { DoctorPluginMetadataSnapshotState } from "./shared/plugin-metadata-snapshot-scope.js"; +import { + resolveConfigWideDoctorPluginMetadataSnapshot, + type DoctorPluginMetadataSnapshotState, +} from "./shared/plugin-metadata-snapshot-scope.js"; import { repairStaleAgentModelRefs } from "./shared/stale-agent-model-ref-repair.js"; import { maybeRepairStaleConfiguredAuthOrders } from "./shared/stale-auth-order.js"; import { repairStaleOAuthProfileShadows } from "./shared/stale-oauth-profile-shadows.js"; @@ -75,9 +78,10 @@ export async function runDoctorRepairSequence(params: { const env = params.env ?? process.env; const resolveCurrentPluginMetadataScope = () => { const config = state.candidate; + const soleAgentId = tryResolveSoleAgentId(config); return { config, - workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config), env), + workspaceDir: soleAgentId ? resolveAgentWorkspaceDir(config, soleAgentId, env) : undefined, }; }; const sanitizeLines = (lines: string[]) => lines.map((line) => sanitizeForLog(line)).join("\n"); @@ -204,10 +208,14 @@ export async function runDoctorRepairSequence(params: { // Inventory repair changes the authoritative plugin generation. Replace the // shared Doctor base before later discovery so nested scopes cannot reuse stale metadata. const currentScope = resolveCurrentPluginMetadataScope(); - pluginMetadataSnapshotState.current = loadPluginMetadataSnapshot({ + pluginMetadataSnapshotState.current = resolveConfigWideDoctorPluginMetadataSnapshot({ + snapshot: loadPluginMetadataSnapshot({ + config: currentScope.config, + env, + workspaceDir: currentScope.workspaceDir, + }), config: currentScope.config, env, - workspaceDir: currentScope.workspaceDir, }); } if (missingConfiguredPluginInstallRepair.changes.length > 0) { diff --git a/src/commands/doctor/shared/config-mutation-state.test.ts b/src/commands/doctor/shared/config-mutation-state.test.ts index a42bff5fd45f..162c71926841 100644 --- a/src/commands/doctor/shared/config-mutation-state.test.ts +++ b/src/commands/doctor/shared/config-mutation-state.test.ts @@ -1,5 +1,9 @@ // Config mutation state tests cover doctor mutation tracking and final state reporting. import { describe, expect, it } from "vitest"; +import { + retainLegacyDefaultAgentId, + tryGetLegacyDefaultAgentId, +} from "../../../config/legacy.default-agent-owner.js"; import { applyDoctorConfigMutation } from "./config-mutation-state.js"; import type { DoctorConfigMutationState } from "./config-mutation-state.js"; @@ -65,4 +69,18 @@ describe("doctor config mutation state", () => { }), ).toBe(state); }); + + it("carries the upgrade-only owner across repair mutations", () => { + const state = emptyMutationState(); + retainLegacyDefaultAgentId(state.candidate, "ops"); + + const next = applyDoctorConfigMutation({ + state, + mutation: enabledSignalMutation(), + shouldRepair: true, + }); + + expect(tryGetLegacyDefaultAgentId(next.candidate)).toBe("ops"); + expect(tryGetLegacyDefaultAgentId(next.cfg)).toBe("ops"); + }); }); diff --git a/src/commands/doctor/shared/config-mutation-state.ts b/src/commands/doctor/shared/config-mutation-state.ts index 240c1c55752d..3a874f7142b4 100644 --- a/src/commands/doctor/shared/config-mutation-state.ts +++ b/src/commands/doctor/shared/config-mutation-state.ts @@ -1,4 +1,5 @@ // Shared doctor state helpers for previewing or applying config mutations. +import { inheritLegacyDefaultAgentId } from "../../../config/legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; export type DoctorConfigMutationState = { @@ -29,10 +30,11 @@ export function applyDoctorConfigMutation(params: { if (params.mutation.changes.length === 0) { return params.state; } + const config = inheritLegacyDefaultAgentId(params.state.candidate, params.mutation.config); return { - cfg: params.shouldRepair ? params.mutation.config : params.state.cfg, - candidate: params.mutation.config, + cfg: params.shouldRepair ? config : params.state.cfg, + candidate: config, pendingChanges: true, fixHints: !params.shouldRepair && params.fixHint diff --git a/src/commands/doctor/shared/default-agent-role-materialization.test.ts b/src/commands/doctor/shared/default-agent-role-materialization.test.ts index 28308155e9c8..828ecc833a37 100644 --- a/src/commands/doctor/shared/default-agent-role-materialization.test.ts +++ b/src/commands/doctor/shared/default-agent-role-materialization.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it } from "vitest"; -import { resolveDefaultAgentId } from "../../../agents/agent-scope-config.js"; +import { listAgentEntries, resolveDefaultAgentId } from "../../../agents/agent-scope-config.js"; +import { materializeLegacyDefaultAgentRoles } from "../../../config/legacy.default-agent-roles.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { resolveCronJobEffectiveAgentId } from "../../../cron/agent-id.js"; import { resolveHeartbeatAgents } from "../../../infra/heartbeat-runner.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveSystemAgentTargetAgentId } from "../../../system-agent/inference-route.js"; import { resolveTalkSessionAgentId, resolveTalkTargetAgentId } from "../../../talk/agent-target.js"; -import { materializeDefaultAgentRoles } from "./default-agent-role-materialization.js"; + +function materializeDefaultAgentRoles(cfg: OpenClawConfig) { + if (listAgentEntries(cfg).length < 2) { + return { config: cfg, changes: [] }; + } + const result = materializeLegacyDefaultAgentRoles(cfg, resolveDefaultAgentId(cfg)); + return { config: result.config, changes: result.insertedPaths.map((path) => path.join(".")) }; +} type SurfaceSnapshot = { channel: { agentId: string; sessionKey: string }; @@ -36,11 +44,7 @@ function snapshotSurfaces(cfg: OpenClawConfig): SurfaceSnapshot { } const fixtures: Array<{ name: string; config: OpenClawConfig; materializes: boolean }> = [ - { - name: "legacy single-agent", - config: {}, - materializes: false, - }, + { name: "legacy single-agent", config: {}, materializes: false }, { name: "explicit single-agent", config: { @@ -53,12 +57,7 @@ const fixtures: Array<{ name: string; config: OpenClawConfig; materializes: bool { name: "multi-agent default with an unbound channel", config: { - agents: { - entries: { - ops: { default: true }, - research: {}, - }, - }, + agents: { entries: { ops: { default: true }, research: {} } }, channels: { telegram: { enabled: true } }, }, materializes: true, @@ -70,11 +69,9 @@ const fixtures: Array<{ name: string; config: OpenClawConfig; materializes: bool defaults: { heartbeat: { agentId: "ops" }, systemAgent: { agentId: "ops" }, + authInheritance: { agentId: "ops" }, }, - entries: { - ops: { default: true }, - research: {}, - }, + entries: { ops: { default: true }, research: {} }, }, bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "*" } }], channels: { telegram: { enabled: true } }, @@ -113,7 +110,6 @@ describe("default agent role materialization", () => { { agentId: "research", match: { channel: "discord", accountId: "*" } }, ], }; - const result = materializeDefaultAgentRoles(config); expect(result.config.bindings).toEqual([ ...config.bindings!, @@ -144,7 +140,6 @@ describe("default agent role materialization", () => { }, }, }; - expect(materializeDefaultAgentRoles(allAgents).config.agents?.defaults?.heartbeat).toEqual({ every: "1h", }); @@ -162,11 +157,8 @@ describe("default agent role materialization", () => { agents: { entries: { ops: { default: true }, research: {} } }, }; expect(materializeDefaultAgentRoles(base).config.talk).toEqual({ agentId: "ops" }); - const malformed = { ...base, talk: "invalid" as never }; - const result = materializeDefaultAgentRoles(malformed); - expect(result.config.talk).toBe("invalid"); - expect(result.changes).not.toContain('Assigned ambient Talk sessions to agent "ops".'); + expect(materializeDefaultAgentRoles(malformed).config.talk).toBe("invalid"); }); it("uses the Talk owner for unscoped aliases and explicit agent keys when present", () => { @@ -179,6 +171,20 @@ describe("default agent role materialization", () => { expect(resolveTalkSessionAgentId(config, "agent:ops:main")).toBe("ops"); }); + it("routes bare Talk sessions through the persisted fixed-store owner", () => { + const config: OpenClawConfig = { + talk: { agentId: "research" }, + session: { store: "/tmp/owned-shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + expect(resolveTalkSessionAgentId(config, "incident-42")).toBe("ops"); + }); + it("preserves malformed bindings and agent-default blocks for validation", () => { const base = { agents: { entries: { ops: { default: true }, research: {} } }, @@ -186,22 +192,11 @@ describe("default agent role materialization", () => { } satisfies OpenClawConfig; const malformedBindings = { ...base, bindings: { bad: true } as never }; expect(materializeDefaultAgentRoles(malformedBindings).config.bindings).toEqual({ bad: true }); - - const malformedBindingEntry = { - ...base, - bindings: [null as never, { agentId: "ops" } as never], - }; - expect(() => materializeDefaultAgentRoles(malformedBindingEntry)).not.toThrow(); - expect(materializeDefaultAgentRoles(malformedBindingEntry).config.bindings?.[1]).toEqual({ - agentId: "ops", - }); - const malformedDefaults = { ...base, agents: { ...base.agents, defaults: null as never }, }; expect(materializeDefaultAgentRoles(malformedDefaults).config.agents?.defaults).toBeNull(); - const malformedSystemAgent = { ...base, agents: { ...base.agents, defaults: { systemAgent: null as never } }, diff --git a/src/commands/doctor/shared/default-agent-role-materialization.ts b/src/commands/doctor/shared/default-agent-role-materialization.ts deleted file mode 100644 index 030f608abf4e..000000000000 --- a/src/commands/doctor/shared/default-agent-role-materialization.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { listAgentEntries } from "../../../agents/agent-scope-config.js"; -import type { AgentRouteBinding } from "../../../config/types.agents.js"; -import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { normalizeRouteBindingChannelId } from "../../../routing/binding-scope.js"; -import { normalizeAgentId } from "../../../routing/session-key.js"; -import { isRecord } from "../../../utils.js"; - -type DefaultAgentRoleMaterialization = { - config: OpenClawConfig; - changes: string[]; -}; - -function resolveLegacyMultiAgentDefault(cfg: OpenClawConfig): string | undefined { - const entries = listAgentEntries(cfg); - if (entries.length < 2) { - return undefined; - } - const defaults = entries.filter((entry) => entry.default === true); - return defaults.length === 1 ? normalizeAgentId(defaults[0]!.id) : undefined; -} - -function listAmbientConfiguredChannelIds(cfg: OpenClawConfig): string[] { - if (!isRecord(cfg.channels)) { - return []; - } - return Object.entries(cfg.channels) - .flatMap(([channelId, value]) => { - if (channelId === "defaults" || (isRecord(value) && value.enabled === false)) { - return []; - } - const normalized = normalizeRouteBindingChannelId(channelId); - return normalized ? [normalized] : []; - }) - .toSorted((left, right) => left.localeCompare(right)); -} - -function isChannelWideBinding(binding: AgentRouteBinding, channelId: string): boolean { - const match = binding.match; - if (!isRecord(match)) { - return false; - } - return ( - normalizeRouteBindingChannelId( - typeof match.channel === "string" ? match.channel : undefined, - ) === channelId && - (typeof match.accountId === "string" ? match.accountId.trim() : undefined) === "*" && - match.peer === undefined && - !normalizeOptionalString(typeof match.guildId === "string" ? match.guildId : undefined) && - !normalizeOptionalString(typeof match.teamId === "string" ? match.teamId : undefined) && - (!Array.isArray(match.roles) || match.roles.length === 0) - ); -} - -/** - * Materialize only ambient roles that currently fall through to a multi-agent default. - * The marker remains authoritative in H2-0; these explicit targets are preparation for H2-1. - */ -export function materializeDefaultAgentRoles(cfg: OpenClawConfig): DefaultAgentRoleMaterialization { - const defaultAgentId = resolveLegacyMultiAgentDefault(cfg); - if (!defaultAgentId) { - return { config: cfg, changes: [] }; - } - - let next = cfg; - const changes: string[] = []; - const canMaterializeBindings = cfg.bindings === undefined || Array.isArray(cfg.bindings); - const bindings = Array.isArray(cfg.bindings) - ? cfg.bindings.filter( - (binding): binding is AgentRouteBinding => isRecord(binding) && binding.type !== "acp", - ) - : []; - const missingChannelBindings = canMaterializeBindings - ? listAmbientConfiguredChannelIds(cfg).filter( - (channelId) => !bindings.some((binding) => isChannelWideBinding(binding, channelId)), - ) - : []; - if (missingChannelBindings.length > 0) { - next = { - ...next, - bindings: [ - ...(Array.isArray(next.bindings) ? next.bindings : []), - ...missingChannelBindings.map((channel) => ({ - agentId: defaultAgentId, - match: { channel, accountId: "*" }, - })), - ], - }; - changes.push( - `Bound ${missingChannelBindings.join(", ")} unbound account routing to agent "${defaultAgentId}".`, - ); - } - - const rawDefaults = (cfg.agents as { defaults?: unknown } | undefined)?.defaults; - const defaultsConfig = isRecord(rawDefaults) ? rawDefaults : undefined; - const canMaterializeDefaults = rawDefaults === undefined || defaultsConfig !== undefined; - const hasPerAgentHeartbeat = listAgentEntries(cfg).some((entry) => Boolean(entry.heartbeat)); - // A shared defaults heartbeat already fans out to every agent. Pinning it here - // would silently narrow existing multi-agent enrollment to the legacy default. - if (canMaterializeDefaults && !hasPerAgentHeartbeat && defaultsConfig?.heartbeat === undefined) { - next = { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - heartbeat: { agentId: defaultAgentId }, - }, - }, - }; - changes.push(`Assigned ambient heartbeat runs to agent "${defaultAgentId}".`); - } - - const rawSystemAgent = defaultsConfig?.systemAgent; - const systemAgentConfig = isRecord(rawSystemAgent) ? rawSystemAgent : undefined; - if ( - canMaterializeDefaults && - (rawSystemAgent === undefined || systemAgentConfig !== undefined) && - (!systemAgentConfig || !Object.hasOwn(systemAgentConfig, "agentId")) - ) { - next = { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - systemAgent: { - ...next.agents?.defaults?.systemAgent, - agentId: defaultAgentId, - }, - }, - }, - }; - changes.push(`Assigned ambient system-agent consults to agent "${defaultAgentId}".`); - } - - const talkConfig = isRecord(cfg.talk) ? cfg.talk : undefined; - if ( - (cfg.talk === undefined || talkConfig !== undefined) && - (!talkConfig || !Object.hasOwn(talkConfig, "agentId")) - ) { - next = { - ...next, - talk: { ...talkConfig, agentId: defaultAgentId }, - }; - changes.push(`Assigned ambient Talk sessions to agent "${defaultAgentId}".`); - } - - return { config: next, changes }; -} diff --git a/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts b/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts index 38fb2337c790..14b771e2226f 100644 --- a/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts +++ b/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts @@ -3,7 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createConfigIO, resetConfigRuntimeState } from "../../../config/io.js"; -import { materializeDefaultAgentRoles } from "./default-agent-role-materialization.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../../config/legacy.default-agent-owner.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; const roots: string[] = []; @@ -25,7 +26,7 @@ describe("default role materialization authored writes", () => { `${JSON.stringify( { agents: { - defaults: { model: "${DEFAULT_MODEL}" }, + defaults: { model: "${DEFAULT_MODEL}", workspace: "/srv/ops" }, entries: { ops: { default: true }, research: { model: "${RESEARCH_MODEL}" }, @@ -53,21 +54,27 @@ describe("default role materialization authored writes", () => { }); const snapshot = await io.readConfigFileSnapshot(); - const materialized = materializeDefaultAgentRoles(snapshot.config); - expect(materialized.changes.length).toBeGreaterThan(0); - await io.writeConfigFile(materialized.config, { baseSnapshot: snapshot }); - - const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { - agents?: { - defaults?: { model?: string; heartbeat?: { agentId?: string } }; - entries?: Record; - }; - channels?: { $include?: string }; - bindings?: Array<{ agentId?: string; match?: { channel?: string; accountId?: string } }>; - talk?: { agentId?: string }; + expect(snapshot.config.agents?.entries?.ops).not.toHaveProperty("default"); + expect(snapshot.config.agents?.defaults?.heartbeat?.agentId).toBe("ops"); + const doctorCandidate = { + ...snapshot.config, + agents: { ...snapshot.config.agents, ownership: "explicit" as const }, }; + await io.writeConfigFile(doctorCandidate, { + baseSnapshot: snapshot, + explicitSetPaths: [ + ["agents", "entries"], + ["agents", "ownership"], + ], + explicitSetValueSource: doctorCandidate, + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as OpenClawConfig; expect(persisted.agents?.defaults?.model).toBe("${DEFAULT_MODEL}"); + expect(persisted.agents?.entries?.ops?.workspace).toBe("/srv/ops"); + expect(persisted.agents?.ownership).toBe("explicit"); expect(persisted.agents?.entries?.research?.model).toBe("${RESEARCH_MODEL}"); + expect(persisted.agents?.entries?.ops).not.toHaveProperty("default"); expect(persisted.channels).toEqual({ $include: "./channels.json5" }); await expect(fs.readFile(channelsPath, "utf-8")).resolves.toBe(includeRaw); expect(persisted.bindings).toContainEqual({ @@ -75,9 +82,296 @@ describe("default role materialization authored writes", () => { match: { channel: "telegram", accountId: "*" }, }); expect(persisted.agents?.defaults?.heartbeat?.agentId).toBe("ops"); + expect(persisted.agents?.defaults?.authInheritance?.agentId).toBe("ops"); expect(persisted.talk?.agentId).toBe("ops"); + const firstPersisted = await fs.readFile(configPath, "utf-8"); const reread = await io.readConfigFileSnapshot(); - expect(materializeDefaultAgentRoles(reread.config).changes).toEqual([]); + await io.writeConfigFile(reread.config, { baseSnapshot: reread }); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(firstPersisted); + + const topology = await io.readConfigFileSnapshot(); + await io.writeConfigFile( + { + ...topology.config, + agents: { + ...topology.config.agents, + ownership: undefined, + entries: { ...topology.config.agents?.entries, writer: {} }, + }, + }, + { baseSnapshot: topology }, + ); + const rewritten = JSON.parse(await fs.readFile(configPath, "utf-8")); + expect(rewritten.agents).toMatchObject({ ownership: "explicit", entries: { writer: {} } }); + }); + + it.each([true, false])( + "pins a replaced sole fixed-store owner only when the store is unchanged: %s", + async (sameStore) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-owner-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + const sourceStore = path.join(root, "source-sessions.json"); + await fs.writeFile( + configPath, + JSON.stringify({ agents: { entries: { ops: {} } }, session: { store: sourceStore } }), + ); + const io = createConfigIO({ + configPath, + env: { HOME: root, OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + const snapshot = await io.readConfigFileSnapshot(); + await io.writeConfigFile( + { + ...snapshot.config, + agents: { ownership: "explicit", entries: { research: {} } }, + session: { + store: sameStore ? sourceStore : path.join(root, "destination-sessions.json"), + }, + }, + { baseSnapshot: snapshot, allowedAgentRosterRemovals: ["ops"] }, + ); + const persisted = JSON.parse(await fs.readFile(configPath, "utf8")); + expect(persisted.agents?.defaults?.sessionStore?.agentId).toBe(sameStore ? "ops" : undefined); + }, + ); + + it.each([ + ["another fixed store", "destination-sessions.json"], + ["a per-agent store", "sessions-{agentId}.json"], + ])("drops a persisted fixed-store owner when switching to %s", async (_label, storeName) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-owner-switch-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: path.join(root, "source-sessions.json") }, + }), + ); + const io = createConfigIO({ + configPath, + env: { HOME: root, OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + const snapshot = await io.readConfigFileSnapshot(); + + await io.writeConfigFile( + { + ...snapshot.config, + session: { ...snapshot.config.session, store: path.join(root, storeName) }, + }, + { baseSnapshot: snapshot }, + ); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(persisted.agents?.defaults?.sessionStore?.agentId).toBeUndefined(); + }); + + it("keeps an explicitly supplied owner when switching fixed stores", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-owner-switch-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: path.join(root, "source-sessions.json") }, + }), + ); + const io = createConfigIO({ + configPath, + env: { HOME: root, OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + const snapshot = await io.readConfigFileSnapshot(); + const nextConfig: OpenClawConfig = { + ...snapshot.config, + agents: { + ...snapshot.config.agents, + defaults: { + ...snapshot.config.agents?.defaults, + sessionStore: { agentId: "research" }, + }, + }, + session: { ...snapshot.config.session, store: path.join(root, "destination-sessions.json") }, + }; + + await io.writeConfigFile(nextConfig, { + baseSnapshot: snapshot, + explicitSetPaths: [["agents", "defaults", "sessionStore", "agentId"]], + explicitSetValueSource: nextConfig, + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(persisted.agents?.defaults?.sessionStore?.agentId).toBe("research"); + }); + + it("pins the survivor's previous workspace during a generic roster collapse", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-collapse-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + ownership: "explicit", + defaults: { workspace: "/srv/fleet" }, + entries: { ops: {}, research: {} }, + }, + }), + ); + const io = createConfigIO({ + configPath, + env: { HOME: root, OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + const snapshot = await io.readConfigFileSnapshot(); + + await io.writeConfigFile( + { + ...snapshot.config, + agents: { + ...snapshot.config.agents, + ownership: undefined, + entries: { research: {} }, + }, + }, + { baseSnapshot: snapshot, allowedAgentRosterRemovals: ["ops"] }, + ); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(persisted.agents?.entries?.research?.workspace).toBe("/srv/fleet/research"); + }); + + it.each([ + ["pins the replaced owner", "research", false, "ops"], + ["keeps an explicitly authored owner", "research", true, "research"], + ["does nothing when the owner is unchanged", "ops", false, undefined], + ] as const)( + "%s during generic roster writes", + async (_label, targetAgentId, explicit, expected) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-owner-transition-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + await fs.writeFile(configPath, JSON.stringify({ agents: { entries: { ops: {} } } })); + const io = createConfigIO({ + configPath, + env: { HOME: root, OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + const snapshot = await io.readConfigFileSnapshot(); + const nextConfig: OpenClawConfig = { + ...snapshot.config, + agents: { + ownership: "explicit", + ...(explicit ? { defaults: { authInheritance: { agentId: "research" } } } : {}), + entries: { [targetAgentId]: targetAgentId === "ops" ? { model: "openai/test" } : {} }, + }, + }; + await io.writeConfigFile(nextConfig, { + baseSnapshot: snapshot, + ...(targetAgentId === "research" ? { allowedAgentRosterRemovals: ["ops"] } : {}), + explicitSetPaths: [ + ["agents", "entries"], + ...(explicit ? [["agents", "defaults", "authInheritance"]] : []), + ], + explicitSetValueSource: nextConfig, + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(persisted.agents?.defaults?.authInheritance?.agentId).toBe(expected); + }, + ); + + it("refuses to remove an inherited-auth owner with a custom agentDir", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-custom-auth-owner-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + const customAgentDir = path.join(root, "custom-ops-agent"); + await fs.writeFile( + configPath, + JSON.stringify({ agents: { entries: { ops: { agentDir: customAgentDir } } } }), + ); + const io = createConfigIO({ + configPath, + env: { HOME: root, OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + const snapshot = await io.readConfigFileSnapshot(); + + await expect( + io.writeConfigFile( + { + ...snapshot.config, + agents: { ownership: "explicit", entries: { research: {} } }, + }, + { baseSnapshot: snapshot, allowedAgentRosterRemovals: ["ops"] }, + ), + ).rejects.toMatchObject({ + code: "CONFIG_WRITE_REJECTED", + message: expect.stringContaining("set agents.defaults.authInheritance explicitly"), + }); + }); + + it("preserves migrated legacy ownership during an unrelated write", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-legacy-owner-roundtrip-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + entries: { + ops: {}, + research: { default: true }, + }, + }, + gateway: { port: 18789 }, + }), + ); + const io = createConfigIO({ + configPath, + env: { HOME: root, OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(tryResolveLegacyCompatibilityAgentId(snapshot.config)).toBe("research"); + + await io.writeConfigFile( + { ...snapshot.config, gateway: { ...snapshot.config.gateway, port: 19001 } }, + { baseSnapshot: snapshot, explicitSetPaths: [["gateway", "port"]] }, + ); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(persisted.agents?.ownership).toBeUndefined(); + expect(persisted.agents?.entries?.research?.default).toBe(true); + const reread = await io.readConfigFileSnapshot(); + expect(tryResolveLegacyCompatibilityAgentId(reread.config)).toBe("research"); }); }); diff --git a/src/commands/doctor/shared/plugin-metadata-snapshot-scope.ts b/src/commands/doctor/shared/plugin-metadata-snapshot-scope.ts index 9c8720186a04..83f90904c6c2 100644 --- a/src/commands/doctor/shared/plugin-metadata-snapshot-scope.ts +++ b/src/commands/doctor/shared/plugin-metadata-snapshot-scope.ts @@ -1,3 +1,4 @@ +import { resolveConfigWidePluginManifestRegistry } from "../../../config/io.plugin-metadata.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { withPluginMetadataSnapshotScope, @@ -7,6 +8,7 @@ import { completePluginMetadataSnapshot, isPluginMetadataSnapshotCompatible, loadPluginMetadataSnapshot, + rebasePluginMetadataSnapshotManifestRegistry, type PluginMetadataSnapshot, } from "../../../plugins/plugin-metadata-snapshot.js"; @@ -19,13 +21,43 @@ type DoctorPluginMetadataSnapshotScope = { invalidate: () => void; }; +const configWideDoctorSnapshots = new WeakSet(); + +/** Aligns Doctor's immutable snapshot view with config-wide agent workspace discovery. */ +export function resolveConfigWideDoctorPluginMetadataSnapshot(params: { + snapshot: PluginMetadataSnapshot; + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): PluginMetadataSnapshot { + if (configWideDoctorSnapshots.has(params.snapshot)) { + return params.snapshot; + } + const manifestRegistry = resolveConfigWidePluginManifestRegistry({ + config: params.config, + env: params.env, + // Doctor calls this after filesystem repairs; the process-current snapshot + // may describe the pre-repair manifest and must not restore stale owners. + allowCurrent: false, + }); + const snapshot = rebasePluginMetadataSnapshotManifestRegistry(params.snapshot, manifestRegistry); + configWideDoctorSnapshots.add(snapshot); + return snapshot; +} + /** Promotes validation-scoped metadata to a complete immutable Doctor snapshot. */ export function completeDoctorPluginMetadataSnapshot(params: { snapshot?: PluginMetadataSnapshot; config: OpenClawConfig; env?: NodeJS.ProcessEnv; }): PluginMetadataSnapshot | undefined { - return completePluginMetadataSnapshot(params); + const snapshot = completePluginMetadataSnapshot(params); + return snapshot + ? resolveConfigWideDoctorPluginMetadataSnapshot({ + snapshot, + config: params.config, + env: params.env, + }) + : undefined; } /** Reuses one exact immutable plugin metadata generation per Doctor workspace. */ @@ -63,12 +95,22 @@ export function createDoctorPluginMetadataSnapshotScope(params: { workspaceDir, }) ) { - return current; + const snapshot = resolveConfigWideDoctorPluginMetadataSnapshot({ + snapshot: current, + config, + env, + }); + snapshotsByWorkspace.set(workspaceDir, snapshot); + return snapshot; } - const snapshot = loadPluginMetadataSnapshot({ + const snapshot = resolveConfigWideDoctorPluginMetadataSnapshot({ + snapshot: loadPluginMetadataSnapshot({ + config, + env, + ...(workspaceDir ? { workspaceDir } : {}), + }), config, env, - ...(workspaceDir ? { workspaceDir } : {}), }); snapshotsByWorkspace.set(workspaceDir, snapshot); return snapshot; diff --git a/src/commands/health.ts b/src/commands/health.ts index 150fa0247b3f..19f708cd435c 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -390,7 +390,9 @@ export async function healthCommand( const preferred = resolvePreferredAccountId({ accountIds, defaultAccountId, - boundAccounts: channelBindings.get(plugin.id)?.get(defaultAgentId) ?? [], + boundAccounts: defaultAgentId + ? (channelBindings.get(plugin.id)?.get(defaultAgentId) ?? []) + : [], }); return [plugin.id, [preferred] as string[]] as const; }), @@ -455,7 +457,9 @@ export async function healthCommand( if (!plugin.status?.logSelfId) { continue; } - const boundAccounts = channelBindings.get(plugin.id)?.get(defaultAgentId) ?? []; + const boundAccounts = defaultAgentId + ? (channelBindings.get(plugin.id)?.get(defaultAgentId) ?? []) + : []; const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, diff --git a/src/commands/onboard-agent-target.test.ts b/src/commands/onboard-agent-target.test.ts index 16b3b0aa674e..187c3955ef3e 100644 --- a/src/commands/onboard-agent-target.test.ts +++ b/src/commands/onboard-agent-target.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import type { RuntimeEnv } from "../runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; import { @@ -13,6 +14,18 @@ import { const tempDirs = useAutoCleanupTempDirTracker(afterEach); describe("onboarding agent target", () => { + it("uses the retained compatibility owner after the marker is removed", () => { + const config = retainLegacyDefaultAgentId( + { agents: { entries: { main: {}, ops: { workspace: "/srv/ops" } } } }, + "ops", + ); + + expect(resolveOnboardingAgentTarget(config)).toMatchObject({ + agentId: "ops", + workspaceDir: "/srv/ops", + }); + }); + it("provisions the configured default agent workspace and sessions", async () => { const stateDir = tempDirs.make("openclaw-onboard-target-"); const globalWorkspace = path.join(stateDir, "global-workspace"); diff --git a/src/commands/onboard-agent-target.ts b/src/commands/onboard-agent-target.ts index 2e86cb658893..2f479ab0acf8 100644 --- a/src/commands/onboard-agent-target.ts +++ b/src/commands/onboard-agent-target.ts @@ -2,8 +2,9 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentId, + resolveSoleAgentId, } from "../agents/agent-scope-config.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { normalizeAgentModelMapForConfig, normalizeAgentModelRefForConfig, @@ -26,7 +27,9 @@ export function resolveOnboardingAgentTarget( config: OpenClawConfig, explicitAgentId?: string, ): OnboardingAgentTarget { - const agentId = normalizeAgentId(explicitAgentId ?? resolveDefaultAgentId(config)); + const agentId = normalizeAgentId( + explicitAgentId ?? tryResolveLegacyCompatibilityAgentId(config) ?? resolveSoleAgentId(config), + ); return { agentId, agentDir: resolveAgentDir(config, agentId), diff --git a/src/commands/onboard-agent.test.ts b/src/commands/onboard-agent.test.ts index c5e635679c64..a8a32275ab67 100644 --- a/src/commands/onboard-agent.test.ts +++ b/src/commands/onboard-agent.test.ts @@ -54,9 +54,11 @@ describe("onboarding main-agent creation", () => { expect(mocks.createAgent).toHaveBeenCalledWith( expect.objectContaining({ - entry: expect.objectContaining({ id: "main", default: true }), + entry: expect.objectContaining({ id: "main" }), + bootstrapMain: true, }), ); + expect(mocks.createAgent.mock.calls[0]?.[0]?.entry).not.toHaveProperty("default"); expect(result).toMatchObject({ agentId: "main", config: { diff --git a/src/commands/onboard-agent.ts b/src/commands/onboard-agent.ts index 7c038390aed7..ce2b1b3356e8 100644 --- a/src/commands/onboard-agent.ts +++ b/src/commands/onboard-agent.ts @@ -4,6 +4,7 @@ import { listAgentEntries, resolveDefaultAgentId, toAgentEntriesRecord, + tryResolveLegacyCompatibilityAgentId, } from "../agents/agent-scope-config.js"; import { readConfigFileSnapshot } from "../config/config.js"; import { createMergePatch } from "../config/merge-patch.js"; @@ -64,7 +65,8 @@ export async function ensureOnboardingAgent(params: { ) { return { config: params.config, - agentId: resolveDefaultAgentId(params.config), + agentId: + tryResolveLegacyCompatibilityAgentId(params.config) ?? resolveDefaultAgentId(params.config), bootstrapPending: false, }; } @@ -81,7 +83,7 @@ export async function ensureOnboardingAgent(params: { candidate: params.config, currentRuntime: effective, }), - agentId: resolveDefaultAgentId(effective), + agentId: tryResolveLegacyCompatibilityAgentId(effective) ?? resolveDefaultAgentId(effective), bootstrapPending: false, }; } @@ -89,9 +91,9 @@ export async function ensureOnboardingAgent(params: { entry: { id: "main", name: "main", - default: true, workspace: params.workspace, }, + bootstrapMain: true, skipBootstrap: params.config.agents?.defaults?.skipBootstrap, skipOptionalBootstrapFiles: params.config.agents?.defaults?.skipOptionalBootstrapFiles, }); diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts index 2ca233c1316d..b47429484a24 100644 --- a/src/commands/onboard-inference.ts +++ b/src/commands/onboard-inference.ts @@ -3,7 +3,7 @@ import { randomInt } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; -import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { resolveAgentConfig } from "../agents/agent-scope-config.js"; import { formatCliBackendVersionAdvisory, resolveCliBackendVersionGuidance, @@ -15,6 +15,7 @@ import { readGeminiCliCredentialsCached, } from "../agents/cli-credentials.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { probeLocalCommand, type LocalCommandProbe } from "../system-agent/probes.js"; @@ -55,6 +56,7 @@ type DetectInferenceBackendsDeps = { type DetectInferenceBackendsOptions = { config?: OpenClawConfig; + agentId?: string; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; deps?: DetectInferenceBackendsDeps; @@ -234,10 +236,13 @@ export async function detectInferenceBackends( (() => readGeminiCliCredentialsCached({ ttlMs: 60_000 })); const candidates: InferenceBackendCandidate[] = []; - const defaultAgentId = options.config ? resolveDefaultAgentId(options.config) : undefined; - const defaultAgentModel = options.config - ? resolveAgentConfig(options.config, resolveDefaultAgentId(options.config))?.model + const defaultAgentId = options.config + ? options.agentId?.trim() || tryResolveLegacyCompatibilityAgentId(options.config) : undefined; + const defaultAgentModel = + options.config && defaultAgentId + ? resolveAgentConfig(options.config, defaultAgentId)?.model + : undefined; const existingModel = resolveAgentModelPrimaryValue(defaultAgentModel) ?? resolveAgentModelPrimaryValue(options.config?.agents?.defaults?.model); diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 69a09e8ff0cc..365631ab34a6 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -497,7 +497,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const warningRuntime = { ...runtime, error: vi.fn() }; const passwordRef = { source: "env" as const, provider: "default", id: "GATEWAY_PASSWORD" }; const seededAgents = [ - { id: "alpha", model: "anthropic/claude-3-5-sonnet" }, + { id: "alpha", default: true, model: "anthropic/claude-3-5-sonnet" }, { id: "beta", model: "openai/gpt-4o" }, ]; const seededBindings = [ diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index fad3beeaafc3..16281e09fdf9 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -173,6 +173,7 @@ export async function runNonInteractiveLocalSetup(params: { }) { const { opts, runtime, baseConfig, baseHash } = params; const mode = "local" as const; + const selectedAgentId = resolveOnboardingAgentTarget(baseConfig).agentId; const requestedWorkspaceDir = resolveNonInteractiveWorkspaceDir({ opts, @@ -201,7 +202,7 @@ export async function runNonInteractiveLocalSetup(params: { } // Workspace defaults are already staged above; provider discovery must use // that requested owner before first-agent creation is allowed to write. - const authTarget = resolveOnboardingAgentTarget(nextConfig); + const authTarget = resolveOnboardingAgentTarget(nextConfig, selectedAgentId); const inferredAuthChoice = opts.authChoice ? undefined @@ -283,7 +284,7 @@ export async function runNonInteractiveLocalSetup(params: { }); logConfigUpdated(runtime); - const finalTarget = resolveOnboardingAgentTarget(nextConfig); + const finalTarget = resolveOnboardingAgentTarget(nextConfig, selectedAgentId); await ensureOnboardingAgentWorkspace(finalTarget, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles, diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index d1d565fff957..72b97cd2b697 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -199,6 +199,7 @@ export async function sandboxExplainCommand( normalizeOptionalString(sessionEntry?.spawnedCwd) ?? effectiveAgentWorkspaceDir; const workspaceLayout = resolveSandboxWorkspaceLayoutPaths({ cfg: sandboxCfg, + agentId: resolvedAgentId, rawSessionKey: sessionKey === "global" ? buildAgentMainSessionKey({ diff --git a/src/commands/sessions-tail.test.ts b/src/commands/sessions-tail.test.ts index ac1138759948..e14c2fb3691c 100644 --- a/src/commands/sessions-tail.test.ts +++ b/src/commands/sessions-tail.test.ts @@ -132,7 +132,7 @@ describe("sessionsTailCommand", () => { }), ]); - await sessionsTailCommand({ store: storePath, sessionKey }, runtime); + await sessionsTailCommand({ agent: "main", store: storePath, sessionKey }, runtime); const output = vi .mocked(runtime.log) @@ -165,7 +165,7 @@ describe("sessionsTailCommand", () => { }), ]); - await sessionsTailCommand({ store: storePath, sessionKey, tail: "2" }, runtime); + await sessionsTailCommand({ agent: "main", store: storePath, sessionKey, tail: "2" }, runtime); const output = vi .mocked(runtime.log) @@ -179,7 +179,10 @@ describe("sessionsTailCommand", () => { it("rejects tail counts that exceed JavaScript safe integer precision", async () => { const runtime = makeRuntime(); - await sessionsTailCommand({ store: storePath, sessionKey, tail: "9007199254740992" }, runtime); + await sessionsTailCommand( + { agent: "main", store: storePath, sessionKey, tail: "9007199254740992" }, + runtime, + ); expect(runtime.error).toHaveBeenCalledWith( "--tail must be a non-negative integer, for example --tail 25.", @@ -199,7 +202,7 @@ describe("sessionsTailCommand", () => { }), ]); - await sessionsTailCommand({ store: storePath, sessionKey }, runtime); + await sessionsTailCommand({ agent: "main", store: storePath, sessionKey }, runtime); const output = runtimeOutput(runtime); expect(output).toContain("tool.result"); @@ -230,7 +233,7 @@ describe("sessionsTailCommand", () => { }), ]); - await sessionsTailCommand({ store: storePath, sessionKey }, runtime); + await sessionsTailCommand({ agent: "main", store: storePath, sessionKey }, runtime); const output = runtimeOutput(runtime); expect(output).toContain("current ok"); @@ -266,7 +269,7 @@ describe("sessionsTailCommand", () => { }); const run = sessionsTailCommand( - { store: storePath, sessionKey, tail: "1", follow: true }, + { agent: "main", store: storePath, sessionKey, tail: "1", follow: true }, runtime, ); try { diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 684c63f0ea64..cddaa8133815 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -340,6 +340,49 @@ describe("sessionsCommand", () => { expect(main?.runtimePolicySessionKey).toBe("agent:main:telegram:default:direct:42"); }); + it("projects a bare row with its resolved fixed-store owner", async () => { + const store = await writeStore( + { + global: { + sessionId: "telegram-global", + updatedAt: Date.now() - 60_000, + delivery: normalizeSessionDeliveryState({ + origin: { + provider: "telegram", + chatType: "direct", + to: "telegram:42", + accountId: "default", + }, + }), + }, + }, + "sessions-runtime-policy-owner", + { agentId: "ops" }, + ); + setMockSessionsConfig(() => ({ + session: { scope: "global", store }, + agents: { + ownership: "explicit", + defaults: { + model: { primary: "test:opus" }, + models: { "test:opus": {} }, + contextTokens: 32000, + sessionStore: { agentId: "ops" }, + }, + entries: { ops: {}, research: {} }, + }, + })); + + const payload = await runSessionsJson<{ + sessions?: Array<{ agentId?: string; key: string; runtimePolicySessionKey?: string }>; + }>(sessionsCommand, store, { active: "10" }); + + expect(payload.sessions?.find((row) => row.key === "global")).toMatchObject({ + agentId: "ops", + runtimePolicySessionKey: "agent:ops:telegram:default:direct:42", + }); + }); + it("uses a default JSON output limit of 100 sessions", async () => { const entries = Object.fromEntries( Array.from({ length: 101 }, (_, index) => [ diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index ece1c55d75a0..fe837568dc40 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -253,6 +253,7 @@ function stripChannelRecipientPrefix( } function resolveDisplayRuntimePolicySessionKey(params: { + agentId: string; cfg: OpenClawConfig; key: string; entry: SessionEntry; @@ -279,10 +280,12 @@ function resolveDisplayRuntimePolicySessionKey(params: { // Direct-message runtime policy can route by native user id, stripped // recipient, or sender; expose the derived key when it differs from the row. const runtimePolicySessionKey = resolveRuntimePolicySessionKey({ + agentId: params.agentId, cfg, sessionKey: key, ctx: { SessionKey: key, + AgentId: params.agentId, Provider: channel, Surface: normalizeOptionalString(origin?.surface), AccountId: normalizeOptionalString(origin?.accountId ?? deliveryContext?.accountId), @@ -404,6 +407,7 @@ export async function sessionsCommand( displayModelRef: modelRef, kind: classifySessionKind(row.key, entry), runtimePolicySessionKey: resolveDisplayRuntimePolicySessionKey({ + agentId, cfg, key: row.key, entry, diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index bd6058a19247..8ebe5433274e 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -78,7 +78,7 @@ describe("setupCommand", () => { defaults: { workspace, }, - entries: { main: { default: true, workspace } }, + entries: { main: {} }, }, gateway: { mode: "local", @@ -155,7 +155,8 @@ describe("setupCommand", () => { await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), ) as OpenClawConfig; expect(resolveAgentWorkspaceDir(config, "main")).toBe(nextWorkspace); - expect(config.agents?.entries?.main?.workspace).toBe(nextWorkspace); + expect(config.agents?.defaults?.workspace).toBe(nextWorkspace); + expect(config.agents?.entries?.main).toEqual({}); }); }); @@ -391,7 +392,7 @@ describe("setupCommand", () => { await setupCommand(undefined, runtime, deps); const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; - expect(config.agents?.entries).toEqual({ main: { default: true } }); + expect(config.agents?.entries).toEqual({ main: {} }); }); }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8336d23c26a9..9999b4b7ee93 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -8,7 +8,7 @@ import fs from "node:fs/promises"; import { listAgentEntries, resolveAgentEntry, - resolveDefaultAgentId, + resolveSoleAgentId, toAgentEntriesRecord, } from "../agents/agent-scope-config.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -17,9 +17,11 @@ import { hasResolvedRosterBeforeMigrations, } from "../config/agent-roster-provenance.js"; import type { ConfigWriteOptions, ReadConfigFileSnapshotForWriteResult } from "../config/io.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { migratePersistedImplicitMainRoster } from "../config/legacy.js"; import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime, writeRuntimeJson } from "../runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; @@ -165,7 +167,9 @@ export async function setupCommand( : snapshot.sourceConfig; const authoredDefaults = cfg.agents?.defaults ?? {}; const resolvedDefaults = resolvedConfig.agents?.defaults ?? authoredDefaults; - const defaultEntry = resolveAgentEntry(resolvedConfig, resolveDefaultAgentId(resolvedConfig)); + const selectedAgentId = + tryResolveLegacyCompatibilityAgentId(resolvedConfig) ?? resolveSoleAgentId(resolvedConfig); + const defaultEntry = resolveAgentEntry(resolvedConfig, selectedAgentId); const defaultEntryWorkspace = defaultEntry?.workspace?.trim(); const configuredWorkspace = defaultEntryWorkspace || resolvedDefaults.workspace; @@ -197,9 +201,13 @@ export async function setupCommand( const roster = structuredClone(listAgentEntries(next)); if (!snapshot.exists || Boolean(defaultEntryWorkspace)) { for (const entry of roster) { - if (entry.default === true) { - // Fresh bootstrap and explicitly entry-owned workspaces stay aligned. - // Inherited defaults must not turn an include-owned roster into a roster write. + if ( + snapshot.exists && + defaultEntryWorkspace && + normalizeAgentId(entry.id) === selectedAgentId + ) { + // An explicit workspace follows the resolved setup owner. Fresh and inherited + // workspaces stay in defaults so setup does not duplicate them into the roster. entry.workspace = workspace; } } @@ -288,10 +296,9 @@ export async function setupCommand( runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`); } - const defaultAgentId = resolveDefaultAgentId(next); const sessionsDir = await ( deps.resolveSessionTranscriptsDir ?? resolveDefaultSessionTranscriptsDir - )(defaultAgentId); + )(selectedAgentId); await (deps.mkdir ?? fs.mkdir)(sessionsDir, { recursive: true }); if (opts?.json) { writeRuntimeJson(runtime, { diff --git a/src/config/__snapshots__/schema.help.quality.test.ts.snap b/src/config/__snapshots__/schema.help.quality.test.ts.snap index b7f6a56ad1eb..ea13cc14ad23 100644 --- a/src/config/__snapshots__/schema.help.quality.test.ts.snap +++ b/src/config/__snapshots__/schema.help.quality.test.ts.snap @@ -48,7 +48,6 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`] "agents.defaults.userTimezone", "agents.defaults.voiceModel.primary", "agents.defaults.workspace", - "agents.entries.*.default", "agents.entries.*.groupChat.mentionPatterns.*", "agents.entries.*.groupChat.unmentionedInbound", "agents.entries.*.heartbeat.model", diff --git a/src/config/agent-roster-provenance.ts b/src/config/agent-roster-provenance.ts index 8bc4a38b1623..2ada1a408e17 100644 --- a/src/config/agent-roster-provenance.ts +++ b/src/config/agent-roster-provenance.ts @@ -72,6 +72,16 @@ export function includeContributionOwnsAgentRoster(event: { return false; } +export function includeContributionOwnsBindings(event: { + path: readonly string[]; + value: unknown; +}): boolean { + if (event.path.length === 0) { + return isRecord(event.value) && Object.hasOwn(event.value, "bindings"); + } + return event.path[0] === "bindings"; +} + /** Whether include/env resolution produced a non-empty roster before raw migrations. */ export function hasResolvedRosterBeforeMigrations(snapshot: ConfigFileSnapshot): boolean { return listAgentEntries(snapshot.sourceConfigBeforeMigrations ?? {}).length > 0; diff --git a/src/config/agent-workspace-roster-transition.ts b/src/config/agent-workspace-roster-transition.ts new file mode 100644 index 000000000000..acaa105c2d01 --- /dev/null +++ b/src/config/agent-workspace-roster-transition.ts @@ -0,0 +1,55 @@ +import { + listAgentEntries, + resolveAgentWorkspaceDir, + toAgentEntriesRecord, +} from "../agents/agent-scope-config.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +export function pinSurvivorWorkspaceForRosterCollapse( + sourceConfig: OpenClawConfig, + targetConfig: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): { config: OpenClawConfig; insertedPaths: string[][] } { + const sourceEntries = listAgentEntries(sourceConfig); + const targetEntries = listAgentEntries(targetConfig); + if (sourceEntries.length <= 1 || targetEntries.length !== 1) { + return { config: targetConfig, insertedPaths: [] }; + } + + const survivorId = normalizeAgentId(targetEntries[0]!.id); + if (!sourceEntries.some((entry) => normalizeAgentId(entry.id) === survivorId)) { + return { config: targetConfig, insertedPaths: [] }; + } + + const targetAgents = targetConfig.agents ?? {}; + const entries = targetAgents.entries + ? { ...targetAgents.entries } + : toAgentEntriesRecord(targetEntries); + const entryKey = Object.keys(entries).find( + (candidate) => normalizeAgentId(candidate) === survivorId, + ); + const entry = entryKey ? entries[entryKey] : undefined; + const workspaceNeedsPin = + entry !== undefined && + (!Object.hasOwn(entry, "workspace") || + (typeof entry.workspace === "string" && entry.workspace.trim().length === 0)); + if (!entryKey || !entry || !workspaceNeedsPin) { + return { config: targetConfig, insertedPaths: [] }; + } + + // Resolve against the old multi-agent topology before sole-agent inheritance + // can move the survivor from its per-agent workspace to the shared root. + entries[entryKey] = { + ...entry, + workspace: resolveAgentWorkspaceDir(sourceConfig, survivorId, env), + }; + const { list: _legacyList, ...canonicalAgents } = targetAgents; + return { + config: { + ...targetConfig, + agents: { ...canonicalAgents, entries }, + }, + insertedPaths: [["agents", "entries", entryKey, "workspace"]], + }; +} diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index eae6652732af..daef243ed488 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -1050,6 +1050,7 @@ describe("broadcast", () => { it("accepts a broadcast peer map with strategy", () => { const res = validateConfigObject({ agents: { + ownership: "explicit", entries: { alfred: {}, baerbel: {} }, }, broadcast: { diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index d92307262d19..d7c55b0bed89 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { clearLoadInstalledPluginIndexInstallRecordsCache } from "../plugins/installed-plugin-index-records.js"; import { writePersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js"; import { shouldSuppressMissingCodexPluginDiagnostics } from "./codex-plugin-diagnostics.js"; +import { resolveConfigWidePluginManifestRegistry } from "./io.plugin-metadata.js"; import { validateConfigObjectWithPlugins as validateConfigObjectWithPluginsRaw } from "./validation.js"; vi.unmock("../version.js"); @@ -604,6 +605,7 @@ describe("config plugin validation", () => { it("warns when a listed agent can fall back from gpt-5.6 to Spark", () => { const res = validateWithMissingCodexPlugin({ agents: { + ownership: "explicit", defaults: { model: { primary: "openai/gpt-5.6", fallbacks: [] }, }, @@ -639,6 +641,7 @@ describe("config plugin validation", () => { { name: "listed-agent subagent", agents: { + ownership: "explicit" as const, defaults: { model: { primary: "openai/gpt-5.6", fallbacks: [] }, subagents: { model: "openai/gpt-5.6" }, @@ -899,6 +902,7 @@ describe("config plugin validation", () => { }, }, agents: { + ownership: "explicit", defaults: { model: { primary: "openai/gpt-5.6", fallbacks: [] }, models: { @@ -1895,6 +1899,48 @@ describe("config plugin validation", () => { } }); + it("discovers legacy-root workspace plugins before ownership materialization", async () => { + const workspaceDir = path.join(fixtureRoot, "legacy-root-workspace"); + const pluginId = "legacy-root-channel"; + const channelId = "legacy-root"; + await writePluginFixture({ + dir: path.join(workspaceDir, ".openclaw", "extensions", pluginId), + id: pluginId, + channels: [channelId], + schema: { type: "object" }, + }); + const env = suiteEnv(); + + const res = validateConfigObjectWithPlugins( + { + agents: { + defaults: { workspace: workspaceDir }, + entries: { ops: { default: true }, research: {} }, + }, + channels: { [channelId]: {} }, + plugins: { entries: { [pluginId]: { enabled: true } } }, + }, + { + env, + loadPluginMetadataSnapshot: (config) => ({ + manifestRegistry: resolveConfigWidePluginManifestRegistry({ + config, + env, + allowCurrent: false, + }), + }), + }, + ); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.bindings).toContainEqual({ + agentId: "ops", + match: { channel: channelId, accountId: "*" }, + }); + } + }); + it("surfaces plugin config diagnostics", () => { const res = validateInSuite({ agents: { list: [{ id: "openclaw" }] }, diff --git a/src/config/io.auth-inheritance-owner.ts b/src/config/io.auth-inheritance-owner.ts new file mode 100644 index 000000000000..07d3b4c16e61 --- /dev/null +++ b/src/config/io.auth-inheritance-owner.ts @@ -0,0 +1,38 @@ +import { + assertSafeLegacyInheritedAuthDirTransition, + pinLegacyInheritedAuthOwnerForRosterTransition, +} from "../agents/legacy-inherited-auth-dir.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +const AUTH_INHERITANCE_PATH = "agents.defaults.authInheritance"; + +function explicitlySetsAuthInheritance(explicitSetPaths?: readonly (readonly string[])[]): boolean { + return Boolean( + explicitSetPaths?.some((writePath) => { + const path = writePath.join("."); + return path === AUTH_INHERITANCE_PATH || path.startsWith(`${AUTH_INHERITANCE_PATH}.`); + }), + ); +} + +export function prepareAuthInheritanceOwnerForWrite(params: { + currentConfig: OpenClawConfig; + targetConfig: OpenClawConfig; + writesOwnershipTopology: boolean; + explicitSetPaths?: readonly (readonly string[])[]; + env?: NodeJS.ProcessEnv; +}): { config: OpenClawConfig; insertedPaths: string[][] } { + if (!params.writesOwnershipTopology || explicitlySetsAuthInheritance(params.explicitSetPaths)) { + return { config: params.targetConfig, insertedPaths: [] }; + } + assertSafeLegacyInheritedAuthDirTransition(params.currentConfig, params.targetConfig, params.env); + const config = pinLegacyInheritedAuthOwnerForRosterTransition( + params.currentConfig, + params.targetConfig, + ); + return { + config, + insertedPaths: + config === params.targetConfig ? [] : [["agents", "defaults", "authInheritance", "agentId"]], + }; +} diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index b5025df79595..53a1270a3a68 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -196,7 +196,7 @@ describe("readBestEffortConfig", () => { expect(snapshot.sourceConfigBeforeMigrations).toEqual({ update: { channel: "beta" } }); expect(snapshot.sourceConfig).toEqual({ update: { channel: "beta" }, - agents: { entries: { main: { default: true } } }, + agents: { entries: { main: {} } }, }); expect(await fs.readFile(configPath, "utf-8")).toBe(directEditRaw); const entries = await fs.readdir(`${home}/.openclaw`); diff --git a/src/config/io.context.plugin-metadata.test.ts b/src/config/io.context.plugin-metadata.test.ts new file mode 100644 index 000000000000..ce982fe5b1d1 --- /dev/null +++ b/src/config/io.context.plugin-metadata.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; + +const mocks = vi.hoisted(() => ({ + resolvePluginMetadataSnapshot: vi.fn(), + resolveConfigWidePluginManifestRegistry: vi.fn(), +})); + +vi.mock("../plugins/plugin-metadata-snapshot.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolvePluginMetadataSnapshot: mocks.resolvePluginMetadataSnapshot, +})); + +vi.mock("./io.plugin-metadata.js", () => ({ + resolveConfigWidePluginManifestRegistry: mocks.resolveConfigWidePluginManifestRegistry, +})); + +const { resolveReadOnlyChannelPluginsForConfig } = await import("../channels/plugins/read-only.js"); +const { createConfigIoContext } = await import("./io.context.js"); + +function manifestRecord(params: { + id: string; + source: string; + channels?: string[]; +}): PluginManifestRecord { + return { + id: params.id, + name: params.id, + description: "test plugin", + version: "1.0.0", + source: params.source, + origin: "workspace", + channels: params.channels ?? [], + } as PluginManifestRecord; +} + +describe("config IO plugin metadata snapshots", () => { + beforeEach(() => { + mocks.resolvePluginMetadataSnapshot.mockReset(); + mocks.resolveConfigWidePluginManifestRegistry.mockReset(); + }); + + it("feeds merged workspace plugins to snapshot-backed read-only discovery", () => { + const primary = manifestRecord({ id: "primary", source: "/srv/ops/primary" }); + const secondary = manifestRecord({ + id: "research-chat-plugin", + source: "/srv/research/research-chat-plugin", + channels: ["research-chat"], + }); + const primaryRegistry = { plugins: [primary], diagnostics: [] }; + const mergedRegistry = { plugins: [primary, secondary], diagnostics: [] }; + mocks.resolvePluginMetadataSnapshot.mockReturnValue({ + plugins: primaryRegistry.plugins, + manifestRegistry: primaryRegistry, + } as unknown as PluginMetadataSnapshot); + mocks.resolveConfigWidePluginManifestRegistry.mockReturnValue(mergedRegistry); + const cfg = { + agents: { + ownership: "explicit" as const, + entries: { + ops: { workspace: "/srv/ops" }, + research: { workspace: "/srv/research" }, + }, + }, + channels: { "research-chat": { enabled: true } }, + plugins: { + allow: ["research-chat-plugin"], + entries: { "research-chat-plugin": { enabled: true } }, + }, + }; + const context = createConfigIoContext({ env: {}, observe: false }); + const loader = context.createValidationPluginMetadataSnapshotLoader({ + effectiveConfigRaw: cfg, + env: {}, + }); + loader.load(cfg); + const snapshot = loader.getSnapshot(); + + expect(snapshot?.plugins).toEqual(mergedRegistry.plugins); + expect(snapshot?.byPluginId.get("research-chat-plugin")).toBe(secondary); + expect(loader.getSnapshot()).toBe(snapshot); + expect( + resolveReadOnlyChannelPluginsForConfig(cfg, { + env: {}, + metadataSnapshot: snapshot, + }).plugins.map((plugin) => plugin.id), + ).toContain("research-chat"); + }); +}); diff --git a/src/config/io.context.ts b/src/config/io.context.ts index 6c9fff68db6c..52fde213cf1a 100644 --- a/src/config/io.context.ts +++ b/src/config/io.context.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import { collectManifestModelIdNormalizationPolicies } from "@openclaw/model-catalog-core/provider-model-id-normalization"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { tryResolveConfiguredAgentWorkspaceDir } from "../agents/agent-scope.js"; import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; import { classifyOtelGrpcMigrationOwnership } from "../commands/doctor/shared/include-migration-ownership.js"; import { applyLegacyDoctorMigrations } from "../commands/doctor/shared/legacy-config-compat.js"; @@ -11,14 +11,17 @@ import { shouldEnableShellEnvFallback, } from "../infra/shell-env.js"; import { createConfigValidationMetadataPluginIdScope } from "../plugins/gateway-startup-plugin-ids.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { + rebasePluginMetadataSnapshotManifestRegistry, resolvePluginMetadataSnapshot, - type PluginMetadataSnapshot, } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; import { applyConfigEnvVars, cloneEnvWithPlatformSemantics } from "./config-env-vars.js"; import { observeConfigSnapshotSync } from "./io.observe.js"; import { retainGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js"; +import { resolveConfigWidePluginManifestRegistry } from "./io.plugin-metadata.js"; import { coerceConfig, normalizeConfigIoDeps, @@ -34,6 +37,7 @@ import type { NormalizedConfigIoDeps, } from "./io.types.js"; import { formatConfigIssueSummary } from "./issue-format.js"; +import { inheritLegacyDefaultAgentId } from "./legacy.default-agent-owner.js"; import { migratePersistedImplicitMainRoster } from "./legacy.roster.js"; import { materializeRuntimeConfig } from "./materialize.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; @@ -42,7 +46,8 @@ import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; type ValidationPluginMetadataSnapshotLoader = { - load: (config: OpenClawConfig) => PluginMetadataSnapshot; + load: (config: OpenClawConfig) => Pick; + getManifestRegistry: () => PluginManifestRegistry | undefined; getSnapshot: () => PluginMetadataSnapshot | undefined; }; @@ -95,7 +100,7 @@ export function createConfigIoContext(options: ConfigIoFactoryOptions = {}): Con cfg, () => pendingValue ?? crypto.randomBytes(32).toString("hex"), ); - return applyConfigOverrides( + const finalized = applyConfigOverrides( retainGeneratedOwnerDisplaySecret({ config: resolvedConfig, configPath, @@ -103,6 +108,7 @@ export function createConfigIoContext(options: ConfigIoFactoryOptions = {}): Con state: { pendingByPath: autoOwnerDisplaySecretByPath }, }), ); + return inheritLegacyDefaultAgentId(cfg, finalized); } function createValidationPluginMetadataSnapshotLoader(params: { @@ -110,28 +116,47 @@ export function createConfigIoContext(options: ConfigIoFactoryOptions = {}): Con env: NodeJS.ProcessEnv; allowCurrentPluginMetadata?: boolean; }): ValidationPluginMetadataSnapshotLoader { + let metadataConfig: OpenClawConfig | undefined; + let manifestRegistry: PluginManifestRegistry | undefined; let snapshot: PluginMetadataSnapshot | undefined; + let configWideSnapshot: PluginMetadataSnapshot | undefined; + const resolvePluginIdScope = (config: OpenClawConfig) => + createConfigValidationMetadataPluginIdScope({ + config, + env: params.env, + }); return { load: (config) => { - if (snapshot) { - return snapshot; + if (manifestRegistry) { + return { manifestRegistry }; } - const metadataConfig = config; - const defaultAgentId = resolveDefaultAgentId(metadataConfig); - snapshot = resolvePluginMetadataSnapshot({ + metadataConfig = config; + manifestRegistry = resolveConfigWidePluginManifestRegistry({ + config, + env: params.env, + allowCurrent: params.allowCurrentPluginMetadata, + pluginIdScope: resolvePluginIdScope(config), + }); + return { manifestRegistry }; + }, + getManifestRegistry: () => manifestRegistry, + getSnapshot: () => { + if (!metadataConfig) { + return undefined; + } + snapshot ??= resolvePluginMetadataSnapshot({ config: metadataConfig, - workspaceDir: resolveAgentWorkspaceDir(metadataConfig, defaultAgentId, params.env), + workspaceDir: tryResolveConfiguredAgentWorkspaceDir(metadataConfig, params.env), env: params.env, allowCurrent: params.allowCurrentPluginMetadata, allowWorkspaceScopedCurrent: true, - pluginIdScope: createConfigValidationMetadataPluginIdScope({ - config: metadataConfig, - env: params.env, - }), + pluginIdScope: resolvePluginIdScope(metadataConfig), }); - return snapshot; + configWideSnapshot ??= manifestRegistry + ? rebasePluginMetadataSnapshotManifestRegistry(snapshot, manifestRegistry) + : snapshot; + return configWideSnapshot; }, - getSnapshot: () => snapshot, }; } @@ -248,9 +273,9 @@ export function materializeConfigForLoad( _context: ConfigIoContext, config: OpenClawConfig, _effectiveConfigRaw: unknown, - pluginMetadata: PluginMetadataSnapshot | undefined, + manifestRegistry: PluginManifestRegistry | undefined, ): OpenClawConfig { return materializeRuntimeConfig(config, "load", { - manifestRegistry: pluginMetadata?.manifestRegistry, + manifestRegistry, }); } diff --git a/src/config/io.cron-owner-refusal.test.ts b/src/config/io.cron-owner-refusal.test.ts new file mode 100644 index 000000000000..0f47bde7c433 --- /dev/null +++ b/src/config/io.cron-owner-refusal.test.ts @@ -0,0 +1,43 @@ +import { expect, it, vi } from "vitest"; +import type { LegacyCronRepairState } from "../commands/doctor/cron/legacy-repair.js"; +import { prepareCronOwnerWriteRefusal } from "./io.cron-owner-refusal.js"; +import { assertAutomaticBindingsWriteAllowed } from "./io.ownership-write-guard.js"; + +const state = (rawJobs: Array>) => + ({ rawJobs, projectedOwnersByJobId: new Map() }) as unknown as LegacyCronRepairState; +const deps = (activeGateway?: { pid: number; port: number }, jobs?: Record[]) => ({ + readActiveGatewayLockIdentity: vi.fn(async () => + activeGateway ? { ...activeGateway, createdAt: new Date(0).toISOString() } : undefined, + ), + loadLegacyCronRepairState: vi.fn(async () => (jobs ? state(jobs) : null)), +}); + +it("refuses unsafe ownership writes and rechecks at commit", async () => { + const injected = deps({ pid: process.pid + 1, port: 18_789 }); + await expect( + prepareCronOwnerWriteRefusal({ storePath: "/tmp/cron.json" }, injected), + ).rejects.toThrow("live external Gateway"); + expect(injected.loadLegacyCronRepairState).not.toHaveBeenCalled(); + + await expect( + prepareCronOwnerWriteRefusal( + { storePath: "/tmp/cron.json" }, + deps(undefined, [ + { id: "null", agentId: null }, + { id: "blank", agentId: " " }, + ]), + ), + ).rejects.toThrow("contains 2 ownerless legacy cron job"); + + const commitDeps = deps(undefined, [{ id: "owned", agentId: "ops" }]); + const plan = await prepareCronOwnerWriteRefusal({ storePath: "/tmp/cron.json" }, commitDeps); + commitDeps.loadLegacyCronRepairState.mockResolvedValueOnce(state([{ id: "ownerless" }])); + await expect(plan.recheck()).rejects.toThrow("ownerless legacy cron job"); + + expect(() => + assertAutomaticBindingsWriteAllowed({ + bindingsIncludeOwned: true, + ownershipPaths: [["bindings"]], + }), + ).toThrow("cannot append to $include-owned bindings"); +}); diff --git a/src/config/io.cron-owner-refusal.ts b/src/config/io.cron-owner-refusal.ts new file mode 100644 index 000000000000..80511bfc0cf0 --- /dev/null +++ b/src/config/io.cron-owner-refusal.ts @@ -0,0 +1,77 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { parseAgentSessionKey } from "../routing/session-key.js"; + +type CronOwnerRefusalDeps = Pick< + typeof import("../infra/gateway-lock.js"), + "readActiveGatewayLockIdentity" +> & + Pick; +const RETRY = ' Run "openclaw doctor --fix", then retry.'; + +function refused(message: string, cause?: unknown): Error { + return Object.assign(new Error(message, cause === undefined ? undefined : { cause }), { + code: "CONFIG_WRITE_REJECTED", + }); +} + +function hasOwner(record: Record | undefined): boolean { + if (!record) { + return false; + } + return Boolean( + normalizeOptionalString(record.agentId) || + parseAgentSessionKey(normalizeOptionalString(record.sessionKey))?.agentId, + ); +} + +async function loadDefaultDeps(): Promise { + const [{ readActiveGatewayLockIdentity }, { loadLegacyCronRepairState }] = await Promise.all([ + import("../infra/gateway-lock.js"), + import("../commands/doctor/cron/legacy-repair.js"), + ]); + return { readActiveGatewayLockIdentity, loadLegacyCronRepairState }; +} + +async function assertSafe( + storePath: string, + env: NodeJS.ProcessEnv, + deps: CronOwnerRefusalDeps, +): Promise { + const active = await deps.readActiveGatewayLockIdentity({ env }).catch((error: unknown) => { + throw refused(`Config write refused: cannot inspect the Gateway lock.${RETRY}`, error); + }); + if (active && active.pid !== process.pid) { + throw refused( + `Config write refused: live external Gateway pid ${active.pid} may write ownerless cron jobs. Stop it.${RETRY}`, + ); + } + const state = await deps + .loadLegacyCronRepairState({ cfg: {}, storePath, env, readOnly: true }) + .catch((error: unknown) => { + throw refused( + `Config write refused: cannot inspect cron ownership at ${storePath}.${RETRY}`, + error, + ); + }); + const ownerless = + state?.rawJobs.filter((job) => { + const id = normalizeOptionalString(job.id) ?? normalizeOptionalString(job.jobId); + return !hasOwner(job) && !hasOwner(id ? state.projectedOwnersByJobId.get(id) : undefined); + }).length ?? 0; + if (ownerless > 0) { + throw refused( + `Config write refused: cron store ${storePath} contains ${ownerless} ownerless legacy cron job(s).${RETRY}`, + ); + } +} + +export async function prepareCronOwnerWriteRefusal( + params: { storePath: string; env?: NodeJS.ProcessEnv }, + injectedDeps?: CronOwnerRefusalDeps, +): Promise<{ recheck: () => Promise }> { + const env = params.env ?? process.env; + const deps = injectedDeps ?? (await loadDefaultDeps()); + const recheck = () => assertSafe(params.storePath, env, deps); + await recheck(); + return { recheck }; +} diff --git a/src/config/io.load.ts b/src/config/io.load.ts index df01ba722eab..ad90b8a042cc 100644 --- a/src/config/io.load.ts +++ b/src/config/io.load.ts @@ -170,7 +170,7 @@ export function loadConfigFromContext( context, validated.config, effectiveConfigRaw, - pluginMetadata.getSnapshot(), + pluginMetadata.getManifestRegistry(), ); context.observeLoadConfigSnapshot( createConfigFileSnapshot({ diff --git a/src/config/io.ownership-write-guard.ts b/src/config/io.ownership-write-guard.ts new file mode 100644 index 000000000000..b35c5a222dfb --- /dev/null +++ b/src/config/io.ownership-write-guard.ts @@ -0,0 +1,16 @@ +export function assertAutomaticBindingsWriteAllowed(params: { + bindingsIncludeOwned: boolean; + ownershipPaths: readonly (readonly string[])[]; +}): void { + if ( + params.bindingsIncludeOwned && + params.ownershipPaths.some((ownershipPath) => ownershipPath[0] === "bindings") + ) { + throw Object.assign( + new Error( + "Automatic agent ownership materialization cannot append to $include-owned bindings. Add the required channel-wide binding to the include, then retry.", + ), + { code: "CONFIG_WRITE_REJECTED" }, + ); + } +} diff --git a/src/config/io.plugin-metadata.ts b/src/config/io.plugin-metadata.ts new file mode 100644 index 000000000000..217b1d85d0e2 --- /dev/null +++ b/src/config/io.plugin-metadata.ts @@ -0,0 +1,64 @@ +import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshotPluginIdScope } from "../plugins/plugin-metadata-snapshot.types.js"; +import { normalizePluginPolicyId } from "../plugins/plugin-policy-id.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +function mergeRegistries(registries: readonly PluginManifestRegistry[]): PluginManifestRegistry { + const grouped = new Map< + string, + { plugin: PluginManifestRegistry["plugins"][number]; sources: Set } + >(); + const diagnostics = registries.flatMap((registry) => registry.diagnostics); + for (const registry of registries) { + for (const plugin of registry.plugins) { + const id = normalizePluginPolicyId(plugin.id); + const group = grouped.get(id) ?? { plugin, sources: new Set() }; + group.plugin = plugin; + group.sources.add(plugin.source); + grouped.set(id, group); + } + } + const plugins = [...grouped.entries()].flatMap(([pluginId, group]) => { + if (group.sources.size === 1) { + return [group.plugin]; + } + diagnostics.push({ + level: "error", + pluginId, + message: `plugin id ${JSON.stringify(pluginId)} is present in multiple agent workspaces: ${[...group.sources].toSorted().join(", ")}`, + }); + return []; + }); + // Registry order carries origin precedence for channel schema ownership. + // Preserve first discovery order while deduplicating repeated workspace views. + return { plugins, diagnostics }; +} + +export function resolveConfigWidePluginManifestRegistry(params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + stateDir?: string; + allowCurrent?: boolean; + pluginIds?: readonly string[]; + pluginIdScope?: PluginMetadataSnapshotPluginIdScope; +}): PluginManifestRegistry { + const env = params.env ?? process.env; + const dirs = listAgentWorkspaceDirs(params.config, env); + return mergeRegistries( + (dirs.length ? dirs : [undefined]).map( + (workspaceDir) => + resolvePluginMetadataSnapshot({ + config: params.config, + ...(workspaceDir ? { workspaceDir } : {}), + ...(params.stateDir ? { stateDir: params.stateDir } : {}), + env, + allowCurrent: params.allowCurrent, + allowWorkspaceScopedCurrent: true, + ...(params.pluginIds !== undefined ? { pluginIds: params.pluginIds } : {}), + ...(params.pluginIdScope ? { pluginIdScope: params.pluginIdScope } : {}), + }).manifestRegistry, + ), + ); +} diff --git a/src/config/io.session-store-owner.ts b/src/config/io.session-store-owner.ts new file mode 100644 index 000000000000..f60929ca08d4 --- /dev/null +++ b/src/config/io.session-store-owner.ts @@ -0,0 +1,45 @@ +import { isDeepStrictEqual } from "node:util"; +import { isRecord } from "../utils.js"; +import { getConfigValueAtPath, unsetConfigValueAtPath } from "./config-paths.js"; +import { isSameFixedSessionStoreConfig } from "./sessions/session-store-config.js"; +import type { OpenClawConfig } from "./types.js"; + +const SESSION_STORE_OWNER_PATH = ["agents", "defaults", "sessionStore", "agentId"] as const; +const SESSION_STORE_CONFIG_PATH = SESSION_STORE_OWNER_PATH.slice(0, -1); + +export function prepareSessionStoreOwnershipForWrite(params: { + currentConfig: OpenClawConfig; + currentStore: string | undefined; + targetConfig: OpenClawConfig; + env: NodeJS.ProcessEnv; + explicitSetPaths?: readonly (readonly string[])[]; + explicitSetValueSource?: OpenClawConfig; +}): { config: OpenClawConfig; sameFixedSessionStore: boolean } { + const sameFixedSessionStore = isSameFixedSessionStoreConfig( + params.currentStore, + params.targetConfig.session?.store, + params.env, + ); + const previousOwner = params.currentConfig.agents?.defaults?.sessionStore?.agentId; + const explicitSessionStore = getConfigValueAtPath( + (params.explicitSetValueSource ?? params.targetConfig) as Record, + SESSION_STORE_CONFIG_PATH, + ); + const suppliesDestinationOwner = Boolean( + isRecord(explicitSessionStore) && + typeof explicitSessionStore.agentId === "string" && + params.explicitSetPaths?.some( + (entry) => + isDeepStrictEqual(entry, SESSION_STORE_CONFIG_PATH) || + isDeepStrictEqual(entry, SESSION_STORE_OWNER_PATH), + ), + ); + // A compatibility owner belongs to one physical fixed store. Copied runtime config must not + // carry it to another store; only an owner-specific authored path establishes the new owner. + if (sameFixedSessionStore || !previousOwner || suppliesDestinationOwner) { + return { config: params.targetConfig, sameFixedSessionStore }; + } + const agents = structuredClone(params.targetConfig.agents ?? {}); + unsetConfigValueAtPath(agents as Record, SESSION_STORE_OWNER_PATH.slice(1)); + return { config: { ...params.targetConfig, agents }, sameFixedSessionStore }; +} diff --git a/src/config/io.snapshot-shared.ts b/src/config/io.snapshot-shared.ts index f1d6545098d0..1f8bb4371918 100644 --- a/src/config/io.snapshot-shared.ts +++ b/src/config/io.snapshot-shared.ts @@ -8,6 +8,7 @@ export function createConfigFileSnapshot(params: { includedPaths?: readonly string[]; includeProvenance?: ConfigFileSnapshot["includeProvenance"]; agentRosterIncludeOwned?: boolean; + bindingsIncludeOwned?: boolean; exists: boolean; raw: string | null; parsed: unknown; @@ -38,6 +39,9 @@ export function createConfigFileSnapshot(params: { ...(params.agentRosterIncludeOwned !== undefined ? { agentRosterIncludeOwned: params.agentRosterIncludeOwned } : {}), + ...(params.bindingsIncludeOwned !== undefined + ? { bindingsIncludeOwned: params.bindingsIncludeOwned } + : {}), exists: params.exists, raw: params.raw, parsed: params.parsed, diff --git a/src/config/io.snapshot.ts b/src/config/io.snapshot.ts index 0563ee998198..d0399d5d8d00 100644 --- a/src/config/io.snapshot.ts +++ b/src/config/io.snapshot.ts @@ -1,4 +1,7 @@ -import { includeContributionOwnsAgentRoster } from "./agent-roster-provenance.js"; +import { + includeContributionOwnsAgentRoster, + includeContributionOwnsBindings, +} from "./agent-roster-provenance.js"; import { resolveManagedUnsetPathsForWrite } from "./config-path-mutation.js"; import { ConfigIncludeError } from "./includes.js"; import type { ConfigIoContext } from "./io.context.js"; @@ -86,6 +89,7 @@ export async function readConfigFileSnapshotInternal( const includeFilePathsForWatch = new Set(); const includeProvenance: NonNullable[number][] = []; let agentRosterIncludeOwned = false; + let bindingsIncludeOwned = false; try { const raw = await deps.measure("config.snapshot.read.file", () => @@ -133,6 +137,7 @@ export async function readConfigFileSnapshotInternal( const { value: _value, ...ownership } = event; includeProvenance.push(ownership); agentRosterIncludeOwned ||= includeContributionOwnsAgentRoster(event); + bindingsIncludeOwned ||= includeContributionOwnsBindings(event); }, ), ); @@ -212,6 +217,7 @@ export async function readConfigFileSnapshotInternal( parsed: snapshotParsed, includeProvenance, agentRosterIncludeOwned, + bindingsIncludeOwned, sourceConfigBeforeMigrations: coerceConfig(readResolution.resolvedConfigRaw), sourceConfig: coerceConfig(effectiveConfigRaw), valid: false, @@ -292,6 +298,7 @@ export async function readConfigFileSnapshotInternal( parsed: snapshotParsed, includeProvenance, agentRosterIncludeOwned, + bindingsIncludeOwned, sourceConfigBeforeMigrations: coerceConfig(readResolution.resolvedConfigRaw), sourceConfig: coerceConfig(effectiveConfigRaw), valid: true, @@ -371,17 +378,16 @@ export async function readConfigFileSnapshotWithPluginMetadataFromContext( recoverSuspicious: options.recoverSuspicious === true, allowSuspiciousRecovery: options.allowSuspiciousRecovery, }); - const pluginMetadataSnapshot = - result.pluginMetadataSnapshot ?? - (result.snapshot.valid - ? context - .createValidationPluginMetadataSnapshotLoader({ - effectiveConfigRaw: result.snapshot.sourceConfig, - env: context.deps.env, - allowCurrentPluginMetadata: options.allowCurrentPluginMetadata, - }) - .load(result.snapshot.sourceConfig) - : undefined); + let pluginMetadataSnapshot = result.pluginMetadataSnapshot; + if (!pluginMetadataSnapshot && result.snapshot.valid) { + const pluginMetadata = context.createValidationPluginMetadataSnapshotLoader({ + effectiveConfigRaw: result.snapshot.sourceConfig, + env: context.deps.env, + allowCurrentPluginMetadata: options.allowCurrentPluginMetadata, + }); + pluginMetadata.load(result.snapshot.sourceConfig); + pluginMetadataSnapshot = pluginMetadata.getSnapshot(); + } return { snapshot: result.snapshot, ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 717aa09fe952..835ea4809031 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import chokidar from "chokidar"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js"; import { startGatewayConfigReloader } from "../gateway/config-reload.js"; import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; @@ -946,7 +947,10 @@ describe("config io write", () => { alias: "GPT", params: { transport: "sse", openaiWsWarmup: false }, }); - expect(persisted.agents?.entries).toEqual({ main: {}, ops: {} }); + expect(persisted.agents?.entries).toEqual({ + main: { workspace: resolveDefaultAgentWorkspaceDir() }, + ops: {}, + }); }, ); @@ -968,11 +972,11 @@ describe("config io write", () => { expect(snapshot.parsed).toEqual(original); expect(snapshot.sourceConfig).toEqual({ ...original, - agents: { entries: { main: { default: true } } }, + agents: { entries: { main: {} } }, }); expect(snapshot.config).toEqual({ ...original, - agents: { entries: { main: { default: true } } }, + agents: { entries: { main: {} } }, }); expect(snapshot.issues[0]?.message).toContain("unknown channel id: test-plugin-channel"); }); @@ -1314,7 +1318,7 @@ describe("config io write", () => { const io = createFastConfigIO(home, { configPath }); const snapshot = await io.readConfigFileSnapshot(); expect(snapshot.exists).toBe(false); - expect(snapshot.config.agents?.entries).toEqual({ main: { default: true } }); + expect(snapshot.config.agents?.entries).toEqual({ main: {} }); let preflightConfig: OpenClawConfig | undefined; await io.writeConfigFile( @@ -1333,7 +1337,7 @@ describe("config io write", () => { }, ); - expect(preflightConfig?.agents?.entries).toEqual({ main: { default: true } }); + expect(preflightConfig?.agents?.entries).toEqual({ main: {} }); const persisted = await readPersistedConfig(configPath); expect(persisted.agents?.defaults?.model).toBe("claude-cli/claude-opus-4-8"); expect(persisted.agents?.entries).toBeUndefined(); @@ -1351,15 +1355,16 @@ describe("config io write", () => { }); const persisted = await readPersistedConfig(configPath); - expect(persisted.agents?.entries).toEqual({ main: { default: true } }); + expect(persisted.agents?.entries).toEqual({ main: {} }); expect(persisted.agents?.list).toBeUndefined(); }); itWithHome("forwards explicitly authorized agent roster removals", async (home) => { const { configPath } = await writeConfigFixture(home, { agents: { + ownership: "explicit", entries: { - main: { default: true, workspace: "/srv/shared" }, + main: { workspace: "/srv/shared" }, ops: { workspace: "/srv/shared" }, }, }, @@ -1374,7 +1379,8 @@ describe("config io write", () => { await writeConfigFile( { agents: { - entries: { main: { default: true, workspace: "/srv/shared" } }, + ownership: "explicit", + entries: { main: { workspace: "/srv/shared" } }, }, }, { @@ -1387,7 +1393,7 @@ describe("config io write", () => { const persisted = await readPersistedConfig(configPath); expect(persisted.agents?.entries).toEqual({ - main: { default: true, workspace: "/srv/shared" }, + main: { workspace: "/srv/shared" }, }); }); @@ -1754,7 +1760,7 @@ describe("config io write", () => { await io.writeConfigFile({ agents: { entries: { - main: { default: true, workspace: "/resolved/agent-workspace" }, + main: { workspace: "/resolved/agent-workspace" }, }, }, }); @@ -1966,7 +1972,7 @@ describe("config io write", () => { defaults: { model: { primary: "openrouter/anthropic/claude-sonnet-4.6" }, }, - entries: { main: { default: true } }, + entries: { main: {} }, }); }, ); diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 8f427864c64e..c4cbb1eea55a 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -5,6 +5,8 @@ import { applyUnsetPathsForWrite } from "./config-path-mutation.js"; import { restoreEnvRefsFromMap, resolveWriteEnvSnapshotForPath } from "./env-preserve.js"; import { formatConfigValidationFailure } from "./io.write-errors.js"; import { resolvePersistCandidateForWrite } from "./io.write-prepare.js"; +import { tryResolveLegacyCompatibilityAgentId } from "./legacy.default-agent-owner.js"; +import { migratePersistedImplicitMainRoster } from "./legacy.roster.js"; import { createMergePatch } from "./merge-patch.js"; import type { OpenClawConfig } from "./types.js"; @@ -679,6 +681,29 @@ describe("config io write prepare", () => { }); }); + it("preserves an untouched legacy owner marker across a partial unrelated write", () => { + const authored = { + agents: { entries: { ops: {}, research: { default: true } } }, + gateway: { port: 18789 }, + }; + const migrated = migratePersistedImplicitMainRoster(authored).config as OpenClawConfig; + + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: migrated, + sourceConfig: migrated, + sourceConfigBeforeMigrations: authored, + rootAuthoredConfig: authored, + nextConfig: { gateway: { port: 19001 } }, + preserveLegacyAgentRoster: true, + explicitSetPaths: [["gateway", "port"]], + explicitSetValueSource: { gateway: { port: 19001 } }, + }) as OpenClawConfig; + + expect(persisted.agents?.entries?.research?.default).toBe(true); + const reloaded = migratePersistedImplicitMainRoster(persisted).config as OpenClawConfig; + expect(tryResolveLegacyCompatibilityAgentId(reloaded)).toBe("research"); + }); + it("rejects duplicate normalized ids before canonicalizing a legacy roster", () => { const nextConfig = listRoster([ { id: "Ops", workspace: "/first" }, diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index a53a0b8a39b3..ee36d38051d6 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -1487,6 +1487,7 @@ export function resolvePersistCandidateForWrite(params: { explicitSetValueSource?: unknown; allowedAgentRosterRemovals?: readonly string[]; allowIncludeAncestorExplicitSetPaths?: boolean; + preserveLegacyAgentRoster?: boolean; }): unknown { const patch = createMergePatch(params.runtimeConfig, params.nextConfig); const projectedSource = normalizeTouchedAgentModelMapEntries({ @@ -1553,8 +1554,6 @@ export function resolvePersistCandidateForWrite(params: { if (persistCanonicalRoster) { persistedBase = deletePathValue(persistedBase, ["agents", "entries"]); persistedBase = deletePathValue(persistedBase, ["agents", "list"]); - } else if (canCanonicalizeAgentRoster(params.nextConfig)) { - persistedBase = restoreAuthoredAgentRoster(persistedBase, rootAuthoredConfig); } const persisted = injectExplicitlySetPaths({ valueSource: explicitSetValueSource, @@ -1575,19 +1574,25 @@ export function resolvePersistCandidateForWrite(params: { persistedCandidate: persisted, }) : persisted; + const preserveAuthoredRoster = + canCanonicalizeAgentRoster(params.nextConfig) || params.preserveLegacyAgentRoster === true; + const withAuthoredRoster = + persistCanonicalRoster || !preserveAuthoredRoster + ? withPreservedIncludes + : restoreAuthoredAgentRoster(withPreservedIncludes, rootAuthoredConfig); if (persistCanonicalRoster) { // A roster rewrite must never drop entries the mutation did not explicitly delete. // A 2026-07-25 production incident lost agents.entries.main twice through silent rewrites. assertCanonicalAgentRosterRetainsEntries({ currentConfig: params.sourceConfig, - canonicalConfig: withPreservedIncludes, + canonicalConfig: withAuthoredRoster, allowedRemovals: params.allowedAgentRosterRemovals, }); } const withSchema = preserveRootSchemaUri({ rootAuthoredConfig, nextConfig: params.nextConfig, - persistedCandidate: withPreservedIncludes, + persistedCandidate: withAuthoredRoster, }); const withAuthoredParams = preserveAuthoredAgentParams({ sourceConfig: params.sourceConfig, diff --git a/src/config/io.write.ts b/src/config/io.write.ts index 66b2e99cee41..0f340402f737 100644 --- a/src/config/io.write.ts +++ b/src/config/io.write.ts @@ -1,9 +1,15 @@ import type fs from "node:fs"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { listAgentEntries, tryResolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { resolveCronJobsStorePathFromConfig } from "../cron/store.js"; import { isVerbose } from "../global-state.js"; import { isVitestRuntimeEnv } from "../infra/env.js"; import { formatErrorMessage } from "../infra/errors.js"; import { replaceFileAtomic } from "../infra/replace-file.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { isRecord } from "../utils.js"; +import { pinSurvivorWorkspaceForRosterCollapse } from "./agent-workspace-roster-transition.js"; import { maintainConfigBackups } from "./backup-rotation.js"; import { collectChangedPaths } from "./config-change-paths.js"; import { @@ -17,6 +23,7 @@ import { applyUnsetPathsForWrite, resolveManagedUnsetPathsForWrite, } from "./config-path-mutation.js"; +import { getConfigValueAtPath, setConfigValueAtPath } from "./config-paths.js"; import { EnvRefArrayMutationError, restoreEnvRefsFromMap, @@ -32,8 +39,11 @@ import { formatConfigOverwriteLogMessage, type ConfigWriteAuditResult, } from "./io.audit.js"; +import { prepareAuthInheritanceOwnerForWrite } from "./io.auth-inheritance-owner.js"; import type { ConfigIoContext } from "./io.context.js"; +import { prepareCronOwnerWriteRefusal } from "./io.cron-owner-refusal.js"; import { recordConfigWriteMetadata } from "./io.meta.js"; +import { assertAutomaticBindingsWriteAllowed } from "./io.ownership-write-guard.js"; import { collectEnvRefPaths, containsConfigIncludeDirective, @@ -44,6 +54,7 @@ import { resolveGatewayMode, restoreAuthoredTildePathsForWrite, } from "./io.read-helpers.js"; +import { prepareSessionStoreOwnershipForWrite } from "./io.session-store-owner.js"; import { loggedConfigWarningFingerprints, setBoundedConfigIoWarningEntry } from "./io.state.js"; import type { ConfigWriteOptions, @@ -70,11 +81,15 @@ import { } from "./io.write-safety.js"; import { formatConfigIssueLines } from "./issue-format.js"; import { warnIfJSON5CommentsWillBeStripped } from "./json5-comments.js"; +import { migratePersistedImplicitMainRoster } from "./legacy.roster.js"; import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js"; import { resolveIncludeRoots } from "./paths.js"; import { preflightRuntimeSnapshotWrite } from "./runtime-snapshot.js"; import type { OpenClawConfig } from "./types.js"; -import { validateConfigObjectRawWithPlugins } from "./validation.js"; +import { + materializeLegacyAgentOwnershipForActiveChannelsResult, + validateConfigObjectRawWithPlugins, +} from "./validation.js"; function hasOwnIncludeDirective(value: unknown): value is Record { return value !== null && typeof value === "object" && Object.hasOwn(value, INCLUDE_KEY); @@ -107,7 +122,8 @@ export async function writeConfigFileFromContext( options.assertConfigPathForWrite?.(); assertConfigWriteAllowedInCurrentMode({ configPath, env: deps.env }); const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths); - let persistCandidate: unknown = cfg; + let nextConfig = cfg; + let persistCandidate: unknown; const snapshotRead = options.baseSnapshot ? { snapshot: options.baseSnapshot, @@ -118,10 +134,162 @@ export async function writeConfigFileFromContext( if (options.baseSnapshot) { assertBaseSnapshotStillCurrent(snapshot, configPath, deps.fs); } + + const sourceRosterMigration = migratePersistedImplicitMainRoster( + snapshot.sourceConfigBeforeMigrations ?? snapshot.parsed, + ); + const retainedLegacyDefaultAgentId = sourceRosterMigration.retainedLegacyDefaultAgentId; + const previousEntries = listAgentEntries(snapshot.config); + const nextEntries = listAgentEntries(nextConfig); + const nextAgentIds = new Set(nextEntries.map((entry) => normalizeAgentId(entry.id))); + const previousSoleAgentId = tryResolveDefaultAgentId(snapshot.config); + const entersMultiAgent = previousEntries.length <= 1 && nextEntries.length > 1; + const previousSoleRemains = Boolean( + previousSoleAgentId && nextAgentIds.has(normalizeAgentId(previousSoleAgentId)), + ); + const writesOwnershipTopology = + !isDeepStrictEqual(previousEntries, nextEntries) || + [...(options.explicitSetPaths ?? []), ...unsetPaths].some( + (writePath) => + writePath[0] === "agents" && + (writePath.length === 1 || + writePath[1] === "entries" || + writePath[1] === "list" || + writePath[1] === "ownership"), + ); + const persistOwnership = + entersMultiAgent || (retainedLegacyDefaultAgentId !== undefined && writesOwnershipTopology); + const keepOwnership = nextEntries.length > 1 && snapshot.config.agents?.ownership === "explicit"; + const stampOwnership = + (persistOwnership || keepOwnership) && nextConfig.agents?.ownership === undefined; + if (stampOwnership) { + nextConfig = { + ...nextConfig, + agents: { ...nextConfig.agents, ownership: "explicit" }, + }; + } + + const workspaceCollapse = pinSurvivorWorkspaceForRosterCollapse( + snapshot.config, + nextConfig, + deps.env, + ); + nextConfig = workspaceCollapse.config; + + const authInheritanceOwnership = prepareAuthInheritanceOwnerForWrite({ + currentConfig: snapshot.config, + targetConfig: nextConfig, + writesOwnershipTopology, + explicitSetPaths: options.explicitSetPaths, + env: deps.env, + }); + nextConfig = authInheritanceOwnership.config; + + const sessionStoreOwnership = prepareSessionStoreOwnershipForWrite({ + currentConfig: snapshot.config, + currentStore: (snapshot.sourceConfigBeforeMigrations ?? snapshot.config).session?.store, + targetConfig: nextConfig, + env: deps.env, + explicitSetPaths: options.explicitSetPaths, + explicitSetValueSource: options.explicitSetValueSource, + }); + nextConfig = sessionStoreOwnership.config; + const { sameFixedSessionStore } = sessionStoreOwnership; + const retainedFleetOwner = + retainedLegacyDefaultAgentId && + writesOwnershipTopology && + nextAgentIds.has(normalizeAgentId(retainedLegacyDefaultAgentId)) + ? retainedLegacyDefaultAgentId + : undefined; + const ownerAgentId = + (entersMultiAgent && previousSoleRemains ? previousSoleAgentId : undefined) ?? + retainedFleetOwner; + const ownershipMaterialization = ownerAgentId + ? materializeLegacyAgentOwnershipForActiveChannelsResult( + nextConfig, + ownerAgentId, + deps.env, + snapshotRead.pluginMetadataSnapshot?.manifestRegistry.plugins, + { materializeSessionStore: sameFixedSessionStore, materializeWorkspace: true }, + ) + : { config: nextConfig, insertedPaths: [] as string[][] }; + nextConfig = ownershipMaterialization.config; + const insertedPaths = [ + ...(persistOwnership || keepOwnership + ? (sourceRosterMigration.insertedPaths ?? []).filter( + (entry) => + sameFixedSessionStore || entry.join(".") !== "agents.defaults.sessionStore.agentId", + ) + : []), + ...((persistOwnership || keepOwnership) && + retainedLegacyDefaultAgentId && + Array.isArray(snapshot.config.bindings) && + !isDeepStrictEqual(snapshot.sourceConfigBeforeMigrations?.bindings, snapshot.config.bindings) + ? [["bindings"]] + : []), + ...ownershipMaterialization.insertedPaths, + ...workspaceCollapse.insertedPaths, + ...authInheritanceOwnership.insertedPaths, + ...(stampOwnership ? [["agents", "ownership"]] : []), + ]; + + const nextSessionStoreConfig = nextConfig.agents?.defaults?.sessionStore; + if ( + !ownerAgentId && + writesOwnershipTopology && + previousEntries.length === 1 && + previousSoleAgentId && + !previousSoleRemains && + sameFixedSessionStore && + (nextSessionStoreConfig === undefined || + (isRecord(nextSessionStoreConfig) && !Object.hasOwn(nextSessionStoreConfig, "agentId"))) + ) { + nextConfig = { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + sessionStore: { + ...(isRecord(nextSessionStoreConfig) ? nextSessionStoreConfig : {}), + agentId: normalizeAgentId(previousSoleAgentId), + }, + }, + }, + }; + insertedPaths.push(["agents", "defaults", "sessionStore", "agentId"]); + } + + const topologyPaths = [ + ...new Map(insertedPaths.map((entry) => [entry.join("\0"), entry])).values(), + ]; + assertAutomaticBindingsWriteAllowed({ + bindingsIncludeOwned: snapshot.bindingsIncludeOwned === true, + ownershipPaths: topologyPaths, + }); + const explicitSetPaths = [...(options.explicitSetPaths ?? []), ...topologyPaths]; + const explicitSetValueSource = structuredClone( + options.explicitSetValueSource ?? nextConfig, + ) as Record; + for (const ownershipPath of topologyPaths) { + setConfigValueAtPath( + explicitSetValueSource, + ownershipPath, + getConfigValueAtPath(nextConfig as Record, ownershipPath), + ); + } + const cronOwnerRefusal = persistOwnership + ? await prepareCronOwnerWriteRefusal({ + storePath: resolveCronJobsStorePathFromConfig(nextConfig, deps.env), + env: deps.env, + }) + : undefined; + + persistCandidate = nextConfig; let envRefMap: Map | null = null; const changedPaths = new Set(); - collectChangedPaths(snapshot.config, cfg, "", changedPaths); - for (const changedPath of [...(options.explicitSetPaths ?? []), ...(options.unsetPaths ?? [])]) { + collectChangedPaths(snapshot.config, nextConfig, "", changedPaths); + for (const changedPath of [...explicitSetPaths, ...(options.unsetPaths ?? [])]) { const normalizedPath = changedPath.filter((segment) => segment.length > 0).join("."); if (normalizedPath) { changedPaths.add(normalizedPath); @@ -129,8 +297,7 @@ export async function writeConfigFileFromContext( } const identityRestoredPaths = new Set(); const hasAuthoredIncludes = containsConfigIncludeDirective(snapshot.parsed); - const hasResolvedAuthoredIncludes = - hasAuthoredIncludes && !containsConfigIncludeDirective(snapshot.sourceConfig); + const hasIncludes = hasAuthoredIncludes && !containsConfigIncludeDirective(snapshot.sourceConfig); // Missing snapshots still need runtime-to-authored projection. Callers authoring an // exact bootstrap roster mark that intent through explicitSetPaths. if (snapshot.valid) { @@ -138,24 +305,25 @@ export async function writeConfigFileFromContext( runtimeConfig: snapshot.config, sourceConfig: snapshot.resolved, sourceConfigBeforeMigrations: snapshot.sourceConfigBeforeMigrations, - nextConfig: cfg, + nextConfig, rootAuthoredConfig: snapshot.parsed, agentRosterIncludeOwned: snapshot.agentRosterIncludeOwned, unsetPaths, - explicitSetPaths: options.explicitSetPaths, - explicitSetValueSource: options.explicitSetValueSource, + explicitSetPaths, + explicitSetValueSource, allowedAgentRosterRemovals: options.allowedAgentRosterRemovals, allowIncludeAncestorExplicitSetPaths: options.allowIncludeAncestorExplicitSetPaths, + preserveLegacyAgentRoster: Boolean(retainedLegacyDefaultAgentId) && !writesOwnershipTopology, }); } else if (snapshot.exists && hasAuthoredIncludes) { persistCandidate = preserveIncludeOwnedConfigForWrite({ runtimeConfig: snapshot.config, sourceConfig: snapshot.resolved, - nextConfig: cfg, + nextConfig, rootAuthoredConfig: snapshot.parsed, }); } - if (snapshot.exists && (snapshot.valid || hasResolvedAuthoredIncludes)) { + if (snapshot.exists && (snapshot.valid || hasIncludes)) { try { const resolvedIncludes = resolveConfigIncludes( snapshot.parsed, @@ -436,6 +604,8 @@ export async function writeConfigFileFromContext( assertBaseSnapshotStillCurrent(snapshot, configPath, deps.fs); } options.assertConfigPathForWrite?.(); + await cronOwnerRefusal?.recheck(); + options.assertConfigPathForWrite?.(); // Warn only after final guards pass, with no later await before rename. warnIfJSON5CommentsWillBeStripped({ raw: snapshot.raw, diff --git a/src/config/legacy.default-agent-owner-state.ts b/src/config/legacy.default-agent-owner-state.ts new file mode 100644 index 000000000000..b599926cb4cd --- /dev/null +++ b/src/config/legacy.default-agent-owner-state.ts @@ -0,0 +1,14 @@ +// Config materialization carries this upgrade-only fact without restoring the retired marker. +const legacyDefaultAgentIdByConfig = new WeakMap(); + +export function setRetainedLegacyDefaultAgentId(config: object, agentId: string | undefined): void { + if (agentId) { + legacyDefaultAgentIdByConfig.set(config, agentId); + } else { + legacyDefaultAgentIdByConfig.delete(config); + } +} + +export function getRetainedLegacyDefaultAgentId(config: object): string | undefined { + return legacyDefaultAgentIdByConfig.get(config); +} diff --git a/src/config/legacy.default-agent-owner.ts b/src/config/legacy.default-agent-owner.ts new file mode 100644 index 000000000000..74d17ce7d5c8 --- /dev/null +++ b/src/config/legacy.default-agent-owner.ts @@ -0,0 +1,34 @@ +import { normalizeAgentId } from "@openclaw/normalization-core/agent-id"; +import { tryResolveLegacyCompatibilityAgentId } from "../agents/agent-scope-config.js"; +import { + getRetainedLegacyDefaultAgentId, + setRetainedLegacyDefaultAgentId, +} from "./legacy.default-agent-owner-state.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +export function retainLegacyDefaultAgentId( + config: OpenClawConfig, + agentId: string | undefined, +): OpenClawConfig { + setRetainedLegacyDefaultAgentId(config, agentId ? normalizeAgentId(agentId) : undefined); + return config; +} + +export function inheritLegacyDefaultAgentId( + source: OpenClawConfig, + target: OpenClawConfig, +): OpenClawConfig { + return retainLegacyDefaultAgentId(target, tryGetLegacyDefaultAgentId(source)); +} + +export function tryGetLegacyDefaultAgentId(config: OpenClawConfig): string | undefined { + return getRetainedLegacyDefaultAgentId(config); +} +export { tryResolveLegacyCompatibilityAgentId } from "../agents/agent-scope-config.js"; + +export function resolveSessionStoreCompatibilityAgentId(config: OpenClawConfig): string { + const persistedAgentId = config.agents?.defaults?.sessionStore?.agentId?.trim(); + return persistedAgentId + ? normalizeAgentId(persistedAgentId) + : (tryResolveLegacyCompatibilityAgentId(config) ?? "main"); +} diff --git a/src/config/legacy.default-agent-roles.ts b/src/config/legacy.default-agent-roles.ts new file mode 100644 index 000000000000..2d541944a74f --- /dev/null +++ b/src/config/legacy.default-agent-roles.ts @@ -0,0 +1,148 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries, tryResolveSoleAgentId } from "../agents/agent-scope-config.js"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js"; +import { isChannelConfigMetadataKey } from "../channels/config-metadata.js"; +import { normalizeRouteBindingChannelId } from "../routing/binding-scope.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { isRecord } from "../utils.js"; +import { isPerAgentSessionStoreConfig } from "./sessions/session-store-config.js"; +import type { AgentRouteBinding } from "./types.agents.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +function isChannelWideBinding(binding: AgentRouteBinding, channelId: string): boolean { + const match = binding.match; + return ( + isRecord(match) && + normalizeRouteBindingChannelId( + typeof match.channel === "string" ? match.channel : undefined, + ) === channelId && + (typeof match.accountId === "string" ? match.accountId.trim() : undefined) === "*" && + match.peer === undefined && + !normalizeOptionalString(typeof match.guildId === "string" ? match.guildId : undefined) && + !normalizeOptionalString(typeof match.teamId === "string" ? match.teamId : undefined) && + (!Array.isArray(match.roles) || match.roles.length === 0) + ); +} + +function listUnboundAmbientChannelIds( + cfg: OpenClawConfig, + ambientChannelIds: readonly string[], +): string[] { + if (cfg.bindings && !Array.isArray(cfg.bindings)) { + return []; + } + const bindings = (cfg.bindings ?? []).filter( + (binding): binding is AgentRouteBinding => isRecord(binding) && binding.type !== "acp", + ); + const channels = new Set( + ambientChannelIds.map(normalizeRouteBindingChannelId).filter((id): id is string => Boolean(id)), + ); + if (isRecord(cfg.channels)) { + for (const [id, value] of Object.entries(cfg.channels)) { + const channelId = normalizeRouteBindingChannelId(id); + if ( + channelId && + !isChannelConfigMetadataKey(id) && + (!isRecord(value) || value.enabled !== false) + ) { + channels.add(channelId); + } + } + } + return [...channels] + .toSorted() + .filter((channelId) => !bindings.some((binding) => isChannelWideBinding(binding, channelId))); +} + +export function materializeLegacyDefaultAgentRoles( + cfg: OpenClawConfig, + legacyDefaultAgentId: string, + options: { + ambientChannelIds?: readonly string[]; + env?: NodeJS.ProcessEnv; + materializeSessionStore?: boolean; + materializeWorkspace?: boolean; + } = {}, +) { + const agentId = normalizeAgentId(legacyDefaultAgentId); + let next = cfg; + const insertedPaths: string[][] = []; + if (options.materializeWorkspace) { + const entries = { ...next.agents?.entries }; + const entryKey = Object.keys(entries).find( + (candidate) => normalizeAgentId(candidate) === agentId, + ); + const entry = entryKey ? entries[entryKey] : undefined; + const workspaceNeedsPin = + entry !== undefined && + (!Object.hasOwn(entry, "workspace") || + (typeof entry.workspace === "string" && entry.workspace.trim().length === 0)); + if (entryKey && entry && workspaceNeedsPin) { + entries[entryKey] = { + ...entry, + workspace: + normalizeOptionalString(next.agents?.defaults?.workspace) ?? + resolveDefaultAgentWorkspaceDir(options.env), + }; + next = { ...next, agents: { ...next.agents, entries } }; + insertedPaths.push(["agents", "entries", entryKey, "workspace"]); + } + } + const channels = listUnboundAmbientChannelIds(cfg, options.ambientChannelIds ?? []); + if (channels.length > 0) { + next = { + ...next, + bindings: [ + ...(Array.isArray(next.bindings) ? next.bindings : []), + ...channels.map((channel) => ({ agentId, match: { channel, accountId: "*" } })), + ], + }; + insertedPaths.push(["bindings"]); + } + + const rawDefaults = (cfg.agents as { defaults?: unknown } | undefined)?.defaults; + const defaults = isRecord(rawDefaults) ? rawDefaults : undefined; + if (rawDefaults === undefined || defaults) { + const soleFallback = normalizeAgentId(tryResolveSoleAgentId(cfg) ?? "main"); + const unset = (key: string) => + defaults?.[key] === undefined || + (isRecord(defaults[key]) && !Object.hasOwn(defaults[key], "agentId")); + const materializedDefaults = { ...defaults }; + let changed = false; + const materialize = (key: string, enabled: boolean) => { + if (!enabled) { + return; + } + materializedDefaults[key] = { + ...(isRecord(materializedDefaults[key]) ? materializedDefaults[key] : {}), + agentId, + }; + insertedPaths.push(["agents", "defaults", key, "agentId"]); + changed = true; + }; + materialize( + "heartbeat", + !listAgentEntries(cfg).some((entry) => entry.heartbeat) && defaults?.heartbeat === undefined, + ); + materialize("systemAgent", unset("systemAgent")); + // Auth transitions are pinned or refused by the roster write guard; fixed-store rows need + // their owner recorded immediately because a later restart loses the migration sidecar. + materialize("authInheritance", agentId !== soleFallback && unset("authInheritance")); + materialize( + "sessionStore", + options.materializeSessionStore !== false && + !isPerAgentSessionStoreConfig(cfg.session?.store) && + unset("sessionStore"), + ); + if (changed) { + next = { ...next, agents: { ...next.agents, defaults: materializedDefaults } }; + } + } + + const talk = isRecord(cfg.talk) ? cfg.talk : undefined; + if ((cfg.talk === undefined || talk) && (!talk || !Object.hasOwn(talk, "agentId"))) { + next = { ...next, talk: { ...talk, agentId } }; + insertedPaths.push(["talk", "agentId"]); + } + return { config: next, insertedPaths }; +} diff --git a/src/config/legacy.roster.test.ts b/src/config/legacy.roster.test.ts index ac5f6aff913b..b29a06ff781a 100644 --- a/src/config/legacy.roster.test.ts +++ b/src/config/legacy.roster.test.ts @@ -18,7 +18,7 @@ describe("persisted implicit-main roster migration", () => { const snapshot = await readConfigFileSnapshot(); - expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: { default: true } }); + expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: {} }); expect(await fs.readFile(configPath, "utf8")).toBe(raw); }); }); @@ -28,7 +28,7 @@ describe("persisted implicit-main roster migration", () => { resetConfigRuntimeState(); const snapshot = await readConfigFileSnapshot(); expect(snapshot.exists).toBe(false); - expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: { default: true } }); + expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: {} }); }); }); @@ -47,7 +47,7 @@ describe("persisted implicit-main roster migration", () => { resetConfigRuntimeState(); const channelsSnapshot = await readConfigFileSnapshot(); expect(channelsSnapshot.sourceConfigBeforeMigrations?.agents?.entries).toBeUndefined(); - expect(channelsSnapshot.sourceConfig.agents?.entries).toEqual({ main: { default: true } }); + expect(channelsSnapshot.sourceConfig.agents?.entries).toEqual({ main: {} }); await fs.writeFile( includePath, @@ -58,9 +58,7 @@ describe("persisted implicit-main roster migration", () => { expect(rosterSnapshot.sourceConfigBeforeMigrations?.agents?.list).toEqual([ { id: "ops", default: true }, ]); - expect(rosterSnapshot.sourceConfig.agents?.entries).toEqual({ - ops: { default: true }, - }); + expect(rosterSnapshot.sourceConfig.agents?.entries).toEqual({ ops: {} }); }); }); @@ -238,7 +236,7 @@ describe("persisted implicit-main roster migration", () => { }); }); - it("converts a legacy list roster before applying default normalization", () => { + it("converts a legacy list roster before applying ownership materialization", () => { expect( migratePersistedImplicitMainRoster({ agents: { @@ -249,44 +247,56 @@ describe("persisted implicit-main roster migration", () => { ], }, }), - ).toEqual({ + ).toMatchObject({ config: { agents: { defaults: { workspace: "/srv/ops" }, entries: { ops: { workspace: "/srv/ops" }, - writer: { default: true }, + writer: {}, }, }, }, changed: true, - diagnostics: ["Moved agents.list to keyed agents.entries."], + retainedLegacyDefaultAgentId: "writer", }); }); - it.each([ - { - label: "missing default", - list: [{ id: "10" }, { id: "2" }], - }, - { - label: "duplicate defaults", - list: [ - { id: "10", default: true }, - { id: "2", default: true }, - ], - }, - ])("preserves original list order for numeric ids with $label", ({ list }) => { - const migrated = migratePersistedImplicitMainRoster({ agents: { list } }); + it("preserves original list order for markerless numeric ids without inventing an owner", () => { + const migrated = migratePersistedImplicitMainRoster({ + agents: { list: [{ id: "10" }, { id: "2" }] }, + }); expect(migrated.changed).toBe(true); expect(migrated.config).toMatchObject({ agents: { entries: { "2": {}, + "10": {}, + }, + }, + }); + expect(migrated.retainedLegacyDefaultAgentId).toBeUndefined(); + }); + + it("preserves duplicate legacy markers for schema rejection", () => { + const migrated = migratePersistedImplicitMainRoster({ + agents: { + list: [ + { id: "10", default: true }, + { id: "2", default: true }, + ], + }, + }); + + expect(migrated.config).toMatchObject({ + agents: { + entries: { + "2": { default: true }, "10": { default: true }, }, }, }); + expect(migrated.retainedLegacyDefaultAgentId).toBeUndefined(); }); it("preserves a __proto__ agent as an own keyed entry", () => { @@ -298,9 +308,7 @@ describe("persisted implicit-main roster migration", () => { }; expect(Object.hasOwn(config.agents.entries, "__proto__")).toBe(true); - expect(Object.getOwnPropertyDescriptor(config.agents.entries, "__proto__")?.value).toEqual({ - default: true, - }); + expect(Object.getOwnPropertyDescriptor(config.agents.entries, "__proto__")?.value).toEqual({}); }); it("preserves an own __proto__ entry field for strict schema rejection", () => { @@ -323,7 +331,7 @@ describe("persisted implicit-main roster migration", () => { tools: { allow: ["*"] }, }); expect(entry.tools).toBeUndefined(); - expect(entry.default).toBe(true); + expect(entry.default).toBeUndefined(); const validation = validateConfigObjectRaw(migrated.config); expect(validation.ok).toBe(false); if (!validation.ok) { @@ -343,6 +351,27 @@ describe("persisted implicit-main roster migration", () => { }); }); + it("marks the first object entry and leaves wholly malformed maps unchanged", () => { + const partial = { agents: { entries: { invalid: null, ops: {} } } }; + expect(migratePersistedImplicitMainRoster(partial)).toEqual({ + config: partial, + changed: false, + diagnostics: [], + }); + const malformed = { agents: { entries: { first: null, second: "invalid" } } }; + expect(migratePersistedImplicitMainRoster(malformed)).toEqual({ + config: malformed, + changed: false, + diagnostics: [], + }); + const invalidMarker = { agents: { entries: { ops: { default: "yes" } } } }; + expect(migratePersistedImplicitMainRoster(invalidMarker)).toEqual({ + config: invalidMarker, + changed: false, + diagnostics: [], + }); + }); + it.each([ { list: [{ default: true }] }, { list: [{ id: "" }] }, @@ -366,7 +395,7 @@ describe("persisted implicit-main roster migration", () => { const snapshot = await readConfigFileSnapshot(); - expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: { default: true } }); + expect(snapshot.sourceConfig.agents?.entries).toEqual({ main: {} }); expect(JSON.parse(await fs.readFile(configPath, "utf8"))).toEqual({ agents: { entries: {} }, }); @@ -375,21 +404,18 @@ describe("persisted implicit-main roster migration", () => { it.each([ { - label: "missing default", + label: "legacy marker-free entries", entries: { ops: {}, research: {} }, - expected: { ops: { default: true }, research: {} }, }, { label: "duplicate defaults", entries: { ops: {}, research: { default: true }, writer: { default: true } }, - expected: { ops: {}, research: { default: true }, writer: {} }, }, { label: "false default markers", entries: { ops: { default: false }, research: { default: false } }, - expected: { ops: { default: true }, research: {} }, }, - ])("normalizes $label markers in memory", async ({ entries, expected }) => { + ])("rejects $label without inventing legacy ownership", async ({ entries }) => { await withTempHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -398,33 +424,32 @@ describe("persisted implicit-main roster migration", () => { const snapshot = await readConfigFileSnapshot(); - expect(snapshot.valid).toBe(true); - expect(snapshot.sourceConfig.agents?.entries).toEqual(expected); + expect(snapshot.valid).toBe(false); + expect(snapshot.issues).toContainEqual( + expect.objectContaining({ path: expect.stringMatching(/^agents\.(entries|ownership)/) }), + ); expect(JSON.parse(await fs.readFile(configPath, "utf8"))).toEqual({ agents: { entries }, }); }); }); - it("marks the first object entry and leaves wholly malformed maps unchanged", () => { - expect( - migratePersistedImplicitMainRoster({ agents: { entries: { invalid: null, ops: {} } } }), - ).toEqual({ - config: { agents: { entries: { invalid: null, ops: { default: true } } } }, - changed: true, - diagnostics: ['Migrated agents.entries by marking "ops" as default.'], - }); - const malformed = { agents: { entries: { first: null, second: "invalid" } } }; - expect(migratePersistedImplicitMainRoster(malformed)).toEqual({ - config: malformed, - changed: false, - diagnostics: [], - }); - const invalidMarker = { agents: { entries: { ops: { default: "yes" } } } }; - expect(migratePersistedImplicitMainRoster(invalidMarker)).toEqual({ - config: invalidMarker, - changed: false, - diagnostics: [], + it("keeps a shipped single-marker fleet valid while retaining its owner", async () => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const entries = { ops: {}, research: { default: true } }; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ agents: { entries } })); + resetConfigRuntimeState(); + + const snapshot = await readConfigFileSnapshot(); + + expect(snapshot.valid).toBe(true); + expect(snapshot.sourceConfig.agents?.entries).toMatchObject({ ops: {}, research: {} }); + expect(snapshot.sourceConfig.agents?.defaults?.heartbeat?.agentId).toBe("research"); + expect(snapshot.sourceConfig.agents?.defaults?.systemAgent?.agentId).toBe("research"); + expect(snapshot.sourceConfig.agents?.defaults?.authInheritance?.agentId).toBe("research"); + expect(snapshot.sourceConfig.talk?.agentId).toBe("research"); }); }); diff --git a/src/config/legacy.roster.ts b/src/config/legacy.roster.ts index 404abbd6f158..e87d366f61a0 100644 --- a/src/config/legacy.roster.ts +++ b/src/config/legacy.roster.ts @@ -1,12 +1,21 @@ import { normalizeAgentId } from "@openclaw/normalization-core/agent-id"; import { readAgentRosterProperty } from "../agents/agent-scope-config.js"; +import { + retainLegacyDefaultAgentId, + tryGetLegacyDefaultAgentId, +} from "./legacy.default-agent-owner.js"; +import { materializeLegacyDefaultAgentRoles } from "./legacy.default-agent-roles.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; -/** Every missing or empty roster is the shipped implicit-main shape. */ -export function migratePersistedImplicitMainRoster(raw: unknown): { +type MigrationResult = { config: unknown; changed: boolean; diagnostics: string[]; -} { + insertedPaths?: string[][]; + retainedLegacyDefaultAgentId?: string; +}; + +export function migratePersistedImplicitMainRoster(raw: unknown): MigrationResult { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return { config: raw, changed: false, diagnostics: [] }; } @@ -67,8 +76,15 @@ export function migratePersistedImplicitMainRoster(raw: unknown): { !Array.isArray(entries) && Object.keys(entries).length === 0) ) { + if (agents.ownership === "explicit") { + return { + config: convertedLegacyList ? { ...root, agents } : raw, + changed: convertedLegacyList, + diagnostics: convertedLegacyList ? ["Moved agents.list to keyed agents.entries."] : [], + }; + } return { - config: { ...root, agents: { ...agents, entries: { main: { default: true } } } }, + config: { ...root, agents: { ...agents, entries: { main: {} } } }, changed: true, diagnostics: convertedLegacyList ? ["Moved agents.list to keyed agents.entries."] : [], }; @@ -92,41 +108,59 @@ export function migratePersistedImplicitMainRoster(raw: unknown): { if (hasInvalidDefaultMarker) { return { config: raw, changed: false, diagnostics: [] }; } - const defaultIds = validIds.filter( + + const markedIds = validIds.filter( (id) => (roster[id] as Record).default === true, ); - if (defaultIds.length === 1) { - return convertedLegacyList - ? { - config: { ...root, agents }, - changed: true, - diagnostics: ["Moved agents.list to keyed agents.entries."], - } - : { config: raw, changed: false, diagnostics: [] }; + const hasValidLegacyMarker = agents.ownership !== "explicit" && markedIds.length === 1; + const legacyDefaultAgentId = + tryGetLegacyDefaultAgentId(raw as OpenClawConfig) ?? + (validIds.length > 1 && hasValidLegacyMarker ? markedIds[0] : undefined); + let nextRoot: Record = { ...root, agents }; + let insertedPaths: string[][] = []; + const diagnostics = convertedLegacyList ? ["Moved agents.list to keyed agents.entries."] : []; + let changed = convertedLegacyList; + if (legacyDefaultAgentId) { + const materialized = materializeLegacyDefaultAgentRoles( + nextRoot as OpenClawConfig, + legacyDefaultAgentId, + ); + nextRoot = materialized.config as Record; + insertedPaths = materialized.insertedPaths; + if (insertedPaths.length > 0) { + diagnostics.push("Materialized legacy per-surface agent ownership."); + changed = true; + } } - const effectiveId = defaultIds[0] ?? validIds[0]!; - const repaired = Object.fromEntries( - Object.entries(roster).map(([id, entry]) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return [id, entry]; - } - const next = { ...(entry as Record) }; - if (id === effectiveId) { - next.default = true; - } else { - delete next.default; - } - return [id, next]; - }), - ); + if (hasValidLegacyMarker) { + const nextAgents = (nextRoot.agents as Record | undefined) ?? agents; + const materializedEntries = (nextAgents.entries ?? roster) as Record; + nextRoot = { + ...nextRoot, + agents: { + ...nextAgents, + entries: Object.fromEntries( + Object.entries(materializedEntries).map(([id, entry]) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return [id, entry]; + } + const { default: _default, ...rest } = entry as Record; + return [id, rest]; + }), + ), + }, + }; + diagnostics.push("Removed retired agents.entries.*.default markers."); + changed = true; + } + + const config = (changed ? nextRoot : raw) as OpenClawConfig; + retainLegacyDefaultAgentId(config, legacyDefaultAgentId); return { - config: { ...root, agents: { ...agents, entries: repaired } }, - changed: true, - diagnostics: [ - ...(convertedLegacyList ? ["Moved agents.list to keyed agents.entries."] : []), - defaultIds.length === 0 - ? `Migrated agents.entries by marking "${effectiveId}" as default.` - : `Migrated agents.entries by keeping "${effectiveId}" as default and clearing ${defaultIds.length - 1} duplicate marker(s).`, - ], + config, + changed, + diagnostics, + ...(insertedPaths.length > 0 ? { insertedPaths } : {}), + ...(legacyDefaultAgentId ? { retainedLegacyDefaultAgentId: legacyDefaultAgentId } : {}), }; } diff --git a/src/config/materialize.ts b/src/config/materialize.ts index 160bc7ad7f7c..c05e533d177b 100644 --- a/src/config/materialize.ts +++ b/src/config/materialize.ts @@ -11,6 +11,7 @@ import { applySessionDefaults, applyTalkConfigNormalization, } from "./defaults.js"; +import { inheritLegacyDefaultAgentId } from "./legacy.default-agent-owner.js"; import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js"; import { normalizeConfigPaths } from "./normalize-paths.js"; import type { OpenClawConfig, ResolvedSourceConfig, RuntimeConfig } from "./types.js"; @@ -85,5 +86,5 @@ export function materializeRuntimeConfig( normalizeConfigPaths(next); } normalizeExecSafeBinProfilesInConfig(next); - return asRuntimeConfig(next); + return asRuntimeConfig(inheritLegacyDefaultAgentId(config, next)); } diff --git a/src/config/runtime-overrides.test.ts b/src/config/runtime-overrides.test.ts index 2a0cf02ecdf4..0af715e83fd2 100644 --- a/src/config/runtime-overrides.test.ts +++ b/src/config/runtime-overrides.test.ts @@ -9,7 +9,7 @@ import { setConfigOverride, unsetConfigOverride, } from "./runtime-overrides.js"; -import { resolveMainSessionKey } from "./sessions/main-session.js"; +import { resolveMainSessionKey, resolveSessionRoutingContract } from "./sessions/main-session.js"; import type { OpenClawConfig } from "./types.js"; import { validateConfigObject } from "./validation.js"; @@ -18,6 +18,28 @@ describe("runtime overrides", () => { resetConfigOverrides(); }); + it("fingerprints the persisted owner of a global fixed store", () => { + const cfg = { + session: { scope: "global" as const, store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit" as const, + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {}, ops: {} }, + }, + }; + + expect(resolveSessionRoutingContract(cfg)).toBe("global|main|ops"); + expect( + resolveSessionRoutingContract({ + ...cfg, + agents: { + ...cfg.agents, + defaults: { sessionStore: { agentId: "research" } }, + }, + }), + ).toBe("global|main|research"); + }); + it("sets and applies nested overrides", () => { const cfg = { channels: { whatsapp: { responsePrefix: "[openclaw]" } }, diff --git a/src/config/runtime-overrides.ts b/src/config/runtime-overrides.ts index a68d07906da8..af8cec55f716 100644 --- a/src/config/runtime-overrides.ts +++ b/src/config/runtime-overrides.ts @@ -4,6 +4,7 @@ import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { isPlainObject } from "../utils.js"; import { attachAgentListProjection } from "./agent-list-projection.js"; import { parseConfigPath, setConfigValueAtPath, unsetConfigValueAtPath } from "./config-paths.js"; +import { inheritLegacyDefaultAgentId } from "./legacy.default-agent-owner.js"; import type { OpenClawConfig } from "./types.js"; type OverrideTree = Record; @@ -49,10 +50,12 @@ function mergeOverrides(base: unknown, override: unknown): unknown { function applyOverrideTree(cfg: OpenClawConfig, overrideTree: OverrideTree): OpenClawConfig { const next = mergeOverrides(cfg, overrideTree) as OpenClawConfig; + // Runtime cloning must preserve retained migration ownership or unrelated + // overrides turn an upgraded fleet back into an ownerless explicit roster. if (next.agents === cfg.agents) { - return next; + return inheritLegacyDefaultAgentId(cfg, next); } - return attachAgentListProjection(next); + return inheritLegacyDefaultAgentId(cfg, attachAgentListProjection(next)); } /** Return the process-local runtime override tree used by debug config commands. */ diff --git a/src/config/runtime-schema.test.ts b/src/config/runtime-schema.test.ts index ec2ef3dcafa7..826e81fd83ec 100644 --- a/src/config/runtime-schema.test.ts +++ b/src/config/runtime-schema.test.ts @@ -21,7 +21,7 @@ let readBestEffortRuntimeConfigSchema: typeof import("./runtime-schema.js").read let loadGatewayRuntimeConfigSchema: typeof import("./runtime-schema.js").loadGatewayRuntimeConfigSchema; function explicitMainRoster(): OpenClawConfig { - return { agents: { list: [{ id: "main", default: true }] } }; + return { agents: { list: [{ id: "main" }] } }; } vi.mock("./config.js", () => { diff --git a/src/config/runtime-schema.ts b/src/config/runtime-schema.ts index 5730744ede2e..1d5e79956a8c 100644 --- a/src/config/runtime-schema.ts +++ b/src/config/runtime-schema.ts @@ -1,23 +1,19 @@ // Builds runtime config schema defaults from agent and workspace state. -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { collectChannelSchemaMetadataCore, collectPluginSchemaMetadataCore, } from "./channel-config-metadata.js"; import { getRuntimeConfig, readConfigFileSnapshot } from "./config.js"; import type { OpenClawConfig } from "./config.js"; +import { resolveConfigWidePluginManifestRegistry } from "./io.plugin-metadata.js"; import { buildConfigSchemaCore, type ConfigSchemaResponse } from "./schema.js"; // Runtime schemas include currently loaded plugin/channel metadata for accurate UI fields. function loadManifestRegistry(config: OpenClawConfig, env?: NodeJS.ProcessEnv) { - const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config), env); - return resolvePluginMetadataSnapshot({ + return resolveConfigWidePluginManifestRegistry({ config, env: env ?? process.env, - workspaceDir, - allowWorkspaceScopedCurrent: true, - }).manifestRegistry; + }); } /** Builds the config schema from the active runtime config and plugin metadata. */ @@ -34,7 +30,7 @@ export async function readBestEffortRuntimeConfigSchema(): Promise = { "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", agents: "Agent runtime configuration root. Root siblings own infrastructure and cross-agent defaults; agents.defaults owns agent-loop behavior; agent entries may override either where supported.", + "agents.ownership": + 'Durable multi-agent ownership generation marker. "explicit" means ambient channels, heartbeat, system-agent consults, Talk, cron, and bare CLI operations must resolve a surface-specific owner or fail closed. OpenClaw stamps this automatically when creating or migrating a fleet; omit it for a sole agent.', "agents.defaults": "Shared default settings inherited by agents unless overridden per entry in agents.entries. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.", "agents.defaults.skills": @@ -323,6 +325,14 @@ export const CORE_FIELD_HELP: Record = { "Target settings for ambient OpenClaw system-agent and Custodian inference.", "agents.defaults.systemAgent.agentId": "Agent whose model and credentials own ambient system-agent and Custodian consults. Delegated consults still use their requesting agent.", + "agents.defaults.authInheritance": + "Upgrade compatibility owner for the inherited credential store until credentials are relocated per agent.", + "agents.defaults.authInheritance.agentId": + "Agent whose legacy credential store remains the inheritance source after default-marker retirement. Written automatically during upgrade when the former owner was not main.", + "agents.defaults.sessionStore": + "Upgrade compatibility owner for a fixed legacy session store until its SQLite database records ownership.", + "agents.defaults.sessionStore.agentId": + "Agent that owns unscoped rows in a fixed legacy session store after default-marker retirement. Written automatically during upgrade when the former owner was not main or the sole agent.", "talk.agentId": "Agent that owns Talk sessions created without an explicit agent-scoped session key.", }; diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 5a7fe758045a..da69a4603963 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -84,6 +84,7 @@ export const FIELD_LABELS: Record = { "agents.entries.*.experimental": "Agent Experimental Flags", "agents.entries.*.experimental.localModelLean": "Agent Lean Local Model Mode", agents: "Agents", + "agents.ownership": "Agent Ownership Generation", "agents.defaults": "Agent Defaults", "agents.defaults.contextLimits": "Default Context Limits", "agents.defaults.contextLimits.memoryGetMaxChars": "Default memory_get Max Chars", @@ -639,6 +640,10 @@ export const FIELD_LABELS: Record = { "agents.entries.*.heartbeat.timeoutSeconds": "Heartbeat Timeout (Seconds)", "agents.defaults.systemAgent": "System Agent Target", "agents.defaults.systemAgent.agentId": "System Agent Owner", + "agents.defaults.authInheritance": "Auth Inheritance Target", + "agents.defaults.authInheritance.agentId": "Auth Inheritance Owner", + "agents.defaults.sessionStore": "Legacy Session Store Target", + "agents.defaults.sessionStore.agentId": "Legacy Session Store Owner", "agents.defaults.sandbox.browser.network": "Sandbox Browser Network", "agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range", "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": diff --git a/src/config/schema.tiers.ts b/src/config/schema.tiers.ts index 70dc2adc1b8b..d67d676399e7 100644 --- a/src/config/schema.tiers.ts +++ b/src/config/schema.tiers.ts @@ -33,7 +33,7 @@ agents.defaults.subagents.model agents.defaults.subagents.model.primary agents.defaults.sandbox.ssh.workspaceRoot agents.defaults.sandbox.workspaceRoot agents.defaults.thinkingDefault agents.defaults.userTimezone agents.defaults.voiceModel.primary -agents.defaults.workspace agents.entries.*.default agents.entries.*.groupChat.mentionPatterns +agents.defaults.workspace agents.entries.*.groupChat.mentionPatterns agents.entries.*.groupChat.unmentionedInbound agents.entries.*.identity agents.entries.*.memory.search.enabled agents.entries.*.memory.search.provider agents.entries.*.memory.search.rememberAcrossConversations agents.entries.*.memory.search.model diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index 178b17e14667..54b07a8fb989 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -3,7 +3,6 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getLogger } from "../../logging/logger.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveOpenClawAgentSqlitePath } from "../../state/openclaw-agent-db.js"; @@ -38,6 +37,7 @@ import { type ResolvedSessionMaintenanceConfig, } from "./store-maintenance.js"; import { + resolveSessionStoreCompatibilityAgentId, resolveSessionStoreTargets, type SessionStoreTarget, type SessionStoreSelectionOptions, @@ -708,7 +708,7 @@ export async function purgeAgentSessionStoreEntries( const storeConfig = cfg.session?.store; const storeAgentId = typeof storeConfig === "string" && !storeConfig.includes("{agentId}") - ? normalizeAgentId(resolveDefaultAgentId(cfg)) + ? resolveSessionStoreCompatibilityAgentId(cfg) : normalizedAgentId; const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId: normalizedAgentId, diff --git a/src/config/sessions/combined-store-gateway.ts b/src/config/sessions/combined-store-gateway.ts index 195eb7ef9d8e..6085996f3cd2 100644 --- a/src/config/sessions/combined-store-gateway.ts +++ b/src/config/sessions/combined-store-gateway.ts @@ -2,18 +2,18 @@ // Gateway callers need canonical per-agent keys even when stores are split by `{agentId}`. import { expectDefined } from "@openclaw/normalization-core"; -import { listAgentEntries, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentEntries } from "../../agents/agent-scope.js"; import { resolveSessionStoreKey, resolveStoredSessionKeyForAgentStore, } from "../../gateway/session-store-key.js"; import { isIncognitoSessionKey, - LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; import { listOpenIncognitoAgentDatabases } from "../../state/openclaw-agent-db.js"; +import { resolveSessionStoreCompatibilityAgentId } from "../legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveSessionStorePathCore } from "./paths.js"; import { @@ -160,11 +160,11 @@ function resolveGatewaySessionStoreTargets( ): ResolvedGatewaySessionStoreTargets { const storeConfig = cfg.session?.store; const diagnostics: string[] = []; - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); const requestedAgentId = typeof opts.agentId === "string" && opts.agentId.trim() ? normalizeAgentId(opts.agentId) : undefined; + const defaultAgentId = normalizeAgentId(resolveSessionStoreCompatibilityAgentId(cfg)); const configuredAgentIds = opts.configuredAgentsOnly === true && !requestedAgentId ? new Set(listConfiguredSessionStoreAgentIds(cfg)) @@ -185,7 +185,6 @@ function resolveGatewaySessionStoreTargets( ...listAgentEntries(cfg).map((entry) => normalizeAgentId(entry.id)), ...listKnownSessionStoreAgentIds(cfg), defaultAgentId, - LEGACY_IMPLICIT_AGENT_ID, ...(requestedAgentId ? [requestedAgentId] : []), ]), ]; @@ -231,13 +230,12 @@ export function canPrewarmCombinedSessionStoresForGateway( cfg: OpenClawConfig, params: { agentIds: readonly string[]; maxRows: number }, ): boolean { - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); let totalRows = 0; for (const agentId of params.agentIds) { const resolved = resolveGatewaySessionStoreTargets(cfg, { agentId }); const projectionTargets = dedupeSessionStoreTargetsBySqliteTarget( [...resolved.durableTargets, ...resolved.incognitoTargets], - { defaultAgentId }, + { defaultAgentId: resolved.defaultAgentId }, ); for (const target of projectionTargets) { totalRows += countSessionEntryRowsReadOnly(target); diff --git a/src/config/sessions/main-session.ts b/src/config/sessions/main-session.ts index 827a65d15590..49e731489f0d 100644 --- a/src/config/sessions/main-session.ts +++ b/src/config/sessions/main-session.ts @@ -1,12 +1,14 @@ -import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; +import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; // Main-session keys normalize configured agents and legacy aliases into store keys. import { normalizeAgentId, normalizeMainKey, resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveCanonicalMainSessionKey } from "./main-session-key.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "./session-store-owner.js"; import type { SessionScope } from "./types.js"; const FALLBACK_DEFAULT_AGENT_ID = "main"; @@ -20,7 +22,12 @@ function buildMainSessionKey(agentId: string, mainKey?: string): string { /** Resolves the configured main session key, honoring global session scope. */ export function resolveMainSessionKey(cfg: OpenClawConfig): string { return resolveCanonicalMainSessionKey({ - agentId: resolveDefaultAgentId(cfg), + agentId: + tryResolveLegacyCompatibilityAgentId(cfg) ?? + resolveDefaultAgentId(cfg, { + surface: "main-session routing", + hint: "Pass an explicit agent/session key instead of the unscoped main alias.", + }), mainKey: cfg.session?.mainKey, sessionScope: cfg.session?.scope, }); @@ -28,11 +35,21 @@ export function resolveMainSessionKey(cfg: OpenClawConfig): string { /** Stable fingerprint for the config values that canonicalize chat session keys. */ export function resolveSessionRoutingContract(cfg: OpenClawConfig): string { - const defaultAgentId = resolveDefaultAgentId(cfg); const scope = cfg?.session?.scope ?? "per-sender"; - return [scope, normalizeMainKey(cfg?.session?.mainKey), normalizeAgentId(defaultAgentId)].join( - "|", - ); + // Global keys carry no agent namespace, so their durable fixed-store owner is + // part of the routing contract; otherwise stale clients can target a changed row. + const persistedOwner = + scope === "global" + ? resolvePersistedSessionStoreOwnerForKey(cfg, "global") + : ({ kind: "none" } as const); + const routingOwner = + persistedOwner.kind === "configured" + ? persistedOwner.agentId + : persistedOwner.kind === "retired" + ? `retired:${persistedOwner.agentId}` + : (tryResolveLegacyCompatibilityAgentId(cfg) ?? + (cfg.agents?.ownership === "explicit" ? "unowned" : (listAgentIds(cfg)[0] ?? "main"))); + return [scope, normalizeMainKey(cfg?.session?.mainKey), routingOwner].join("|"); } export { resolveAgentIdFromSessionKey }; diff --git a/src/config/sessions/session-accessor.transcript-owner.test.ts b/src/config/sessions/session-accessor.transcript-owner.test.ts new file mode 100644 index 000000000000..e33580c1513e --- /dev/null +++ b/src/config/sessions/session-accessor.transcript-owner.test.ts @@ -0,0 +1,280 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { withTempHome } from "openclaw/plugin-sdk/test-env"; +import { describe, expect, it } from "vitest"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; +import { retainLegacyDefaultAgentId } from "../legacy.default-agent-owner.js"; +import type { OpenClawConfig } from "../types.openclaw.js"; +import { loadTranscriptEvents, replaceSessionEntry } from "./session-accessor.js"; +import { persistSessionTranscriptTurn } from "./session-accessor.transcript-turn.js"; + +describe("transcript turn logical ownership", () => { + it("rejects a bare-key write for an ownerless explicit fleet", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + const cfg = { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + session: { store: storePath }, + } satisfies OpenClawConfig; + + await expect( + persistSessionTranscriptTurn( + { + sessionId: "ownerless-transcript-session", + sessionKey: "main", + storePath, + }, + { + config: cfg, + messages: [{ message: { role: "user", content: "must not be attributed" } }], + updateMode: "none", + }, + ), + ).rejects.toBeInstanceOf(AgentSelectionRequiredError); + }); + }); + + it("attributes a bare-key write to the retained compatibility owner", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + const cfg = retainLegacyDefaultAgentId( + { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + session: { store: storePath }, + }, + "ops", + ); + const scope = { + sessionId: "retained-owner-transcript-session", + sessionKey: "main", + storePath, + }; + await replaceSessionEntry( + { agentId: "ops", sessionKey: scope.sessionKey, storePath }, + { sessionId: scope.sessionId, updatedAt: 1 }, + ); + + await expect( + persistSessionTranscriptTurn(scope, { + config: cfg, + messages: [{ message: { role: "user", content: "retained owner" } }], + updateMode: "none", + }), + ).resolves.toMatchObject({ appendedCount: 1 }); + await expect(loadTranscriptEvents({ ...scope, agentId: "ops" })).resolves.toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ content: "retained owner", role: "user" }), + type: "message", + }), + ); + }); + }); + + it("rejects a conflicting scope agent for a persisted fixed-store owner", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: storePath }, + } satisfies OpenClawConfig; + const scope = { + agentId: "research", + sessionId: "persisted-owner-transcript-session", + sessionKey: "global", + storePath, + }; + + await expect( + persistSessionTranscriptTurn(scope, { + config: cfg, + messages: [{ message: { role: "user", content: "wrong owner" } }], + updateMode: "none", + }), + ).rejects.toBeInstanceOf(AgentSelectionRequiredError); + + await replaceSessionEntry( + { agentId: "ops", sessionKey: scope.sessionKey, storePath }, + { sessionId: scope.sessionId, updatedAt: 1 }, + ); + await expect( + persistSessionTranscriptTurn( + { ...scope, agentId: "ops" }, + { + config: cfg, + messages: [{ message: { role: "user", content: "right owner" } }], + updateMode: "none", + }, + ), + ).resolves.toMatchObject({ appendedCount: 1 }); + }); + }); + + it("rejects a bare-key write for a retired persisted owner", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "retired" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: storePath }, + } satisfies OpenClawConfig; + + await expect( + persistSessionTranscriptTurn( + { + sessionId: "retired-owner-transcript-session", + sessionKey: "global", + storePath, + }, + { + config: cfg, + messages: [{ message: { role: "user", content: "retired owner" } }], + updateMode: "none", + }, + ), + ).rejects.toBeInstanceOf(AgentSelectionRequiredError); + }); + }); + + it("allows an explicit agent write to a different per-agent store", async () => { + await withTempHome(async (home) => { + const fixedStorePath = path.join(home, "shared-sessions.json"); + const researchStorePath = path.join(home, "research-sessions.json"); + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: fixedStorePath }, + } satisfies OpenClawConfig; + const scope = { + agentId: "research", + sessionId: "research-global-session", + sessionKey: "global", + storePath: researchStorePath, + }; + await replaceSessionEntry( + { agentId: "research", sessionKey: scope.sessionKey, storePath: researchStorePath }, + { sessionId: scope.sessionId, updatedAt: 1 }, + ); + + await expect( + persistSessionTranscriptTurn(scope, { + config: cfg, + expectedSessionId: scope.sessionId, + messages: [{ message: { role: "user", content: "research store" } }], + updateMode: "none", + }), + ).resolves.toMatchObject({ appendedCount: 1 }); + await expect(loadTranscriptEvents({ ...scope, agentId: "research" })).resolves.toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ content: "research store", role: "user" }), + type: "message", + }), + ); + }); + }); + + it("uses an explicit agent for a pathless injected session store", async () => { + await withTempHome(async (home) => { + const configuredStorePath = path.join(home, "shared-sessions.json"); + const sessionEntry = { sessionId: "injected-research", updatedAt: 1 }; + const sessionStore = { global: sessionEntry }; + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: configuredStorePath }, + } satisfies OpenClawConfig; + + await expect( + persistSessionTranscriptTurn( + { + agentId: "research", + sessionId: sessionEntry.sessionId, + sessionKey: "global", + sessionStore, + }, + { + config: cfg, + messages: [{ message: { role: "user", content: "injected research" } }], + updateMode: "none", + }, + ), + ).resolves.toMatchObject({ appendedCount: 1 }); + }); + }); + + it("keeps a pathless injected session store ownerless without an explicit agent", async () => { + await withTempHome(async (home) => { + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: path.join(home, "shared-sessions.json") }, + } satisfies OpenClawConfig; + + await expect( + persistSessionTranscriptTurn( + { + sessionId: "injected-ownerless", + sessionKey: "global", + sessionStore: { global: { sessionId: "injected-ownerless", updatedAt: 1 } }, + }, + { + config: cfg, + messages: [{ message: { role: "user", content: "must select" } }], + updateMode: "none", + }, + ), + ).rejects.toBeInstanceOf(AgentSelectionRequiredError); + }); + }); + + it.runIf(process.platform !== "win32")( + "treats a symlink alias as the configured owned fixed store", + async () => { + await withTempHome(async (home) => { + const fixedStorePath = path.join(home, "shared-store.sqlite"); + const aliasStorePath = path.join(home, "shared-store-alias.sqlite"); + await fs.writeFile(fixedStorePath, ""); + await fs.symlink(fixedStorePath, aliasStorePath); + const cfg = { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { store: fixedStorePath }, + } satisfies OpenClawConfig; + + await expect( + persistSessionTranscriptTurn( + { + agentId: "research", + sessionId: "aliased-store-session", + sessionKey: "global", + storePath: aliasStorePath, + }, + { + config: cfg, + messages: [{ message: { role: "user", content: "wrong owner" } }], + updateMode: "none", + }, + ), + ).rejects.toBeInstanceOf(AgentSelectionRequiredError); + }); + }, + ); +}); diff --git a/src/config/sessions/session-accessor.transcript-turn.ts b/src/config/sessions/session-accessor.transcript-turn.ts index 8553264653ef..950c464e5b45 100644 --- a/src/config/sessions/session-accessor.transcript-turn.ts +++ b/src/config/sessions/session-accessor.transcript-turn.ts @@ -1,7 +1,13 @@ import { randomUUID } from "node:crypto"; -import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { AgentSelectionRequiredError, listAgentIds } from "../../agents/agent-scope-config.js"; +import { + classifySessionKeyShape, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import { getRuntimeConfig } from "../io.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../legacy.default-agent-owner.js"; +import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveSessionStorePathCore } from "./paths.js"; import { updateSessionEntry } from "./session-accessor.entry-mutation.js"; import { @@ -25,12 +31,74 @@ import type { SessionTranscriptTurnPersistOptions, SessionTranscriptTurnPersistResult, } from "./session-accessor.types.js"; +import { resolvePersistedSessionStoreOwnerForTarget } from "./session-store-owner.js"; import { getOwnedSessionTranscriptWriterFence, runWithOwnedSessionTranscriptWrite, } from "./transcript-write-context.js"; import type { SessionEntry } from "./types.js"; +function resolveTranscriptTurnAgentId(params: { + config: OpenClawConfig; + scopeAgentId?: string; + sessionKey: string; + storePath?: string; + sessionStore?: Record; + env?: NodeJS.ProcessEnv; +}): string { + const keyShape = classifySessionKeyShape(params.sessionKey); + if (keyShape === "malformed_agent") { + throw new Error("Malformed agent session key; refusing transcript turn persistence."); + } + const scopedAgentId = params.scopeAgentId?.trim() + ? normalizeAgentId(params.scopeAgentId.trim()) + : undefined; + const parsedAgentId = parseAgentSessionKey(params.sessionKey)?.agentId; + const keyAgentId = parsedAgentId ? normalizeAgentId(parsedAgentId) : undefined; + if (scopedAgentId && keyAgentId && scopedAgentId !== keyAgentId) { + throw new Error( + `Session key owner "${keyAgentId}" does not match requested agent "${scopedAgentId}".`, + ); + } + const persistedStoreOwner = + params.sessionStore && !params.storePath + ? ({ kind: "none" } as const) + : resolvePersistedSessionStoreOwnerForTarget({ + config: params.config, + sessionKey: params.sessionKey, + storePath: params.storePath, + env: params.env, + }); + if ( + scopedAgentId && + persistedStoreOwner.kind === "configured" && + scopedAgentId !== persistedStoreOwner.agentId + ) { + throw new AgentSelectionRequiredError(listAgentIds(params.config), { + surface: "transcript turn persistence", + hint: `The shared fixed-store row belongs to agent "${persistedStoreOwner.agentId}", not agent "${scopedAgentId}".`, + }); + } + if (persistedStoreOwner.kind === "retired") { + throw new AgentSelectionRequiredError(listAgentIds(params.config), { + surface: "transcript turn persistence", + hint: `The shared fixed-store row belongs to retired agent "${persistedStoreOwner.agentId}".`, + }); + } + const agentId = + keyAgentId ?? + (persistedStoreOwner.kind === "configured" ? persistedStoreOwner.agentId : undefined) ?? + scopedAgentId ?? + tryResolveLegacyCompatibilityAgentId(params.config); + if (agentId) { + return normalizeAgentId(agentId); + } + throw new AgentSelectionRequiredError(listAgentIds(params.config), { + surface: "transcript turn persistence", + hint: "Pass an agentId or use an agent-qualified session key.", + }); +} + /** Appends one prepared ordered group in the existing transcript turn transaction. */ export async function appendTranscriptMessages( scope: SessionTranscriptWriteScope, @@ -212,15 +280,14 @@ async function persistExpectedSessionTranscriptTurn( } const storePath = scope.storePath; const expectedSessionId = options.expectedSessionId; - const agentId = - scope.agentId ?? - resolveAgentIdFromSessionKey( - sessionKey, - resolveDefaultAgentId(options.config ?? getRuntimeConfig()), - ); - if (!agentId) { - throw new Error(`Cannot resolve transcript turn without an agent id: ${sessionKey}`); - } + const agentId = resolveTranscriptTurnAgentId({ + config: options.config ?? getRuntimeConfig(), + scopeAgentId: scope.agentId, + sessionKey, + storePath, + sessionStore: scope.sessionStore, + env: scope.env, + }); const resolved = scope.sessionStore ? resolveSessionEntryFromStore({ store: scope.sessionStore, sessionKey }) : resolveSessionEntrySelection({ @@ -315,15 +382,18 @@ async function resolveTranscriptTurnTarget( if (!sessionKey || !scope.sessionId) { throw new Error("Cannot persist a transcript turn without a session key and session id"); } - const agentId = - scope.agentId ?? - resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(config ?? getRuntimeConfig())); - if (!agentId) { - throw new Error(`Cannot resolve transcript turn without an agent id: ${sessionKey}`); - } + const effectiveConfig = config ?? getRuntimeConfig(); + const agentId = resolveTranscriptTurnAgentId({ + config: effectiveConfig, + scopeAgentId: scope.agentId, + sessionKey, + storePath: scope.storePath, + sessionStore: scope.sessionStore, + env: scope.env, + }); const storePath = scope.storePath ?? - resolveSessionStorePathCore(getRuntimeConfig().session?.store, { + resolveSessionStorePathCore(effectiveConfig.session?.store, { agentId, env: scope.env, }); diff --git a/src/config/sessions/session-store-config.test.ts b/src/config/sessions/session-store-config.test.ts new file mode 100644 index 000000000000..92540d4fcfae --- /dev/null +++ b/src/config/sessions/session-store-config.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { tryResolvePathCaseInsensitive } from "../../infra/path-case.js"; +import { withTestDir } from "../../test-helpers/temp-dir.js"; +import { isSameFixedSessionStoreConfig } from "./session-store-config.js"; + +describe("fixed session store identity", () => { + it.runIf(process.platform !== "win32")( + "canonicalizes dangling leaf and ancestor aliases for a missing owned store", + async () => { + await withTestDir({ prefix: "openclaw-fixed-store-alias-" }, async (root) => { + const ownedStore = path.join(root, "future", "sessions.sqlite"); + const leafAlias = path.join(root, "leaf-alias.sqlite"); + const ancestorAlias = path.join(root, "ancestor-alias"); + await fs.symlink(ownedStore, leafAlias); + await fs.symlink(path.dirname(ownedStore), ancestorAlias); + + expect(isSameFixedSessionStoreConfig(ownedStore, leafAlias, process.env)).toBe(true); + expect( + isSameFixedSessionStoreConfig( + ownedStore, + path.join(ancestorAlias, path.basename(ownedStore)), + process.env, + ), + ).toBe(true); + expect( + isSameFixedSessionStoreConfig( + ownedStore, + path.join(root, "unrelated", "sessions.sqlite"), + process.env, + ), + ).toBe(false); + }); + }, + ); + + it("treats pre-creation case variants as owned on case-insensitive filesystems", async () => { + await withTestDir({ prefix: "openclaw-fixed-store-case-" }, async (root) => { + const ownedStore = path.join(root, "Future", "Sessions.sqlite"); + const caseVariantStore = path.join(root, "future", "sessions.sqlite"); + await expect(fs.stat(ownedStore)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(caseVariantStore)).rejects.toMatchObject({ code: "ENOENT" }); + if (tryResolvePathCaseInsensitive(ownedStore) !== true) { + return; + } + + expect(isSameFixedSessionStoreConfig(ownedStore, caseVariantStore, process.env)).toBe(true); + await expect(fs.stat(ownedStore)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(caseVariantStore)).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); +}); diff --git a/src/config/sessions/session-store-config.ts b/src/config/sessions/session-store-config.ts new file mode 100644 index 000000000000..54de13f29e81 --- /dev/null +++ b/src/config/sessions/session-store-config.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import path from "node:path"; +import { sameFileIdentity } from "../../infra/fs-safe-advanced.js"; +import { tryResolvePathCaseInsensitive } from "../../infra/path-case.js"; +import { resolveSessionStorePathCore } from "./paths.js"; + +const MAX_SYMLINK_HOPS = 64; + +function splitPathSegments(value: string): string[] { + return value.split(path.sep).filter(Boolean); +} + +function resolveMissingStorePathIdentity(pathname: string): string | undefined { + const absolutePath = path.resolve(pathname); + let resolvedPath = path.parse(absolutePath).root; + const remaining = splitPathSegments(absolutePath.slice(resolvedPath.length)); + const visitedLinks = new Set(); + let symlinkHops = 0; + + while (remaining.length > 0) { + const segment = remaining.shift(); + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + resolvedPath = path.dirname(resolvedPath); + continue; + } + const candidate = path.join(resolvedPath, segment); + let stat: fs.Stats; + try { + stat = fs.lstatSync(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + return undefined; + } + try { + const canonicalAncestor = fs.realpathSync.native(resolvedPath); + return path.resolve(canonicalAncestor, segment, ...remaining); + } catch { + return undefined; + } + } + if (!stat.isSymbolicLink()) { + resolvedPath = candidate; + continue; + } + const resolutionState = `${candidate}\0${remaining.join(path.sep)}`; + if (symlinkHops >= MAX_SYMLINK_HOPS || visitedLinks.has(resolutionState)) { + return undefined; + } + visitedLinks.add(resolutionState); + symlinkHops += 1; + let target: string; + try { + target = fs.readlinkSync(candidate); + } catch { + return undefined; + } + if (path.isAbsolute(target)) { + resolvedPath = path.parse(target).root; + remaining.unshift(...splitPathSegments(target.slice(resolvedPath.length))); + } else { + remaining.unshift(...splitPathSegments(target)); + } + } + + try { + return fs.realpathSync.native(resolvedPath); + } catch { + return undefined; + } +} + +export function isPerAgentSessionStoreConfig(storeConfig: string | undefined): boolean { + return !storeConfig?.trim() || storeConfig.includes("{agentId}"); +} + +export function isSameFixedSessionStoreConfig( + source: string | undefined, + target: string | undefined, + env: NodeJS.ProcessEnv, +): boolean { + if (isPerAgentSessionStoreConfig(source) || isPerAgentSessionStoreConfig(target)) { + return false; + } + const sourcePath = path.resolve(resolveSessionStorePathCore(source, { env })); + const targetPath = path.resolve(resolveSessionStorePathCore(target, { env })); + if (sourcePath === targetPath) { + return true; + } + try { + return sameFileIdentity( + fs.statSync(sourcePath, { bigint: true }), + fs.statSync(targetPath, { bigint: true }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + // An unresolved target may still alias the owned store. Treat that + // ambiguity as owned so callers fail closed instead of admitting a writer. + return true; + } + } + + const sourceIdentity = resolveMissingStorePathIdentity(sourcePath); + const targetIdentity = resolveMissingStorePathIdentity(targetPath); + if (!sourceIdentity || !targetIdentity) { + return true; + } + if (sourceIdentity === targetIdentity) { + return true; + } + if (sourceIdentity.toLowerCase() !== targetIdentity.toLowerCase()) { + return false; + } + const sourceCaseInsensitive = tryResolvePathCaseInsensitive(sourceIdentity); + const targetCaseInsensitive = tryResolvePathCaseInsensitive(targetIdentity); + if (sourceCaseInsensitive === false || targetCaseInsensitive === false) { + return false; + } + // Case-equivalent missing paths are owned when the filesystem folds case or + // when probing cannot prove that the future paths will remain distinct. + return true; +} diff --git a/src/config/sessions/session-store-owner.ts b/src/config/sessions/session-store-owner.ts new file mode 100644 index 000000000000..9dde13bb7040 --- /dev/null +++ b/src/config/sessions/session-store-owner.ts @@ -0,0 +1,62 @@ +import { normalizeAgentId } from "@openclaw/normalization-core/agent-id"; +import { listAgentIds } from "../../agents/agent-scope-config.js"; +import { classifySessionKeyShape } from "../../routing/session-key.js"; +import type { OpenClawConfig } from "../types.openclaw.js"; +import { + isPerAgentSessionStoreConfig, + isSameFixedSessionStoreConfig, +} from "./session-store-config.js"; + +export type PersistedSessionStoreOwner = + | { kind: "none" } + | { kind: "configured"; agentId: string } + | { kind: "retired"; agentId: string }; + +/** Preserves a retired fixed-store owner as an explicit unavailable state. */ +export function resolvePersistedSessionStoreOwner( + config: OpenClawConfig, +): PersistedSessionStoreOwner { + if (isPerAgentSessionStoreConfig(config.session?.store)) { + return { kind: "none" }; + } + const persistedAgentId = config.agents?.defaults?.sessionStore?.agentId?.trim(); + if (!persistedAgentId) { + return { kind: "none" }; + } + const agentId = normalizeAgentId(persistedAgentId); + return listAgentIds(config).some( + (configuredAgentId) => normalizeAgentId(configuredAgentId) === agentId, + ) + ? { kind: "configured", agentId } + : { kind: "retired", agentId }; +} + +/** Applies fixed-store ownership only to keys without an agent-qualified namespace. */ +export function resolvePersistedSessionStoreOwnerForKey( + config: OpenClawConfig, + sessionKey: string | undefined, +): PersistedSessionStoreOwner { + return classifySessionKeyShape(sessionKey) === "legacy_or_alias" + ? resolvePersistedSessionStoreOwner(config) + : { kind: "none" }; +} + +/** Applies fixed-store ownership only when the concrete write target is that configured store. */ +export function resolvePersistedSessionStoreOwnerForTarget(params: { + config: OpenClawConfig; + sessionKey: string | undefined; + storePath?: string; + env?: NodeJS.ProcessEnv; +}): PersistedSessionStoreOwner { + const owner = resolvePersistedSessionStoreOwnerForKey(params.config, params.sessionKey); + if (owner.kind === "none" || !params.storePath) { + return owner; + } + return isSameFixedSessionStoreConfig( + params.config.session?.store, + params.storePath, + params.env ?? process.env, + ) + ? owner + : { kind: "none" }; +} diff --git a/src/config/sessions/targets-path-validation.ts b/src/config/sessions/targets-path-validation.ts new file mode 100644 index 000000000000..199a28f8ab06 --- /dev/null +++ b/src/config/sessions/targets-path-validation.ts @@ -0,0 +1,61 @@ +import fsSync from "node:fs"; +import path from "node:path"; +import { isValidAgentId, LEGACY_IMPLICIT_AGENT_ID } from "../../routing/session-key.js"; +import type { SessionStoreTarget } from "./targets-collision.js"; + +const NON_FATAL_DISCOVERY_ERROR_CODES = new Set([ + "EACCES", + "ELOOP", + "ENOENT", + "ENOTDIR", + "EPERM", + "ESTALE", +]); + +export function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] { + const deduped = new Map(); + for (const target of targets) { + if (!deduped.has(target.storePath)) { + deduped.set(target.storePath, target); + } + } + return [...deduped.values()]; +} + +export function shouldSkipDiscoveryError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return typeof code === "string" && NON_FATAL_DISCOVERY_ERROR_CODES.has(code); +} + +export function isWithinRoot(realPath: string, realRoot: string): boolean { + return realPath === realRoot || realPath.startsWith(`${realRoot}${path.sep}`); +} + +export function shouldSkipDiscoveredAgentDirName(dirName: string, agentId: string): boolean { + return ( + !/[a-z0-9]/i.test(dirName) || + !isValidAgentId(agentId) || + (agentId === LEGACY_IMPLICIT_AGENT_ID && dirName.toLowerCase() !== LEGACY_IMPLICIT_AGENT_ID) + ); +} + +export function resolveValidatedManagedFilePathSync(params: { + agentsRoot: string; + filePath: string; + realAgentsRoot?: string; +}): string | undefined { + try { + const stat = fsSync.lstatSync(params.filePath); + if (stat.isSymbolicLink() || !stat.isFile()) { + return undefined; + } + const realFilePath = fsSync.realpathSync.native(params.filePath); + const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync.native(params.agentsRoot); + return isWithinRoot(realFilePath, realAgentsRoot) ? params.filePath : undefined; + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return undefined; + } + throw err; + } +} diff --git a/src/config/sessions/targets-read-availability.test.ts b/src/config/sessions/targets-read-availability.test.ts index ad7519fcb2ff..4841ecbf86d2 100644 --- a/src/config/sessions/targets-read-availability.test.ts +++ b/src/config/sessions/targets-read-availability.test.ts @@ -9,13 +9,17 @@ import { } from "./targets-read-availability.js"; describe("session store availability", () => { - it("reuses one fixed-store ownership snapshot across agents", async () => { + it("reads cross-agent rows from a migrated fixed store", async () => { await withTempHome(async (home) => { const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(home, ".openclaw") }; const storePath = path.join(home, "shared.sqlite"); const cfg: OpenClawConfig = { session: { store: storePath }, - agents: { entries: { main: { default: true }, ops: {} } }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "main" } }, + entries: { main: {}, ops: {} }, + }, }; await replaceSessionEntry( { agentId: "main", env, storePath, sessionKey: "agent:main:main" }, @@ -36,4 +40,27 @@ describe("session store availability", () => { expect(cache.size).toBe(1); }); }); + + it("reads ownerless fixed-store rows under the requested agent", async () => { + await withTempHome(async (home) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(home, ".openclaw") }; + const storePath = path.join(home, "ownerless-shared.sqlite"); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }; + await replaceSessionEntry( + { agentId: "ops", env, storePath, sessionKey: "agent:ops:main" }, + { sessionId: "ops-session", updatedAt: 1 }, + ); + + expect(resolveExistingAgentSessionStoreTargetsReadOnlyResult(cfg, "ops", { env })).toEqual({ + available: true, + targets: [{ agentId: "ops", storePath }], + }); + }); + }); }); diff --git a/src/config/sessions/targets-read-availability.ts b/src/config/sessions/targets-read-availability.ts index 68602b58ca04..070be65917ff 100644 --- a/src/config/sessions/targets-read-availability.ts +++ b/src/config/sessions/targets-read-availability.ts @@ -1,12 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { withOpenClawAgentDatabaseReadOnly } from "../../state/openclaw-agent-db-readonly.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveSessionStorePathCore } from "./paths.js"; import { readSessionEntryKeys } from "./session-accessor.sqlite-entry-store.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; +import { resolvePersistedSessionStoreOwner } from "./session-store-owner.js"; import { dedupeSessionStoreTargetsBySqliteTarget, type SessionStoreTarget, @@ -33,6 +33,11 @@ type FixedSessionStoreReadSnapshot = | Extract; export type SessionStoreTargetsReadCache = Map; +function resolveReadDefaultAgentId(cfg: OpenClawConfig, targetAgentId: string): string { + const persistedOwner = resolvePersistedSessionStoreOwner(cfg); + return persistedOwner.kind === "none" ? normalizeAgentId(targetAgentId) : persistedOwner.agentId; +} + function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] { return [...new Map(targets.map((target) => [target.storePath, target])).values()]; } @@ -83,7 +88,7 @@ function resolveFixedSessionStoreTargetsReadOnly( cache?: SessionStoreTargetsReadCache, ): SessionStoreTargetsReadResult { const storeConfig = cfg.session?.store; - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = resolveReadDefaultAgentId(cfg, requested); const fixedTarget = { agentId: requested, storePath: resolveSessionStorePathCore(storeConfig, { agentId: requested, env }), @@ -149,9 +154,10 @@ export function resolveExistingAgentSessionStoreTargetsReadOnlyResult( ...resolveExistingAgentSessionStoreTargetsSync(cfg, requested, { env }), ]); for (const target of targets) { + const defaultAgentId = resolveReadDefaultAgentId(cfg, target.agentId); const resolved = resolveSqliteTargetFromSessionStorePath(target.storePath, { agentId: target.agentId, - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId, env, }); const snapshot = readSessionStoreTargetSnapshot({ diff --git a/src/config/sessions/targets.test-support.ts b/src/config/sessions/targets.test-support.ts new file mode 100644 index 000000000000..b7db462b016c --- /dev/null +++ b/src/config/sessions/targets.test-support.ts @@ -0,0 +1,63 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect } from "vitest"; +import type { OpenClawConfig } from "../config.js"; +import { replaceSessionEntry } from "./session-accessor.js"; +import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js"; + +export const EXPLICIT_MAIN_CONFIG: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }] }, +}; + +export async function resolveRealStorePath(sessionsDir: string): Promise { + return path.resolve(path.join(sessionsDir, "sessions.json")); +} + +export async function createAgentSessionStores( + root: string, + agentIds: string[], +): Promise> { + const storePaths: Record = {}; + for (const agentId of agentIds) { + const sessionsDir = path.join(root, "agents", agentId, "sessions"); + const storePath = path.join(sessionsDir, "sessions.json"); + await fs.mkdir(sessionsDir, { recursive: true }); + await replaceSessionEntry( + { storePath, sessionKey: "main" }, + { sessionId: "sid", updatedAt: Date.now() }, + ); + storePaths[agentId] = await resolveRealStorePath(sessionsDir); + } + return storePaths; +} + +export function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig { + return { + session: { store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json") }, + agents: { list: [{ id: defaultAgentId, default: true }] }, + }; +} + +export function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + return items.filter(predicate).length; +} + +export async function resolveTargetsForCustomRoot(home: string, agentIds: string[]) { + const customRoot = path.join(home, "custom-state"); + const storePaths = await createAgentSessionStores(customRoot, agentIds); + const targets = resolveAllAgentSessionStoreTargetsSync(createCustomRootCfg(customRoot), { + env: process.env, + }); + return { storePaths, targets }; +} + +export function expectTargetsToContainStores( + targets: Array<{ agentId: string; storePath: string }>, + stores: Record, +): void { + for (const [agentId, storePath] of Object.entries(stores)) { + expect( + targets.some((target) => target.agentId === agentId && target.storePath === storePath), + ).toBe(true); + } +} diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index c630edd35806..a655ead4f75c 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -19,72 +19,15 @@ import { resolveExistingAgentSessionStoreTargetsSync, resolveSessionStoreTargets, } from "./targets.js"; - -const EXPLICIT_MAIN_CONFIG: OpenClawConfig = { - agents: { list: [{ id: "main", default: true }] }, -}; - -async function resolveRealStorePath(sessionsDir: string): Promise { - return path.resolve(path.join(sessionsDir, "sessions.json")); -} - -async function createAgentSessionStores( - root: string, - agentIds: string[], -): Promise> { - const storePaths: Record = {}; - for (const agentId of agentIds) { - const sessionsDir = path.join(root, "agents", agentId, "sessions"); - const storePath = path.join(sessionsDir, "sessions.json"); - await fs.mkdir(sessionsDir, { recursive: true }); - await replaceSessionEntry( - { storePath, sessionKey: "main" }, - { sessionId: "sid", updatedAt: Date.now() }, - ); - storePaths[agentId] = await resolveRealStorePath(sessionsDir); - } - return storePaths; -} - -function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig { - return { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: defaultAgentId, default: true }], - }, - }; -} - -function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { - let count = 0; - for (const item of items) { - if (predicate(item)) { - count += 1; - } - } - return count; -} - -async function resolveTargetsForCustomRoot(home: string, agentIds: string[]) { - const customRoot = path.join(home, "custom-state"); - const storePaths = await createAgentSessionStores(customRoot, agentIds); - const cfg = createCustomRootCfg(customRoot); - const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env }); - return { storePaths, targets }; -} - -function expectTargetsToContainStores( - targets: Array<{ agentId: string; storePath: string }>, - stores: Record, -): void { - for (const [agentId, storePath] of Object.entries(stores)) { - expect( - targets.some((target) => target.agentId === agentId && target.storePath === storePath), - ).toBe(true); - } -} +import { + countMatching, + createAgentSessionStores, + createCustomRootCfg, + EXPLICIT_MAIN_CONFIG, + expectTargetsToContainStores, + resolveTargetsForCustomRoot, + resolveRealStorePath, +} from "./targets.test-support.js"; describe("resolveSessionStoreTargets", () => { it("resolves all configured agent stores", async () => { @@ -580,6 +523,58 @@ describe("resolveSessionStoreTargets", () => { }); }); + it("uses the persisted owner when --store targets the configured fixed store", () => { + const storePath = path.resolve("/tmp/restart-shaped-shared.sqlite"); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { research: {}, ops: {} }, + }, + }; + + expect(resolveSessionStoreTargets(cfg, { store: storePath })).toEqual([ + { agentId: "ops", storePath }, + ]); + expect(() => resolveSessionStoreTargets(cfg, { agent: "research", store: storePath })).toThrow( + 'Session store belongs to agent "ops", not requested agent "research"', + ); + }); + + it("rejects a path-inferred agent that conflicts with the persisted fixed-store owner", () => { + const storePath = path.resolve("/tmp/agents/research/sessions/sessions.json"); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + expect(() => resolveSessionStoreTargets(cfg, { store: storePath })).toThrow( + 'Session store belongs to agent "research", not requested agent "ops"', + ); + }); + + it("allows an explicit store path with an explicit fleet agent", () => { + const storePath = path.resolve("/tmp/explicit-fleet-sessions.json"); + const cfg: OpenClawConfig = { + agents: { ownership: "explicit", entries: { Ops: {}, research: {} } }, + }; + + expect(resolveSessionStoreTargets(cfg, { agent: "ops", store: storePath })).toEqual([ + { agentId: "ops", storePath }, + ]); + expect(() => + resolveSessionStoreTargets(cfg, { + agent: "ops", + store: path.resolve("/tmp/agents/research/sessions/sessions.json"), + }), + ).toThrow('Session store belongs to agent "research", not requested agent "ops"'); + }); + it("accepts case-insensitive legacy main paths but rejects aliases", () => { const cfg: OpenClawConfig = { agents: { list: [{ id: "ops", default: true }] } }; const mainPath = path.resolve("/tmp/agents/Main/sessions/sessions.json"); diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts index 8b9e1945c959..745df1089518 100644 --- a/src/config/sessions/targets.ts +++ b/src/config/sessions/targets.ts @@ -3,17 +3,16 @@ import fsSync from "node:fs"; import path from "node:path"; import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveAgentSessionDirsFromAgentsDirSync } from "../../agents/session-dirs.js"; -import { - isValidAgentId, - LEGACY_IMPLICIT_AGENT_ID, - normalizeAgentId, - parseAgentSessionKey, -} from "../../routing/session-key.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { withOpenClawAgentDatabaseReadOnly } from "../../state/openclaw-agent-db-readonly.js"; import { createOpenClawAgentDatabasePathMatcher, listOpenClawRegisteredAgentDatabases, } from "../../state/openclaw-agent-db-registry.js"; +import { + resolveSessionStoreCompatibilityAgentId, + tryResolveLegacyCompatibilityAgentId, +} from "../legacy.default-agent-owner.js"; import { resolveStateDir } from "../paths.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveAgentsDirFromSessionStorePath, resolveSessionStorePathCore } from "./paths.js"; @@ -22,13 +21,27 @@ import { listDurableSqliteTargetOwnersForSessionStorePath, resolveSqliteTargetFromSessionStorePath, } from "./session-sqlite-target.js"; +import { isPerAgentSessionStoreConfig } from "./session-store-config.js"; +import { + resolvePersistedSessionStoreOwner, + resolvePersistedSessionStoreOwnerForTarget, +} from "./session-store-owner.js"; import { dedupeSessionStoreTargetsBySqliteTarget, type SessionStoreTarget, } from "./targets-collision.js"; +import { + dedupeTargetsByStorePath, + isWithinRoot, + resolveValidatedManagedFilePathSync, + shouldSkipDiscoveryError, + shouldSkipDiscoveredAgentDirName, +} from "./targets-path-validation.js"; export type { SessionStoreTarget } from "./targets-collision.js"; export { dedupeSessionStoreTargetsBySqliteTarget } from "./targets-collision.js"; +export { resolveSessionStoreCompatibilityAgentId } from "../legacy.default-agent-owner.js"; +export { isPerAgentSessionStoreConfig } from "./session-store-config.js"; /** CLI/session-store target selection options. */ export type SessionStoreSelectionOptions = { @@ -37,63 +50,6 @@ export type SessionStoreSelectionOptions = { allAgents?: boolean; }; -const NON_FATAL_DISCOVERY_ERROR_CODES = new Set([ - "EACCES", - "ELOOP", - "ENOENT", - "ENOTDIR", - "EPERM", - "ESTALE", -]); - -function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] { - const deduped = new Map(); - for (const target of targets) { - if (!deduped.has(target.storePath)) { - deduped.set(target.storePath, target); - } - } - return [...deduped.values()]; -} - -function shouldSkipDiscoveryError(err: unknown): boolean { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - return typeof code === "string" && NON_FATAL_DISCOVERY_ERROR_CODES.has(code); -} - -function isWithinRoot(realPath: string, realRoot: string): boolean { - return realPath === realRoot || realPath.startsWith(`${realRoot}${path.sep}`); -} - -function shouldSkipDiscoveredAgentDirName(dirName: string, agentId: string): boolean { - return ( - !/[a-z0-9]/i.test(dirName) || - !isValidAgentId(agentId) || - (agentId === LEGACY_IMPLICIT_AGENT_ID && dirName.toLowerCase() !== LEGACY_IMPLICIT_AGENT_ID) - ); -} - -function resolveValidatedManagedFilePathSync(params: { - agentsRoot: string; - filePath: string; - realAgentsRoot?: string; -}): string | undefined { - try { - const stat = fsSync.lstatSync(params.filePath); - if (stat.isSymbolicLink() || !stat.isFile()) { - return undefined; - } - const realFilePath = fsSync.realpathSync.native(params.filePath); - const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync.native(params.agentsRoot); - return isWithinRoot(realFilePath, realAgentsRoot) ? params.filePath : undefined; - } catch (err) { - if (shouldSkipDiscoveryError(err)) { - return undefined; - } - throw err; - } -} - /** Lists agent ids whose session stores should be considered configured. */ export function listConfiguredSessionStoreAgentIds(cfg: OpenClawConfig): string[] { const ids = new Set(listAgentIds(cfg).map((agentId) => normalizeAgentId(agentId))); @@ -125,7 +81,7 @@ export function listKnownSessionStoreAgentIds( params: { env?: NodeJS.ProcessEnv } = {}, ): string[] { const env = params.env ?? process.env; - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = resolveSessionStoreCompatibilityAgentId(cfg); const isSameDatabasePath = createOpenClawAgentDatabasePathMatcher(); const ids = new Set(listConfiguredSessionStoreAgentIds(cfg)); if (!isPerAgentSessionStoreConfig(cfg.session?.store)) { @@ -193,12 +149,6 @@ export function isConfiguredSessionStoreAgentId(cfg: OpenClawConfig, agentId: st return listConfiguredSessionStoreAgentIds(cfg).includes(normalizedAgentId); } -/** Whether session.store resolves to a distinct store for each agent. */ -export function isPerAgentSessionStoreConfig(storeConfig: string | undefined): boolean { - const normalized = storeConfig?.trim(); - return !normalized || normalized.includes("{agentId}"); -} - function resolveValidatedDiscoveredStorePathSync(params: { sessionsDir: string; agentsRoot: string; @@ -408,7 +358,7 @@ export function resolveAllAgentSessionStoreTargetsSync( }); return dedupeSessionStoreTargetsBySqliteTarget( [...validatedConfiguredTargets, ...discoveredTargets], - { defaultAgentId: resolveDefaultAgentId(cfg), env }, + { defaultAgentId: resolveSessionStoreCompatibilityAgentId(cfg), env }, ); } @@ -421,7 +371,7 @@ export function resolveExistingAgentSessionStoreTargetsSync( const env = params.env ?? process.env; const requested = normalizeAgentId(agentId); const storeConfig = cfg.session?.store; - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = resolveSessionStoreCompatibilityAgentId(cfg); if (!isPerAgentSessionStoreConfig(storeConfig)) { const fixedTarget = { agentId: requested, @@ -570,7 +520,7 @@ export function resolveAllAgentSessionStoreCandidateTargetsSync( }); return dedupeSessionStoreTargetsBySqliteTarget( [...validatedConfiguredTargets, ...discoveredTargets], - { defaultAgentId: resolveDefaultAgentId(cfg), env }, + { defaultAgentId: resolveSessionStoreCompatibilityAgentId(cfg), env }, ); } @@ -672,16 +622,54 @@ export function resolveSessionStoreTargets( if (hasAgent && allAgents) { throw new Error("--agent and --all-agents cannot be used together"); } - if (opts.store && (hasAgent || allAgents)) { - throw new Error("--store cannot be combined with --agent or --all-agents"); + if (opts.store && allAgents) { + throw new Error("--store cannot be combined with --all-agents"); } - const defaultAgentId = resolveDefaultAgentId(cfg); - if (opts.store) { - return [resolveExplicitSessionStoreTarget({ defaultAgentId, env, store: opts.store })]; + const persistedStoreOwner = resolvePersistedSessionStoreOwnerForTarget({ + config: cfg, + sessionKey: "main", + storePath: opts.store, + env, + }); + if (persistedStoreOwner.kind === "retired") { + throw new Error(`Session store owner is retired: ${persistedStoreOwner.agentId}`); + } + const requestedAgentId = hasAgent ? normalizeAgentId(opts.agent ?? "") : undefined; + if ( + requestedAgentId && + persistedStoreOwner.kind === "configured" && + persistedStoreOwner.agentId !== requestedAgentId + ) { + throw new Error( + `Session store belongs to agent "${persistedStoreOwner.agentId}", not requested agent "${requestedAgentId}".`, + ); + } + const defaultAgentId = + requestedAgentId ?? + (persistedStoreOwner.kind === "configured" ? persistedStoreOwner.agentId : undefined) ?? + tryResolveLegacyCompatibilityAgentId(cfg) ?? + resolveDefaultAgentId(cfg); + const knownAgentIds = new Set(listAgentIds(cfg).map(normalizeAgentId)); + if (hasAgent && !knownAgentIds.has(defaultAgentId)) { + throw new Error( + `Unknown agent id "${opts.agent}". Use "openclaw agents list" to see configured agents.`, + ); + } + const target = resolveExplicitSessionStoreTarget({ defaultAgentId, env, store: opts.store }); + if ( + (hasAgent || persistedStoreOwner.kind === "configured") && + target.agentId !== defaultAgentId + ) { + throw new Error( + `Session store belongs to agent "${target.agentId}", not requested agent "${defaultAgentId}".`, + ); + } + return [target]; } if (allAgents) { + const defaultAgentId = resolveSessionStoreCompatibilityAgentId(cfg); const targets = listConfiguredSessionStoreAgentIds(cfg).map((agentId) => ({ agentId, storePath: resolveSessionStorePathCore(cfg.session?.store, { agentId, env }), @@ -711,6 +699,14 @@ export function resolveSessionStoreTargets( ]; } + const persistedStoreOwner = resolvePersistedSessionStoreOwner(cfg); + if (persistedStoreOwner.kind === "retired") { + throw new Error(`Session store owner is retired: ${persistedStoreOwner.agentId}`); + } + const defaultAgentId = + (persistedStoreOwner.kind === "configured" ? persistedStoreOwner.agentId : undefined) ?? + tryResolveLegacyCompatibilityAgentId(cfg) ?? + resolveDefaultAgentId(cfg); return [ { agentId: defaultAgentId, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 8869426be6ae..57787e90c1d0 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -324,6 +324,14 @@ export type AgentDefaultsConfig = { systemAgent?: { agentId?: string; }; + /** Upgrade-only owner for the inherited credential store until H2-2 relocates credentials. */ + authInheritance?: { + agentId?: string; + }; + /** Upgrade-only owner for legacy fixed session stores until SQLite records ownership. */ + sessionStore?: { + agentId?: string; + }; /** Max concurrent agent runs across all conversations. Default: min(16, max(8, available CPU parallelism)). */ maxConcurrent?: number; /** Sub-agent defaults (spawned via sessions_spawn). */ diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 21e510205384..cbbdc9d89018 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -82,6 +82,7 @@ export type AgentBinding = AgentRouteBinding | AgentAcpBinding; export type AgentConfig = { id: string; + /** @deprecated Raw legacy list compatibility only; canonical agents.entries rejects this key. */ default?: boolean; name?: string; /** Optional human-authored agent description. */ @@ -171,6 +172,7 @@ export type AgentConfig = { export type AgentEntryConfig = Omit; export type AgentsConfig = { + ownership?: "explicit"; defaults?: AgentDefaultsConfig; entries?: Record; /** Internal non-serialized projection materialized by validation for ID-based runtime code. */ diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 6f2df132be0d..7810fd114703 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -285,6 +285,7 @@ export type ConfigFileSnapshot = { includeProvenance?: readonly ConfigIncludeOwnership[]; /** Temporary roster-only projection retained until write preparation uses generic ownership. */ agentRosterIncludeOwned?: boolean; + bindingsIncludeOwned?: boolean; /** Whether the config file exists on disk. */ exists: boolean; /** Raw file contents before parsing; null when missing. */ diff --git a/src/config/validation-core.ts b/src/config/validation-core.ts index 39192212503d..9fc5c99ba7fc 100644 --- a/src/config/validation-core.ts +++ b/src/config/validation-core.ts @@ -6,6 +6,7 @@ import { listAgentEntriesWithSource, resolveAgentWorkspaceDir, resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, } from "../agents/agent-scope.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { @@ -22,6 +23,10 @@ import { import { isRecord } from "../utils.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { attachAgentListProjection } from "./agent-list-projection.js"; +import { + inheritLegacyDefaultAgentId, + tryGetLegacyDefaultAgentId, +} from "./legacy.default-agent-owner.js"; import { migratePersistedImplicitMainRoster } from "./legacy.roster.js"; import { materializeRuntimeConfig } from "./materialize.js"; import { @@ -175,7 +180,7 @@ function validateIdentityAvatar( } const workspaceDir = resolveAgentWorkspaceDir( config, - entry.id ?? resolveDefaultAgentId(config), + entry.id ?? tryResolveLegacyCompatibilityAgentId(config) ?? resolveDefaultAgentId(config), env, ); if (!isWorkspaceAvatarPath(avatar, workspaceDir)) { @@ -298,10 +303,25 @@ export function validateConfigObjectRaw( env?: NodeJS.ProcessEnv; }, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { - const normalizedRaw = stripPreservedLegacyRootKeysForValidation( - raw, - opts?.preservedLegacyRootKeys, - ); + const legacyDefaultAgentId = isRecord(raw) + ? tryGetLegacyDefaultAgentId(raw as OpenClawConfig) + : undefined; + let normalizedRaw = stripPreservedLegacyRootKeysForValidation(raw, opts?.preservedLegacyRootKeys); + let syntheticLegacyOwnership = false; + if (legacyDefaultAgentId && isRecord(normalizedRaw) && isRecord(normalizedRaw.agents)) { + const entries = normalizedRaw.agents.entries; + if ( + isRecord(entries) && + Object.keys(entries).length > 1 && + normalizedRaw.agents.ownership === undefined + ) { + normalizedRaw = { + ...normalizedRaw, + agents: { ...normalizedRaw.agents, ownership: "explicit" }, + }; + syntheticLegacyOwnership = true; + } + } // Generic config transforms can rebuild records before schema validation, so // validate authored MCP names from the parsed source when it is available. const normalizedMcpServerNameIssueKeys = new Set( @@ -323,8 +343,15 @@ export function validateConfigObjectRaw( issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues), }; } - const validatedConfig = attachAgentListProjection( - materializeBundledModelProviderOverlays(validated.data as OpenClawConfig), + let parsedConfig = validated.data as OpenClawConfig; + if (syntheticLegacyOwnership && parsedConfig.agents) { + const agents = { ...parsedConfig.agents }; + delete agents.ownership; + parsedConfig = { ...parsedConfig, agents }; + } + const validatedConfig = inheritLegacyDefaultAgentId( + raw as OpenClawConfig, + attachAgentListProjection(materializeBundledModelProviderOverlays(parsedConfig)), ); const channelIssues = policyIssues.length > 0 || opts?.validateBundledChannels diff --git a/src/config/validation.ts b/src/config/validation.ts index 2fc94da53ba9..c2f06e8ad716 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -2,20 +2,14 @@ import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; -import { - listAgentEntriesWithSource, - resolveAgentWorkspaceDir, - resolveDefaultAgentId, -} from "../agents/agent-scope.js"; +import { listAgentEntriesWithSource } from "../agents/agent-scope.js"; import type { ChannelDmAllowFromMode } from "../channels/plugins/dm-access.js"; import { planManifestModelCatalogSuppressions } from "../model-catalog/index.js"; +import { listChannelIdsForOwnershipMigration } from "../plugins/channel-presence-policy.js"; import { normalizePluginsConfig, normalizePluginId } from "../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-record-reader.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; -import { - resolvePluginMetadataSnapshot, - type PluginMetadataSnapshot, -} from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { resolveWebSearchInstallCatalogEntries } from "../plugins/web-search-install-catalog.js"; import { isRecord } from "../utils.js"; @@ -24,6 +18,12 @@ import { collectChannelDmPolicyMetadata, collectChannelSchemaMetadataWithOwnership, } from "./channel-config-metadata.js"; +import { resolveConfigWidePluginManifestRegistry } from "./io.plugin-metadata.js"; +import { + inheritLegacyDefaultAgentId, + tryGetLegacyDefaultAgentId, +} from "./legacy.default-agent-owner.js"; +import { materializeLegacyDefaultAgentRoles } from "./legacy.default-agent-roles.js"; import { migratePersistedImplicitMainRoster } from "./legacy.roster.js"; import { materializeRuntimeConfig } from "./materialize.js"; import type { ConfigValidationIssue, OpenClawConfig } from "./types.js"; @@ -72,37 +72,79 @@ export function validateConfigObjectWithPlugins( raw: unknown, params?: ValidateConfigWithPluginsParams, ): ValidateConfigWithPluginsResult { - const migrated = migratePersistedImplicitMainRoster(raw).config; - return validateConfigObjectWithPluginsBase(migrated, { - applyDefaults: true, - env: params?.env, - pluginValidation: params?.pluginValidation ?? "full", - pluginMetadataSnapshot: params?.pluginMetadataSnapshot, - loadPluginMetadataSnapshot: params?.loadPluginMetadataSnapshot, - sourceRaw: params?.sourceRaw, - preservedLegacyRootKeys: params?.preservedLegacyRootKeys, - }); + return validateConfigObjectWithPluginMode(raw, params, true); } export function validateConfigObjectRawWithPlugins( raw: unknown, params?: ValidateConfigWithPluginsParams, ): ValidateConfigWithPluginsResult { - const migrated = migratePersistedImplicitMainRoster(raw).config; - return validateConfigObjectWithPluginsBase(migrated, { - applyDefaults: false, + return validateConfigObjectWithPluginMode(raw, params, false); +} + +function validateConfigObjectWithPluginMode( + raw: unknown, + params: ValidateConfigWithPluginsParams | undefined, + applyDefaults: boolean, +): ValidateConfigWithPluginsResult { + const migrated = migratePersistedImplicitMainRoster(raw).config as OpenClawConfig; + let manifestRegistry = params?.pluginMetadataSnapshot?.manifestRegistry; + const result = validateConfigObjectWithPluginsBase(migrated, { + applyDefaults, env: params?.env, pluginValidation: params?.pluginValidation ?? "full", pluginMetadataSnapshot: params?.pluginMetadataSnapshot, loadPluginMetadataSnapshot: params?.loadPluginMetadataSnapshot, sourceRaw: params?.sourceRaw, preservedLegacyRootKeys: params?.preservedLegacyRootKeys, + onManifestRegistryResolved: (registry) => { + manifestRegistry = registry; + }, }); + const legacyDefaultAgentId = tryGetLegacyDefaultAgentId(migrated); + if (!result.ok || !legacyDefaultAgentId) { + return result; + } + // Carry the migration sidecar across Zod's fresh object. + const validatedConfig = inheritLegacyDefaultAgentId(migrated, result.config); + const materialized = materializeLegacyAgentOwnershipForActiveChannelsResult( + validatedConfig, + legacyDefaultAgentId, + params?.env, + manifestRegistry?.plugins, + ); + const config = materialized.config; + return { ...result, config }; +} + +export function materializeLegacyAgentOwnershipForActiveChannelsResult( + config: OpenClawConfig, + legacyDefaultAgentId: string, + env?: NodeJS.ProcessEnv, + manifestRecords?: PluginManifestRegistry["plugins"], + options?: { materializeSessionStore?: boolean; materializeWorkspace?: boolean }, +): ReturnType { + const ambientChannelIds = listChannelIdsForOwnershipMigration({ + config, + env, + ...(manifestRecords ? { manifestRecords } : {}), + }); + const materialized = materializeLegacyDefaultAgentRoles(config, legacyDefaultAgentId, { + ambientChannelIds, + env, + materializeSessionStore: options?.materializeSessionStore, + materializeWorkspace: options?.materializeWorkspace, + }); + const next = inheritLegacyDefaultAgentId(config, materialized.config); + return { ...materialized, config: next }; } function validateConfigObjectWithPluginsBase( raw: unknown, - opts: ValidateConfigWithPluginsParams & { applyDefaults: boolean }, + opts: ValidateConfigWithPluginsParams & { + applyDefaults: boolean; + onManifestRegistryResolved?: (registry: PluginManifestRegistry) => void; + }, ): ValidateConfigWithPluginsResult { const base = validateConfigObjectRaw(raw, { sourceRaw: opts.sourceRaw, @@ -112,21 +154,28 @@ function validateConfigObjectWithPluginsBase( if (!base.ok) { return { ok: false, issues: base.issues, warnings: [] }; } + // Zod returns a fresh object. Preserve the migration-only owner before + // workspace-scoped plugin discovery, or legacy-root plugins disappear here. + const parsedConfig = inheritLegacyDefaultAgentId(raw as OpenClawConfig, base.config); + const rememberRegistry = (registry: PluginManifestRegistry): RegistryInfo => { + opts.onManifestRegistryResolved?.(registry); + return { registry }; + }; let registryInfo: RegistryInfo | null = opts.pluginMetadataSnapshot - ? { registry: opts.pluginMetadataSnapshot.manifestRegistry } + ? rememberRegistry(opts.pluginMetadataSnapshot.manifestRegistry) : null; if (opts.applyDefaults && !registryInfo) { - const pluginMetadataSnapshot = opts.loadPluginMetadataSnapshot?.(base.config); + const pluginMetadataSnapshot = opts.loadPluginMetadataSnapshot?.(parsedConfig); if (pluginMetadataSnapshot) { - registryInfo = { registry: pluginMetadataSnapshot.manifestRegistry }; + registryInfo = rememberRegistry(pluginMetadataSnapshot.manifestRegistry); } } const config = opts.applyDefaults - ? materializeRuntimeConfig(base.config, "snapshot", { + ? materializeRuntimeConfig(parsedConfig, "snapshot", { manifestRegistry: registryInfo?.registry, }) - : base.config; + : parsedConfig; if (opts.pluginValidation === "skip") { return { ok: true, config, warnings: [] }; } @@ -174,17 +223,14 @@ function validateConfigObjectWithPluginsBase( const loadValidationRegistry = (): RegistryInfo => { const pluginMetadataSnapshot = opts.loadPluginMetadataSnapshot?.(config); if (pluginMetadataSnapshot) { - registryInfo = { registry: pluginMetadataSnapshot.manifestRegistry }; + registryInfo = rememberRegistry(pluginMetadataSnapshot.manifestRegistry); return registryInfo; } - const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config), opts.env); - const registry = resolvePluginMetadataSnapshot({ + const registry = resolveConfigWidePluginManifestRegistry({ config, - workspaceDir: workspaceDir ?? undefined, env: opts.env ?? process.env, - allowWorkspaceScopedCurrent: true, - }).manifestRegistry; - registryInfo = { registry }; + }); + registryInfo = rememberRegistry(registry); return registryInfo; }; @@ -297,11 +343,11 @@ function validateConfigObjectWithPluginsBase( // Generic DM-policy/allowFrom dependency check on the raw user config (pre-defaults) // so account inheritance matches the per-channel Zod refinements. warnings.push( - ...(hasChannelDmPolicyDependencyWarningCandidates(base.config) - ? collectChannelDmPolicyDependencyWarnings(base.config, { + ...(hasChannelDmPolicyDependencyWarningCandidates(parsedConfig) + ? collectChannelDmPolicyDependencyWarnings(parsedConfig, { dmAllowFromModes: ensureChannelDmAllowFromModes(), }) - : collectChannelDmPolicyDependencyWarnings(base.config)), + : collectChannelDmPolicyDependencyWarnings(parsedConfig)), ); let mutatedConfig = config; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 1d99f679597c..283d15cd48f0 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -207,6 +207,18 @@ export const AgentDefaultsSchema = z }) .strict() .optional(), + authInheritance: z + .object({ + agentId: z.string().trim().min(1).optional(), + }) + .strict() + .optional(), + sessionStore: z + .object({ + agentId: z.string().trim().min(1).optional(), + }) + .strict() + .optional(), maxConcurrent: z.number().int().positive().optional(), subagents: z .object({ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index d339e6c71a08..ec0631a0b8c3 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -885,7 +885,6 @@ export const AgentModelPolicySchema = z export const AgentEntrySchema = z .object({ id: z.string(), - default: z.boolean().optional(), name: z.string().optional(), description: z.string().optional(), workspace: z.string().optional(), diff --git a/src/config/zod-schema.agents.test.ts b/src/config/zod-schema.agents.test.ts index 553ac6863fab..2047cbf98144 100644 --- a/src/config/zod-schema.agents.test.ts +++ b/src/config/zod-schema.agents.test.ts @@ -2,21 +2,50 @@ import { describe, expect, it } from "vitest"; import { AgentsSchema } from "./zod-schema.agents.js"; import { OpenClawSchema } from "./zod-schema.js"; -describe("agent roster defaults", () => { +describe("agent roster ownership", () => { it("rejects an empty roster after load-time migration", () => { expect(AgentsSchema.safeParse({ entries: {} }).success).toBe(false); }); - it("requires exactly one default in a non-empty roster", () => { - expect(AgentsSchema.safeParse({ entries: { alpha: { default: true } } }).success).toBe(true); - for (const entries of [{ alpha: {} }, { alpha: { default: true }, beta: { default: true } }]) { - const result = AgentsSchema.safeParse({ entries }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues).toContainEqual(expect.objectContaining({ path: ["entries"] })); - } + it("accepts sole and explicitly owned multi-agent rosters without a stored default", () => { + expect(AgentsSchema.safeParse({ entries: { alpha: {} } }).success).toBe(true); + expect( + AgentsSchema.safeParse({ ownership: "explicit", entries: { alpha: {}, beta: {} } }).success, + ).toBe(true); + }); + + it("rejects a markerless multi-agent roster without explicit ownership", () => { + const result = AgentsSchema.safeParse({ entries: { alpha: {}, beta: {} } }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain('agents.ownership="explicit"'); + expect(result.error.issues[0]?.message).toContain("run openclaw doctor"); } }); + + it("accepts one legacy default marker", () => { + expect( + AgentsSchema.safeParse({ entries: { alpha: { default: true }, beta: {} } }).success, + ).toBe(true); + }); + + it("rejects multiple legacy default markers", () => { + expect( + AgentsSchema.safeParse({ + entries: { alpha: { default: true }, beta: { default: true } }, + }).success, + ).toBe(false); + }); + + it("rejects a legacy marker with explicit ownership", () => { + expect( + AgentsSchema.safeParse({ + ownership: "explicit", + entries: { alpha: { default: true }, beta: {} }, + }).success, + ).toBe(false); + }); }); describe("explicit ambient agent targets", () => { @@ -24,16 +53,16 @@ describe("explicit ambient agent targets", () => { { agents: { defaults: { heartbeat: { agentId: "missing" } }, - entries: { main: { default: true } }, + entries: { main: {} }, }, }, { agents: { defaults: { systemAgent: { agentId: "missing" } }, - entries: { main: { default: true } }, + entries: { main: {} }, }, }, - { agents: { entries: { main: { default: true } } }, talk: { agentId: "missing" } }, + { agents: { entries: { main: {} } }, talk: { agentId: "missing" } }, ])("rejects an unknown explicit target", (target) => { const result = OpenClawSchema.safeParse(target); expect(result.success).toBe(false); @@ -42,15 +71,17 @@ describe("explicit ambient agent targets", () => { } }); - it("accepts configured heartbeat, system-agent, and Talk targets", () => { + it("accepts configured heartbeat, system-agent, compatibility, and Talk targets", () => { expect( OpenClawSchema.safeParse({ agents: { defaults: { heartbeat: { agentId: "ops" }, systemAgent: { agentId: "ops" }, + authInheritance: { agentId: "ops" }, + sessionStore: { agentId: "ops" }, }, - entries: { ops: { default: true } }, + entries: { ops: {} }, }, talk: { agentId: "ops" }, }).success, @@ -61,16 +92,28 @@ describe("explicit ambient agent targets", () => { { agents: { defaults: { heartbeat: { agentId: " " } }, - entries: { main: { default: true } }, + entries: { main: {} }, }, }, { agents: { defaults: { systemAgent: { agentId: " " } }, - entries: { main: { default: true } }, + entries: { main: {} }, }, }, - { agents: { entries: { main: { default: true } } }, talk: { agentId: " " } }, + { + agents: { + defaults: { authInheritance: { agentId: " " } }, + entries: { main: {} }, + }, + }, + { + agents: { + defaults: { sessionStore: { agentId: " " } }, + entries: { main: {} }, + }, + }, + { agents: { entries: { main: {} } }, talk: { agentId: " " } }, ])("rejects blank explicit targets", (config) => { expect(OpenClawSchema.safeParse(config).success).toBe(false); }); @@ -79,4 +122,19 @@ describe("explicit ambient agent targets", () => { expect(OpenClawSchema.safeParse({ talk: { agentId: "main" } }).success).toBe(true); expect(OpenClawSchema.safeParse({ talk: { agentId: "missing" } }).success).toBe(false); }); + + it("allows upgrade compatibility owners to outlive their roster entries", () => { + expect( + OpenClawSchema.safeParse({ + agents: { + ownership: "explicit", + defaults: { + authInheritance: { agentId: "retired-ops" }, + sessionStore: { agentId: "retired-ops" }, + }, + entries: { research: {}, writer: {} }, + }, + }).success, + ).toBe(true); + }); }); diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 052e75849a43..adf7b2bafcb6 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -22,11 +22,12 @@ const AgentEntryConfigSchema = z.preprocess( } return value; }, - AgentEntrySchema.omit({ id: true }), + AgentEntrySchema.omit({ id: true }).extend({ default: z.boolean().optional() }), ); export const AgentsSchema = z .object({ + ownership: z.literal("explicit").optional(), defaults: z.lazy(() => AgentDefaultsSchema).optional(), entries: z .record( @@ -37,13 +38,35 @@ export const AgentsSchema = z }) .strict() .superRefine((value, ctx) => { - const agents = Object.values(value.entries ?? {}); - const defaultCount = agents.filter((agent) => agent.default === true).length; - if (defaultCount !== 1) { + const entries = Object.entries(value.entries ?? {}); + if (entries.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["entries"], - message: `agents.entries must contain exactly one default=true entry (found ${defaultCount})`, + message: "agents.entries must contain at least one configured agent", + }); + } + const marked = entries.filter(([, entry]) => entry.default === true); + if (marked.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["entries"], + message: `agents.entries must contain at most one default=true entry (found ${marked.length})`, + }); + } + if (value.ownership === "explicit" && marked.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ownership"], + message: "agents.ownership=explicit cannot be combined with a legacy default=true marker", + }); + } + if (entries.length > 1 && marked.length === 0 && value.ownership !== "explicit") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ownership"], + message: + 'multi-agent rosters require agents.ownership="explicit" or one legacy default=true marker; add agents.ownership="explicit" or run openclaw doctor', }); } }) diff --git a/src/cron/legacy-default-agent-owner-migration.test.ts b/src/cron/legacy-default-agent-owner-migration.test.ts new file mode 100644 index 000000000000..360b4c0a9a35 --- /dev/null +++ b/src/cron/legacy-default-agent-owner-migration.test.ts @@ -0,0 +1,154 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { makeCronJob } from "./delivery.test-helpers.js"; +import { materializeLegacyDefaultCronJobOwners } from "./legacy-default-agent-owner-migration.js"; +import { CronService } from "./service.js"; +import * as cronStoreModule from "./store.js"; +import { cronStoreKey } from "./store/key.js"; +import { loadCronRows, replaceCronRows } from "./store/row-codec.js"; +import { ensureCronStoreEpochSchema } from "./store/schema.js"; +import type { CronStoreFile } from "./types.js"; + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + vi.unstubAllEnvs(); +}); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const migrate = (storePath: string, env: NodeJS.ProcessEnv) => + materializeLegacyDefaultCronJobOwners({ storePath, legacyDefaultAgentId: "ops", env }); + +function fixture(label: string) { + const root = tempDirs.make(label); + const env = { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv; + const storePath = path.join(root, "cron", "jobs.json"); + const storeKey = cronStoreKey(storePath); + const database = openOpenClawStateDatabase({ env }).db; + replaceCronRows(database, storeKey, { version: 1, jobs: [makeCronJob({ id: "ownerless" })] }); + return { env, storePath, storeKey, database }; +} + +it("preserves undecodable JSON and bumps the epoch once", () => { + const { env, storePath, storeKey, database } = fixture("openclaw-cron-owner-"); + database + .prepare("UPDATE cron_jobs SET agent_id = ' ', job_json = ? WHERE store_key = ?") + .run("{malformed", storeKey); + + expect(migrate(storePath, env)).toBe(1); + expect(loadCronRows(database, storeKey)[0]).toMatchObject({ + agent_id: "ops", + job_json: "{malformed", + }); + expect( + ( + database + .prepare("SELECT store_epoch FROM cron_store_epochs WHERE store_key = ?") + .get(storeKey) as { store_epoch: number } + ).store_epoch, + ).toBe(1); +}); + +it("preserves a session-scoped owner stored only in job JSON", () => { + const { env, storePath, storeKey, database } = fixture("openclaw-cron-json-owner-"); + const row = loadCronRows(database, storeKey)[0]; + const jobJson = JSON.parse(row?.job_json ?? "{}") as Record; + delete jobJson.agentId; + jobJson.sessionKey = "agent:research:main"; + database + .prepare( + "UPDATE cron_jobs SET agent_id = NULL, session_key = NULL, job_json = ? WHERE store_key = ?", + ) + .run(JSON.stringify(jobJson), storeKey); + + expect(migrate(storePath, env)).toBe(0); + const preserved = loadCronRows(database, storeKey)[0]; + const preservedJobJson = JSON.parse(preserved?.job_json ?? "{}") as Record; + expect(preserved?.agent_id).toBeNull(); + expect(preservedJobJson).toMatchObject({ + sessionKey: "agent:research:main", + }); + expect(preservedJobJson).not.toHaveProperty("agentId"); +}); + +it("rolls back the row when the epoch bump fails", () => { + const { env, storePath, storeKey, database } = fixture("openclaw-cron-atomic-"); + ensureCronStoreEpochSchema(database); + database.exec(`CREATE TRIGGER fail_epoch BEFORE UPDATE OF store_epoch ON cron_store_epochs + BEGIN SELECT RAISE(ABORT, 'synthetic epoch failure'); END`); + + expect(() => migrate(storePath, env)).toThrow("synthetic epoch failure"); + expect(loadCronRows(database, storeKey)[0]?.agent_id).toBeNull(); +}); + +it("materializes before scheduler startup", async () => { + const { env, storePath } = fixture("openclaw-cron-startup-"); + vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR); + closeOpenClawStateDatabaseForTest(); + const cron = new CronService({ + storePath, + cronEnabled: true, + legacyDefaultAgentId: "ops", + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + try { + await cron.start(); + expect(cron.getLoadedJobs()?.[0]?.agentId).toBe("ops"); + } finally { + cron.stop(); + } +}); + +it("owns rows imported from a JSON-only store on first startup load", async () => { + const root = tempDirs.make("openclaw-cron-json-startup-"); + const env = { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv; + const storePath = path.join(root, "cron", "jobs.json"); + const storeKey = cronStoreKey(storePath); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify({ version: 1, jobs: [makeCronJob({ id: "json-only" })] }), + ); + vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR); + + const realLoad = cronStoreModule.loadCronJobsStoreWithConfigJobs; + let imported = false; + const loadSpy = vi + .spyOn(cronStoreModule, "loadCronJobsStoreWithConfigJobs") + .mockImplementation(async (requestedStorePath) => { + if (!imported) { + imported = true; + const legacyStore = JSON.parse( + await fs.readFile(requestedStorePath, "utf8"), + ) as CronStoreFile; + replaceCronRows(openOpenClawStateDatabase({ env }).db, storeKey, legacyStore); + } + return await realLoad(requestedStorePath); + }); + + const cron = new CronService({ + storePath, + cronEnabled: true, + legacyDefaultAgentId: "ops", + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + try { + await cron.start(); + expect(imported).toBe(true); + expect(cron.getLoadedJobs()?.[0]?.agentId).toBe("ops"); + expect(loadCronRows(openOpenClawStateDatabase({ env }).db, storeKey)[0]?.agent_id).toBe("ops"); + } finally { + cron.stop(); + loadSpy.mockRestore(); + } +}); diff --git a/src/cron/legacy-default-agent-owner-migration.ts b/src/cron/legacy-default-agent-owner-migration.ts new file mode 100644 index 000000000000..f350a0188aea --- /dev/null +++ b/src/cron/legacy-default-agent-owner-migration.ts @@ -0,0 +1,19 @@ +import path from "node:path"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; +import { cronStoreKey } from "./store/key.js"; +import { materializeCronRowAgentOwners } from "./store/row-codec.js"; + +export function materializeLegacyDefaultCronJobOwners(params: { + storePath: string; + legacyDefaultAgentId: string; + env?: NodeJS.ProcessEnv; +}): number { + const agentId = normalizeAgentId(params.legacyDefaultAgentId); + return runOpenClawStateWriteTransaction( + ({ db }) => + materializeCronRowAgentOwners(db, cronStoreKey(path.resolve(params.storePath)), agentId), + { env: params.env }, + { operationLabel: "cron.legacy-default-owner" }, + ); +} diff --git a/src/cron/service/ops-lifecycle.ts b/src/cron/service/ops-lifecycle.ts index a731f0fadff6..fe5e806bd2a4 100644 --- a/src/cron/service/ops-lifecycle.ts +++ b/src/cron/service/ops-lifecycle.ts @@ -1,3 +1,4 @@ +import { materializeLegacyDefaultCronJobOwners } from "../legacy-default-agent-owner-migration.js"; import { failureNotificationDeliveryFromJobState } from "./failure-alerts.js"; import { nextWakeAtMs, recomputeNextRunsForMaintenance } from "./jobs-scheduling.js"; import { locked } from "./locked.js"; @@ -32,6 +33,24 @@ export async function start(state: CronServiceState) { if (state.stopped) { return; } + if (state.deps.legacyDefaultAgentId) { + const rewritten = materializeLegacyDefaultCronJobOwners({ + storePath: state.deps.storePath, + legacyDefaultAgentId: state.deps.legacyDefaultAgentId, + }); + if (rewritten > 0) { + state.deps.log.info( + { storePath: state.deps.storePath, rewritten }, + "cron: assigned legacy jobs to the retained owner", + ); + // The first load can import legacy JSON into SQLite. Refresh the runtime + // snapshot after ownership is committed and before any job can run. + await ensureLoaded(state, { forceReload: true, skipRecompute: true }); + } + } + if (state.stopped) { + return; + } const jobs = state.store?.jobs ?? []; for (const job of jobs) { job.state ??= {}; diff --git a/src/cron/service/ops-mutations.ts b/src/cron/service/ops-mutations.ts index d28f8d695f2c..a55f07a03148 100644 --- a/src/cron/service/ops-mutations.ts +++ b/src/cron/service/ops-mutations.ts @@ -4,6 +4,7 @@ import { AgentDeletionAuthorityRollbackError, AgentDeletionCommitUncertainError, } from "../../agents/agent-lifecycle-registry.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { type CronActiveJobMarker, isCronJobActive, @@ -372,8 +373,16 @@ export async function add( if (normalizedId && state.store?.jobs.some((job) => job.id === normalizedId)) { throw new Error(`cron job already exists: ${normalizedId}`); } + const explicitOwnerAgentId = + normalizeOptionalAgentId(normalizedInput.agentId) ?? + parseAgentSessionKey(normalizeOptionalString(normalizedInput.sessionKey))?.agentId; + const retainedLegacyAgentId = normalizeOptionalAgentId(state.deps.legacyDefaultAgentId); + const creationInput = + !explicitOwnerAgentId && retainedLegacyAgentId === agentId + ? { ...normalizedInput, agentId } + : normalizedInput; const snapshot = snapshotStoreForRollback(state); - const job = createJob(state, normalizedInput, { + const job = createJob(state, creationInput, { scheduledToolPolicy: opts?.scheduledToolPolicy, toolsAllowProvenance: opts?.toolsAllowProvenance, configuredChannels, diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 206606a4e870..9e6083867ad2 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -94,7 +94,8 @@ export type CronServiceDeps = { /** Default agent id for jobs without an agent id. */ defaultAgentId?: string; /** Resolve the current default when runtime config can change after startup. */ - resolveDefaultAgentId?: () => string; + resolveDefaultAgentId?: () => string | undefined; + legacyDefaultAgentId?: string; /** Resolve configured or persisted owners whose session stores need periodic cleanup. */ resolveSessionStoreAgentIds?: () => string[]; /** Revalidate agent ownership inside the cron mutation lock. */ diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index f90b3b11a725..78f1506013c8 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -2,8 +2,10 @@ import type { DatabaseSync } from "node:sqlite"; import { safeParseJson } from "@openclaw/normalization-core"; import { asOptionalObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js"; import { normalizeCronJobInput } from "../normalize.js"; import { getInvalidPersistedCronJobReason } from "../persisted-shape.js"; @@ -19,9 +21,14 @@ import type { import { bindDeliveryColumns, deliveryFromRow } from "./delivery-codec.js"; import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js"; import { bindPayloadColumns, payloadFromRow } from "./payload-codec.js"; -import { booleanToInteger, integerToBoolean, normalizeNumber } from "./scalar-codec.js"; +import { + booleanToInteger, + integerToBoolean, + normalizeNumber, + tryParseJsonObject, +} from "./scalar-codec.js"; import type { CronJobInsert, CronJobRow } from "./schema.js"; -import { getCronStoreKysely } from "./schema.js"; +import { ensureCronStoreEpochSchema, getCronStoreKysely } from "./schema.js"; import { bindStateColumns, stateFromRow } from "./state-codec.js"; import { bindTriggerColumns, triggerFromRow } from "./trigger-codec.js"; import type { LoadedCronStore } from "./types.js"; @@ -367,6 +374,67 @@ export function loadCronRows(db: DatabaseSync, storeKey: string): CronJobRow[] { ).rows; } +function incrementCronStoreEpoch(db: DatabaseSync, storeKey: string): void { + ensureCronStoreEpochSchema(db); + executeSqliteQuerySync( + db, + getCronStoreKysely(db) + .insertInto("cron_store_epochs") + .values({ store_key: storeKey, store_epoch: 0 }) + .onConflict((conflict) => conflict.column("store_key").doNothing()), + ); + executeSqliteQuerySync( + db, + getCronStoreKysely(db) + .updateTable("cron_store_epochs") + .set((eb) => ({ store_epoch: eb("store_epoch", "+", 1) })) + .where("store_key", "=", storeKey), + ); +} + +/** Materializes retired ownership; the caller's transaction commits row and epoch updates together. */ +export function materializeCronRowAgentOwners( + db: DatabaseSync, + storeKey: string, + legacyDefaultAgentId: string, +): number { + const agentId = normalizeAgentId(legacyDefaultAgentId); + let rewritten = 0; + for (const row of loadCronRows(db, storeKey)) { + const jobJson = tryParseJsonObject(row.job_json); + const jsonSessionAgentId = parseAgentSessionKey( + normalizeOptionalString(jobJson?.sessionKey), + )?.agentId; + if ( + normalizeOptionalString(row.agent_id) || + normalizeOptionalString(jobJson?.agentId) || + parseAgentSessionKey(row.session_key)?.agentId || + jsonSessionAgentId + ) { + continue; + } + if (jobJson) { + jobJson.agentId = agentId; + } + executeSqliteQuerySync( + db, + getCronStoreKysely(db) + .updateTable("cron_jobs") + .set({ + agent_id: agentId, + ...(jobJson ? { job_json: JSON.stringify(jobJson) } : {}), + }) + .where("store_key", "=", storeKey) + .where("job_id", "=", row.job_id), + ); + rewritten += 1; + } + if (rewritten > 0) { + incrementCronStoreEpoch(db, storeKey); + } + return rewritten; +} + export type CronJobFamilyIdentity = { declarationKey: string; name: string; diff --git a/src/cron/store/scalar-codec.ts b/src/cron/store/scalar-codec.ts index 05ac7151199a..613f17c3c132 100644 --- a/src/cron/store/scalar-codec.ts +++ b/src/cron/store/scalar-codec.ts @@ -1,6 +1,12 @@ import { safeParseJson } from "@openclaw/normalization-core"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeSqliteNumber } from "../../infra/sqlite-number.js"; +export function tryParseJsonObject(raw: string): Record | undefined { + const parsed = safeParseJson(raw); + return isRecord(parsed) ? parsed : undefined; +} + /** Normalizes SQLite number/bigint columns into JavaScript numbers. */ export { normalizeSqliteNumber as normalizeNumber }; diff --git a/src/cron/store/schema.ts b/src/cron/store/schema.ts index a94abc4e33ea..97ad54d90693 100644 --- a/src/cron/store/schema.ts +++ b/src/cron/store/schema.ts @@ -5,7 +5,10 @@ import { getNodeSqliteKysely } from "../../infra/kysely-sync.js"; import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js"; type CronJobsTable = OpenClawStateKyselyDatabase["cron_jobs"]; -type CronStoreDatabase = Pick; +type CronStoreDatabase = Pick< + OpenClawStateKyselyDatabase, + "cron_job_scratch" | "cron_jobs" | "cron_store_epochs" +>; /** Read shape for rows in the cron_jobs SQLite table. */ export type CronJobRow = Selectable; @@ -17,3 +20,12 @@ export type CronJobInsert = Insertable; export function getCronStoreKysely(db: DatabaseSync) { return getNodeSqliteKysely(db); } + +export function ensureCronStoreEpochSchema(db: DatabaseSync): void { + db.exec(/* sqlite-allow-raw: additive schema DDL is outside Kysely's query builder. */ ` + CREATE TABLE IF NOT EXISTS cron_store_epochs ( + store_key TEXT PRIMARY KEY, + store_epoch INTEGER NOT NULL DEFAULT 0 + ) STRICT + `); +} diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 5f74df0fa84d..6b1150ffe9e4 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -103,9 +103,11 @@ const mocks = vi.hoisted(() => ({ warnings: [], }), listAgentIds: vi.fn<(_cfg: OpenClawConfig) => string[]>(() => ["default"]), + listAgentEntries: vi.fn(() => [{ id: "default" }]), resolveAgentWorkspaceDir: vi.fn<(_cfg: OpenClawConfig, agentId: string) => string>( () => "/tmp/openclaw-workspace", ), + tryResolveConfiguredAgentWorkspaceDir: vi.fn(() => "/tmp/openclaw-workspace"), resolveDefaultAgentId: vi.fn<(_cfg: OpenClawConfig) => string>(() => "default"), resolveAgentContextLimits: vi.fn( (cfg: { agents?: { defaults?: { contextLimits?: unknown } } }) => @@ -386,7 +388,9 @@ vi.mock("../commands/doctor-browser.js", () => ({ vi.mock("../agents/agent-scope.js", () => ({ listAgentIds: mocks.listAgentIds, + listAgentEntries: mocks.listAgentEntries, resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + tryResolveConfiguredAgentWorkspaceDir: mocks.tryResolveConfiguredAgentWorkspaceDir, resolveDefaultAgentId: mocks.resolveDefaultAgentId, resolveAgentContextLimits: mocks.resolveAgentContextLimits, })); @@ -698,8 +702,12 @@ describe("doctor health contributions", () => { }); mocks.resolveAgentWorkspaceDir.mockReset(); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); + mocks.tryResolveConfiguredAgentWorkspaceDir.mockReset(); + mocks.tryResolveConfiguredAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); mocks.listAgentIds.mockReset(); mocks.listAgentIds.mockReturnValue(["default"]); + mocks.listAgentEntries.mockReset(); + mocks.listAgentEntries.mockReturnValue([{ id: "default" }]); mocks.resolveDefaultAgentId.mockReset(); mocks.resolveDefaultAgentId.mockReturnValue("default"); mocks.resolveAgentContextLimits.mockReset(); diff --git a/src/gateway/agent-list.test.ts b/src/gateway/agent-list.test.ts index 571764691a71..642854501c6b 100644 --- a/src/gateway/agent-list.test.ts +++ b/src/gateway/agent-list.test.ts @@ -5,10 +5,41 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { listGatewayAgentsBasic } from "./agent-list.js"; describe("listGatewayAgentsBasic", () => { + it("projects sole, retained-legacy, and explicit fleet ownership honestly", async () => { + await withStateDirEnv("openclaw-agent-list-", async () => { + expect(listGatewayAgentsBasic({ agents: { entries: { ops: {} } } })).toMatchObject({ + defaultId: "ops", + ownership: "sole", + selectionRequired: false, + }); + + const legacy = retainLegacyDefaultAgentId( + { agents: { entries: { first: {}, retired: {}, research: {} } } }, + "retired", + ); + expect(listGatewayAgentsBasic(legacy)).toMatchObject({ + defaultId: "retired", + ownership: "legacy", + selectionRequired: false, + }); + + expect( + listGatewayAgentsBasic({ + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + }), + ).toMatchObject({ + defaultId: "ops", + ownership: "explicit", + selectionRequired: true, + }); + }); + }); + it("retains disk system agents without treating regular disk dirs as roster members", async () => { await withStateDirEnv("openclaw-agent-list-", async ({ stateDir }) => { await Promise.all( diff --git a/src/gateway/agent-list.ts b/src/gateway/agent-list.ts index 8de344552660..800199e312ea 100644 --- a/src/gateway/agent-list.ts +++ b/src/gateway/agent-list.ts @@ -3,7 +3,8 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries, tryResolveDefaultAgentId } from "../agents/agent-scope.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { resolveStateDir } from "../config/paths.js"; import type { SessionScope } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -17,6 +18,14 @@ type GatewayAgentListRow = { name?: string; }; +export type GatewayAgentOwnership = "sole" | "legacy" | "explicit"; + +type GatewayAgentSelectionState = { + defaultId: string; + ownership: GatewayAgentOwnership; + selectionRequired: boolean; +}; + const OWNER_ROSTER_ENTRIES = SYSTEM_AGENT_ROSTER_ENTRIES satisfies ReadonlyArray<{ id: string; kind: GatewayAgentKind; @@ -35,9 +44,34 @@ function listExistingAgentIdsFromDisk(): string[] { } } +export function resolveGatewayAgentSelectionState(cfg: OpenClawConfig): GatewayAgentSelectionState { + const configuredIds = listAgentEntries(cfg).map((entry) => normalizeAgentId(entry.id)); + const soleAgentId = tryResolveDefaultAgentId(cfg); + if (soleAgentId) { + return { + defaultId: normalizeAgentId(soleAgentId), + ownership: "sole", + selectionRequired: false, + }; + } + const legacyAgentId = tryResolveLegacyCompatibilityAgentId(cfg); + const legacyCompatibleId = legacyAgentId ?? configuredIds[0]; + if (!legacyCompatibleId) { + throw new Error("Cannot project gateway agent ownership without a configured agent."); + } + const defaultId = normalizeAgentId(legacyCompatibleId); + return { + defaultId, + ownership: legacyAgentId ? "legacy" : "explicit", + selectionRequired: !legacyAgentId, + }; +} + /** Lists gateway-visible agents with canonical membership, ordering, and semantic kind. */ export function listGatewayAgentsBasic(cfg: OpenClawConfig): { defaultId: string; + ownership?: GatewayAgentOwnership; + selectionRequired?: boolean; mainKey: string; scope: SessionScope; agents: GatewayAgentListRow[]; @@ -45,13 +79,15 @@ export function listGatewayAgentsBasic(cfg: OpenClawConfig): { const ownerEntries = new Map( OWNER_ROSTER_ENTRIES.map((entry) => [normalizeAgentId(entry.id), entry] as const), ); - const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const selection = resolveGatewayAgentSelectionState(cfg); + const defaultId = selection.defaultId; const mainKey = normalizeMainKey(cfg.session?.mainKey); const scope = cfg.session?.scope ?? "per-sender"; const configuredById = new Map(); const explicitIds = new Set(); const diskIds = new Set(); - const agentIds = new Set([defaultId]); + const agentIds = new Set(); + agentIds.add(normalizeAgentId(defaultId)); for (const entry of listAgentEntries(cfg)) { if (!entry?.id) { @@ -70,7 +106,7 @@ export function listGatewayAgentsBasic(cfg: OpenClawConfig): { agentIds.add(id); } - const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null; + const allowedIds = explicitIds.size > 0 ? new Set(explicitIds) : null; const visibleIds = [...agentIds].filter( (id) => !allowedIds || @@ -79,9 +115,10 @@ export function listGatewayAgentsBasic(cfg: OpenClawConfig): { (diskIds.has(id) && ownerEntries.has(id)), ); visibleIds.sort((a, b) => a.localeCompare(b)); - const orderedIds = visibleIds.includes(defaultId) - ? [defaultId, ...visibleIds.filter((id) => id !== defaultId)] - : visibleIds; + const orderedIds = + defaultId && visibleIds.includes(defaultId) + ? [defaultId, ...visibleIds.filter((id) => id !== defaultId)] + : visibleIds; if (mainKey && !orderedIds.includes(mainKey) && (!allowedIds || allowedIds.has(mainKey))) { orderedIds.push(mainKey); } @@ -92,5 +129,5 @@ export function listGatewayAgentsBasic(cfg: OpenClawConfig): { !explicitIds.has(id) && diskIds.has(id) ? (ownerEntries.get(id)?.kind ?? "agent") : "agent", name: configuredById.get(id)?.name, })); - return { defaultId, mainKey, scope, agents }; + return { ...selection, mainKey, scope, agents }; } diff --git a/src/gateway/agent-turn/agent-admission-controller.ts b/src/gateway/agent-turn/agent-admission-controller.ts index 701291f7d1cd..961dcf563c03 100644 --- a/src/gateway/agent-turn/agent-admission-controller.ts +++ b/src/gateway/agent-turn/agent-admission-controller.ts @@ -1,5 +1,4 @@ import { isFutureDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { AGENT_RUN_RESTART_ABORT_STOP_REASON, createAgentRunRestartAbortError, @@ -66,10 +65,7 @@ export function createAgentAdmissionController(params: { const admissionAgentId = () => { const resolvedSessionKey = params.getResolvedSessionKey(); return ( - params.getResolvedSessionAgentId() ?? - (resolvedSessionKey === "global" - ? (params.getAgentId() ?? resolveDefaultAgentId(params.getCfgForAgent() ?? params.cfg)) - : undefined) + params.getResolvedSessionAgentId() ?? (resolvedSessionKey ? params.getAgentId() : undefined) ); }; @@ -86,6 +82,7 @@ export function createAgentAdmissionController(params: { runId: params.runId, sessionKey: resolvedSessionKey, alternateSessionKeys: [params.preAcceptedReservedSessionKey, requestedSessionKey], + agentId: admissionAgentId(), }) ) { if (commitOutcome) { diff --git a/src/gateway/agent-turn/agent-content-phase.ts b/src/gateway/agent-turn/agent-content-phase.ts index 94e0642ec9cc..75d0ff1bef07 100644 --- a/src/gateway/agent-turn/agent-content-phase.ts +++ b/src/gateway/agent-turn/agent-content-phase.ts @@ -2,7 +2,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, @@ -15,11 +14,7 @@ import { } from "../../infra/voicewake-routing.js"; import type { MediaFact } from "../../media/media-facts.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; -import { - classifySessionKeyShape, - isAcpSessionKey, - normalizeAgentId, -} from "../../routing/session-key.js"; +import { classifySessionKeyShape, isAcpSessionKey } from "../../routing/session-key.js"; import { annotateInterSessionPromptText, type InputProvenance, @@ -38,6 +33,7 @@ import { } from "../chat-attachments.js"; import type { AgentRunRequest } from "../server-methods/agent-request-types.js"; import type { GatewayRequestHandlerOptions } from "../server-methods/types.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { loadSessionEntry, resolveGatewayModelSupportsImages, @@ -106,8 +102,7 @@ export async function prepareAgentContentPhase(params: { ...(agentId ? { agentId } : {}), clone: false, }); - const sessionAgentId = - canonicalKey === "global" && agentId ? agentId : resolveAgentIdFromSessionKey(canonicalKey); + const sessionAgentId = resolveAgentIdFromSessionKey(canonicalKey, agentId); catalogAgentId = sessionAgentId; const modelRef = resolveSessionModelRef(cfg, entry, sessionAgentId); baseProvider = modelRef.provider; @@ -182,22 +177,26 @@ export async function prepareAgentContentPhase(params: { const to = params.sessionKeyFromTo ? "" : (params.explicitRecipientSession?.to ?? params.requestedToRaw ?? ""); - const explicitVoiceWakeSessionTarget = - !agentId && params.requestedSessionKeyRaw - ? (() => { - const { cfg, canonicalKey } = loadSessionEntry(params.requestedSessionKeyRaw!, { - clone: false, - }); - const routedAgentId = resolveAgentIdFromSessionKey(canonicalKey); - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); - if (routedAgentId !== defaultAgentId) { - return true; - } - return canonicalKey !== resolveAgentMainSessionKey({ cfg, agentId: routedAgentId }); - })() - : false; + const explicitVoiceWakeSessionTarget = params.requestedSessionKeyRaw + ? (() => { + const { cfg, canonicalKey } = loadSessionEntry(params.requestedSessionKeyRaw!, { + ...(agentId ? { agentId } : {}), + clone: false, + }); + const routedAgentId = resolveAgentIdFromSessionKey(canonicalKey, agentId); + const compatibilityOwner = tryResolveSessionCompatibilityOwnerAgentId(cfg, canonicalKey); + if (!compatibilityOwner || routedAgentId !== compatibilityOwner) { + return true; + } + return canonicalKey !== resolveAgentMainSessionKey({ cfg, agentId: routedAgentId }); + })() + : false; const canAutoRouteVoiceWake = - !agentId && !explicitVoiceWakeSessionTarget && !params.requestedSessionId && !replyTo && !to; + !normalizeOptionalString(params.request.agentId) && + !explicitVoiceWakeSessionTarget && + !params.requestedSessionId && + !replyTo && + !to; if (Object.hasOwn(params.request, "voiceWakeTrigger") && canAutoRouteVoiceWake) { try { const route = resolveVoiceWakeRouteByTrigger({ diff --git a/src/gateway/agent-turn/agent-dedupe-lifecycle.ts b/src/gateway/agent-turn/agent-dedupe-lifecycle.ts index 8bd337cd2062..466fa6f42f44 100644 --- a/src/gateway/agent-turn/agent-dedupe-lifecycle.ts +++ b/src/gateway/agent-turn/agent-dedupe-lifecycle.ts @@ -11,7 +11,6 @@ import { sessionResetAckText, } from "../server-methods/agent-session-reset.js"; import { emitSessionsChanged } from "../server-methods/session-change-event.js"; -import { resolveSessionStoreKey } from "../session-utils.js"; import { isAcceptedAgentDedupePayload, isPreRegistrationAbortedAgentDedupeEntryForSession, @@ -45,9 +44,6 @@ export function createAgentDedupeLifecycle(params: { if (reserved) { return; } - const dedupeSessionResolvesGlobal = sessionKey - ? resolveSessionStoreKey({ cfg: params.cfg, sessionKey }) === "global" - : false; const acceptedAt = Date.now(); const pendingTimeoutMs = resolveAgentTimeoutMs({ cfg: params.cfg, @@ -65,9 +61,7 @@ export function createAgentDedupeLifecycle(params: { reservationId, status: "accepted" as const, ...(sessionKey ? { sessionKey } : {}), - ...(dedupeAgentId && (!sessionKey || dedupeSessionResolvesGlobal) - ? { agentId: dedupeAgentId } - : {}), + ...(dedupeAgentId ? { agentId: dedupeAgentId } : {}), controlUiVisible: !params.suppressVisibleSessionEffects, acceptedAt, dedupeKeys: params.agentDedupeKeys, @@ -128,9 +122,7 @@ export function createAgentDedupeLifecycle(params: { params.io.emitAcceptance([true, responsePayload, undefined], { runId: params.runId }); emitSessionsChanged(params.context, { sessionKey: completion.sessionKey, - ...(completion.sessionKey === "global" && completion.agentId - ? { agentId: completion.agentId } - : {}), + ...(completion.agentId ? { agentId: completion.agentId } : {}), reason: completion.reason, }); return true; diff --git a/src/gateway/agent-turn/agent-dedupe.ts b/src/gateway/agent-turn/agent-dedupe.ts index ca4ad47198e2..b346987c05bd 100644 --- a/src/gateway/agent-turn/agent-dedupe.ts +++ b/src/gateway/agent-turn/agent-dedupe.ts @@ -67,6 +67,7 @@ export function isPreRegistrationAbortedAgentDedupeEntryForSession(params: { runId: string; sessionKey?: string; alternateSessionKeys?: Array; + agentId?: string; }): boolean { if (!params.entry?.ok || !isPreRegistrationAbortedAgentDedupePayload(params.entry.payload)) { return false; @@ -80,6 +81,13 @@ export function isPreRegistrationAbortedAgentDedupeEntryForSession(params: { typeof payload.sessionKey === "string" && payload.sessionKey.trim() ? payload.sessionKey.trim() : undefined; + const payloadAgentId = + typeof payload.agentId === "string" && payload.agentId.trim() + ? payload.agentId.trim() + : undefined; + if (params.agentId && payloadAgentId !== params.agentId) { + return false; + } const expectedSessionKeys = new Set( [params.sessionKey, ...(params.alternateSessionKeys ?? [])].filter((value): value is string => Boolean(value?.trim()), diff --git a/src/gateway/agent-turn/agent-delivery-phase.ts b/src/gateway/agent-turn/agent-delivery-phase.ts index 234dc6c92a0a..d96db6802522 100644 --- a/src/gateway/agent-turn/agent-delivery-phase.ts +++ b/src/gateway/agent-turn/agent-delivery-phase.ts @@ -1,6 +1,5 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveAgentIdFromSessionKey, type SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -16,8 +15,10 @@ import { isInternalNonDeliveryChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { resolveChatRunOwnerAgentId } from "../chat-run-owner.js"; import type { AgentRunRequest } from "../server-methods/agent-request-types.js"; import type { GatewayRequestHandlerOptions } from "../server-methods/types.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { formatForLog } from "../ws-log.js"; import type { AgentTurnContext, AgentTurnPrincipal } from "./types.js"; @@ -56,20 +57,36 @@ export async function resolveAgentDeliveryPhase(params: { isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; onRunObserved?: (runId: string) => void; }): Promise { - const activeSessionAgentId = - params.resolvedSessionKey === "global" && params.resolvedSessionAgentId - ? params.resolvedSessionAgentId - : params.resolvedSessionKey - ? resolveAgentIdFromSessionKey(params.resolvedSessionKey) - : (params.agentId ?? resolveDefaultAgentId(params.cfgForAgent ?? params.cfg)); + const activeSessionAgentId = params.resolvedSessionAgentId + ? params.resolvedSessionAgentId + : params.resolvedSessionKey + ? resolveAgentIdFromSessionKey(params.resolvedSessionKey, params.agentId) + : params.agentId; + if (!activeSessionAgentId) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "agent selection is required for this session"), + ); + return undefined; + } if (params.onRunObserved) { params.onRunObserved(params.runId); + const compatibilityOwnerAgentId = params.resolvedSessionKey + ? tryResolveSessionCompatibilityOwnerAgentId( + params.cfgForAgent ?? params.cfg, + params.resolvedSessionKey, + ) + : undefined; for (const [activeRunId, active] of params.context.chatAbortControllers) { const sameSession = active.sessionKey === params.resolvedSessionKey; - const sameSelectedGlobalAgent = - params.resolvedSessionKey === "global" ? active.agentId === activeSessionAgentId : true; - if (activeRunId !== params.runId && sameSession && sameSelectedGlobalAgent) { + const activeOwner = resolveChatRunOwnerAgentId({ + agentId: active.agentId, + sessionKey: active.sessionKey, + defaultAgentId: compatibilityOwnerAgentId, + }); + if (activeRunId !== params.runId && sameSession && activeOwner === activeSessionAgentId) { params.onRunObserved(activeRunId); } } diff --git a/src/gateway/agent-turn/agent-request-preflight.test.ts b/src/gateway/agent-turn/agent-request-preflight.test.ts index fac2b3625655..1172c447253c 100644 --- a/src/gateway/agent-turn/agent-request-preflight.test.ts +++ b/src/gateway/agent-turn/agent-request-preflight.test.ts @@ -449,3 +449,51 @@ describe("agent request restart recovery preflight", () => { ); }); }); + +describe("agent request session ownership preflight", () => { + function runBareSessionPreflight(owner?: string) { + const respond = vi.fn(); + const result = prepareAgentRequestPreflight({ + request: { + message: "continue", + sessionKey: "global", + idempotencyKey: "bare-session-run", + }, + io: createAgentTurnIo(respond), + context: { + getRuntimeConfig: () => ({ + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: owner ? { sessionStore: { agentId: owner } } : undefined, + entries: { ops: {}, research: {} }, + }, + }), + dedupe: new Map(), + }, + client: null, + } as never); + return { respond, result }; + } + + it("admits a bare key owned by the configured fixed store", () => { + const { respond, result } = runBareSessionPreflight("ops"); + + expect(result).toBeDefined(); + expect(respond).not.toHaveBeenCalled(); + }); + + it("rejects an ownerless bare key with a typed selection error", () => { + const { respond, result } = runBareSessionPreflight(); + + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("has no explicit owner"), + }), + ); + }); +}); diff --git a/src/gateway/agent-turn/agent-request-preflight.ts b/src/gateway/agent-turn/agent-request-preflight.ts index 5eccfcef49ae..01c3b063dc3c 100644 --- a/src/gateway/agent-turn/agent-request-preflight.ts +++ b/src/gateway/agent-turn/agent-request-preflight.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../agents/agent-scope.js"; import { parseExecApprovalFollowupApprovalId } from "../../agents/bash-tools.exec-approval-followup-state.js"; import { normalizeSpawnedRunMetadata } from "../../agents/spawned-context.js"; import { @@ -10,11 +10,9 @@ import { } from "../../agents/subagents/registry/subagent-registry-memory.js"; import { resolveSwarmConfig } from "../../agents/subagents/swarm/swarm-config.js"; import { validateStructuredOutputSchema } from "../../agents/subagents/swarm/swarm-output-schema.js"; -import { - resolveAgentIdFromSessionKey, - resolveSessionStorePathCore, -} from "../../config/sessions.js"; +import { resolveSessionStorePathCore } from "../../config/sessions.js"; import { loadSessionEntry } from "../../config/sessions/session-accessor.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { isMainSessionRestartRecoveryInputProvenance, normalizeInputProvenance, @@ -26,6 +24,7 @@ import { type ExpectedExistingSessionConstraint, } from "../server-methods/agent-expected-session.js"; import type { AgentRunRequest } from "../server-methods/agent-request-types.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { readGatewayDedupeEntry, resolveAgentDedupeKeys } from "./agent-dedupe.js"; import { resolveAllowModelOverrideFromClient, @@ -68,6 +67,23 @@ export function prepareAgentRequestPreflight(params: { const cfg = params.context.getRuntimeConfig(); const canUseInternalRuntimeHandoff = resolveCanUseInternalRuntimeHandoff(params.client); const requestSessionKey = request.sessionKey?.trim(); + const parsedRequestSessionKey = requestSessionKey + ? parseAgentSessionKey(requestSessionKey) + : undefined; + const bareSessionAgent = + requestSessionKey && !parsedRequestSessionKey + ? resolveRequestedSessionAgentId(cfg, requestSessionKey, request.agentId) + : undefined; + if (bareSessionAgent && !bareSessionAgent.ok) { + params.io.emitAcceptance([false, undefined, bareSessionAgent.error]); + return undefined; + } + const selectedAgentId = requestSessionKey + ? (parsedRequestSessionKey?.agentId ?? + bareSessionAgent?.agentId ?? + normalizeOptionalString(request.agentId) ?? + tryResolveLegacyCompatibilityAgentId(cfg)) + : (normalizeOptionalString(request.agentId) ?? tryResolveLegacyCompatibilityAgentId(cfg)); const collectorSession = findSwarmCollectorSession(requestSessionKey); // Collector children always use subagent session keys, so ordinary traffic // must never pay the persisted-store read. The store fallback only covers a @@ -75,8 +91,9 @@ export function prepareAgentRequestPreflight(params: { const persistedCollectorSession = !collectorSession && requestSessionKey && isSubagentSessionKey(requestSessionKey) ? loadSessionEntry({ + ...(selectedAgentId ? { agentId: selectedAgentId } : {}), storePath: resolveSessionStorePathCore(cfg.session?.store, { - agentId: resolveAgentIdFromSessionKey(requestSessionKey, resolveDefaultAgentId(cfg)), + agentId: selectedAgentId, }), sessionKey: requestSessionKey, })?.swarmCollector === true @@ -116,8 +133,8 @@ export function prepareAgentRequestPreflight(params: { cfg, registeredCollector?.requesterAgentId ?? (swarmRequesterSessionKey - ? resolveAgentIdFromSessionKey(swarmRequesterSessionKey, resolveDefaultAgentId(cfg)) - : undefined), + ? (parseAgentSessionKey(swarmRequesterSessionKey)?.agentId ?? selectedAgentId) + : selectedAgentId), ).enabled; const pendingCollectorLaunch = registeredCollector?.swarmLaunchPending === true && diff --git a/src/gateway/agent-turn/agent-request-routing.ts b/src/gateway/agent-turn/agent-request-routing.ts index ab4aba2d5e3f..2185c3c93728 100644 --- a/src/gateway/agent-turn/agent-request-routing.ts +++ b/src/gateway/agent-turn/agent-request-routing.ts @@ -1,19 +1,13 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentIds } from "../../agents/agent-scope.js"; import { isExecApprovalFollowupSessionRebound } from "../../agents/bash-tools.exec-approval-followup-state.js"; -import { - resolveAgentIdFromSessionKey, - resolveExplicitAgentSessionKey, -} from "../../config/sessions.js"; +import { resolveExistingSessionKeyForRequest } from "../../agents/command/session.js"; +import { resolveExplicitAgentSessionKey } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { emitDiagnosticEvent } from "../../infra/diagnostic-events.js"; import { resolveAgentExplicitRecipientSession } from "../../infra/outbound/agent-delivery.js"; -import { - classifySessionKeyShape, - normalizeAgentId, - parseAgentSessionKey, -} from "../../routing/session-key.js"; +import { classifySessionKeyShape, normalizeAgentId } from "../../routing/session-key.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, @@ -25,6 +19,7 @@ import { import type { AgentRunRequest } from "../server-methods/agent-request-types.js"; import { normalizeRpcAttachmentsToChatAttachments } from "../server-methods/attachment-normalize.js"; import type { GatewayRequestHandlerOptions } from "../server-methods/types.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { loadSessionEntry, resolveSessionStoreKey } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import { setGatewayDedupeEntries } from "./agent-dedupe.js"; @@ -106,25 +101,40 @@ export async function prepareAgentRequestRouting(params: { ); return undefined; } - if (!agentId && requestedSessionKeyRaw) { - const parsed = parseAgentSessionKey(requestedSessionKeyRaw); - const inferredAgentId = - parsed && - resolveSessionStoreKey({ cfg: params.cfg, sessionKey: requestedSessionKeyRaw }) === "global" - ? normalizeAgentId(parsed.agentId) - : undefined; - if (inferredAgentId && !knownAgents.includes(inferredAgentId)) { - params.respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid agent params: unknown agent id "${parsed?.agentId}"`, - ), - ); + if (requestedSessionKeyRaw) { + const requestedSessionAgent = resolveRequestedSessionAgentId( + params.cfg, + requestedSessionKeyRaw, + agentId, + ); + if (!requestedSessionAgent.ok) { + params.respond(false, undefined, requestedSessionAgent.error); return undefined; } - agentId = inferredAgentId; + agentId = requestedSessionAgent.agentId; + } + let sessionIdTarget: ReturnType | undefined; + if (requestedSessionId && !requestedSessionKeyRaw) { + try { + sessionIdTarget = resolveExistingSessionKeyForRequest({ + cfg: params.cfg, + sessionId: requestedSessionId, + agentId, + clone: false, + }); + agentId = sessionIdTarget.agentId ?? agentId; + } catch (error) { + params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(error))); + return undefined; + } + } + if (!requestedSessionKeyRaw && !requestedSessionId && !agentId) { + const implicitMainOwner = resolveRequestedSessionAgentId(params.cfg, "main"); + if (!implicitMainOwner.ok) { + params.respond(false, undefined, implicitMainOwner.error); + return undefined; + } + agentId = implicitMainOwner.agentId; } const explicitRecipientChannel = normalizeMessageChannel(params.request.channel); const explicitRecipient = @@ -166,9 +176,11 @@ export async function prepareAgentRequestRouting(params: { } const requestedSessionKey = requestedSessionKeyRaw ?? + sessionIdTarget?.sessionKey ?? explicitRecipientSession?.sessionKey ?? + // Ownership selection alone must not turn a sessionless run into a main-session write. (!requestedSessionId - ? resolveAgentExplicitRecipientSessionKey(params.cfg, agentId) + ? resolveAgentExplicitRecipientSessionKey(params.cfg, agentIdRaw ? agentId : undefined) : undefined); const expectedSessionTargetError = validateExpectedExistingSessionTarget({ constraint: params.expectedSession, @@ -183,29 +195,6 @@ export async function prepareAgentRequestRouting(params: { ); return undefined; } - if (agentId && requestedSessionKeyRaw) { - const parsed = parseAgentSessionKey(requestedSessionKeyRaw); - const canonicalKey = resolveSessionStoreKey({ - cfg: params.cfg, - sessionKey: requestedSessionKeyRaw, - }); - const sessionAgentId = parsed?.agentId - ? normalizeAgentId(parsed.agentId) - : canonicalKey === "global" - ? agentId - : resolveAgentIdFromSessionKey(requestedSessionKeyRaw, resolveDefaultAgentId(params.cfg)); - if (sessionAgentId !== agentId) { - params.respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid agent params: agent "${params.request.agentId}" does not match session key agent "${sessionAgentId}"`, - ), - ); - return undefined; - } - } if ( requestedSessionKey && respondUnavailableAgentSessionForKey({ @@ -223,13 +212,18 @@ export async function prepareAgentRequestRouting(params: { dropReboundExecApprovalFollowup({ ...params, requestedSessionKeyRaw, + agentId, }) ) { return undefined; } const preAcceptedReservedSessionKey = requestedSessionKey && - resolveSessionStoreKey({ cfg: params.cfg, sessionKey: requestedSessionKey }) === "global" + resolveSessionStoreKey({ + cfg: params.cfg, + sessionKey: requestedSessionKey, + storeAgentId: agentId, + }) === "global" ? "global" : requestedSessionKey; // Keyless runs still need the run-id reservation before asynchronous preparation, @@ -266,6 +260,7 @@ function resolveAgentExplicitRecipientSessionKey(cfg: OpenClawConfig, agentId?: function dropReboundExecApprovalFollowup(params: { request: AgentRunRequest; requestedSessionKeyRaw?: string; + agentId?: string; execApprovalFollowupApprovalId?: string; runId: string; agentDedupeKeys: string[]; @@ -281,7 +276,10 @@ function dropReboundExecApprovalFollowup(params: { let currentSessionId: string | undefined; try { currentSessionId = normalizeOptionalString( - loadSessionEntry(params.requestedSessionKeyRaw).entry?.sessionId, + loadSessionEntry(params.requestedSessionKeyRaw, { + ...(params.agentId ? { agentId: params.agentId } : {}), + clone: false, + }).entry?.sessionId, ); } catch { currentSessionId = undefined; diff --git a/src/gateway/agent-turn/agent-run-admission-phase.ts b/src/gateway/agent-turn/agent-run-admission-phase.ts index dd19c7a3620e..a8b4a28bf39c 100644 --- a/src/gateway/agent-turn/agent-run-admission-phase.ts +++ b/src/gateway/agent-turn/agent-run-admission-phase.ts @@ -122,6 +122,7 @@ export async function prepareAgentRunDispatch(params: { runId: params.runId, sessionKey: params.resolvedSessionKey, alternateSessionKeys: [params.preAcceptedReservedSessionKey, params.requestedSessionKey], + agentId: params.activeSessionAgentId, }) ) { params.markAgentRunAccepted(true); @@ -134,7 +135,7 @@ export async function prepareAgentRunDispatch(params: { if ( params.abortForLifecycleRotation({ sessionKey: params.resolvedSessionKey, - agentId: params.resolvedSessionKey === "global" ? params.activeSessionAgentId : undefined, + agentId: params.activeSessionAgentId, }) ) { return undefined; @@ -435,7 +436,7 @@ export async function prepareAgentRunDispatch(params: { const accepted = { runId: params.runId, sessionKey: params.resolvedSessionKey, - ...(params.resolvedSessionKey === "global" ? { agentId: params.activeSessionAgentId } : {}), + agentId: params.activeSessionAgentId, status: "accepted" as const, acceptedAt: Date.now(), ...(taskTrackingMode === "plugin_subagent" ? { runtime: resolvedRuntime } : {}), diff --git a/src/gateway/agent-turn/agent-run-execution-phase.ts b/src/gateway/agent-turn/agent-run-execution-phase.ts index 82c129a17cc7..135df48c5df0 100644 --- a/src/gateway/agent-turn/agent-run-execution-phase.ts +++ b/src/gateway/agent-turn/agent-run-execution-phase.ts @@ -29,7 +29,6 @@ import { setChannelSourceTurnSameThreadRequired, } from "../../auto-reply/reply/source-turn-id.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { resolveAgentIdFromSessionKey } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { MediaFact } from "../../media/media-facts.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; @@ -141,7 +140,7 @@ export function startAgentRunExecution(params: { setAbortedAgentDedupeEntries({ dedupe: params.context.dedupe, keys: params.agentDedupeKeys, - agentId: params.resolvedSessionKey === "global" ? params.activeSessionAgentId : undefined, + agentId: params.activeSessionAgentId, runId: params.runId, stopReason, }); @@ -225,18 +224,14 @@ export function startAgentRunExecution(params: { ) { emitSessionsChanged(params.context, { sessionKey: params.resolvedSessionKey, - ...(params.resolvedSessionKey === "global" - ? { agentId: params.activeSessionAgentId } - : {}), + agentId: params.activeSessionAgentId, reason: "create", }); } if (!params.suppressVisibleSessionEffects && params.resolvedSessionKey) { emitSessionsChanged(params.context, { sessionKey: params.resolvedSessionKey, - ...(params.resolvedSessionKey === "global" - ? { agentId: params.activeSessionAgentId } - : {}), + agentId: params.activeSessionAgentId, reason: "send", }); } @@ -318,14 +313,9 @@ export function startAgentRunExecution(params: { }) : undefined; - const ingressAgentId = - params.resolvedSessionKey === "global" - ? params.activeSessionAgentId - : params.agentId && - (!params.resolvedSessionKey || - resolveAgentIdFromSessionKey(params.resolvedSessionKey) === params.agentId) - ? params.agentId - : undefined; + const ingressAgentId = params.resolvedSessionKey + ? params.activeSessionAgentId + : params.agentId; // Plugin-owned additive grants stay internal to the authenticated in-process run. // Public agent params cannot supply them, and normal tool policy still filters them. const runtimePluginToolGrant = diff --git a/src/gateway/agent-turn/agent-session-persist.ts b/src/gateway/agent-turn/agent-session-persist.ts index a81ce41c72c7..24fb34f42408 100644 --- a/src/gateway/agent-turn/agent-session-persist.ts +++ b/src/gateway/agent-turn/agent-session-persist.ts @@ -43,6 +43,7 @@ import { export type CronContinuationClaim = { storePath: string; sessionKey: string; + sessionAgentId: string; lifecycleRevision: string; initialEntry: SessionEntry; mediaTaskIdsBefore: ReadonlySet; @@ -254,6 +255,7 @@ export async function persistAgentSessionPhase(params: { params.setCronContinuationClaim({ storePath: params.storePath, sessionKey: params.canonicalSessionKey, + sessionAgentId: params.sessionAgentId, lifecycleRevision: marker.lifecycleRevision, initialEntry: structuredClone(entryForPatch!), mediaTaskIdsBefore: getGeneratedMediaTaskIdsForSessionKey( @@ -520,7 +522,7 @@ export async function persistAgentSessionPhase(params: { pendingChatRun: isMainSession ? { sessionKey: params.canonicalSessionKey, - ...(params.canonicalSessionKey === "global" ? { agentId: params.sessionAgentId } : {}), + agentId: params.sessionAgentId, } : undefined, bestEffortDeliver: diff --git a/src/gateway/agent-turn/agent-turn-service.ts b/src/gateway/agent-turn/agent-turn-service.ts index 23542d6a77dd..cdd3a3bf934c 100644 --- a/src/gateway/agent-turn/agent-turn-service.ts +++ b/src/gateway/agent-turn/agent-turn-service.ts @@ -70,9 +70,7 @@ function replayAgentTurnIfCached(params: { ? cached.payload.sessionKey.trim() : undefined; const cachedAgentId = - cachedSessionKey === "global" && - typeof cached.payload.agentId === "string" && - cached.payload.agentId.trim() + typeof cached.payload.agentId === "string" && cached.payload.agentId.trim() ? cached.payload.agentId.trim() : undefined; params.io.emitAcceptance( @@ -312,6 +310,7 @@ export function createAgentTurnService({ if (requestedSessionKey) { const preparedSession = prepareAgentSession({ + cfg, requestedSessionKey, requestedSessionId, expectedExistingSessionId, diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts index d2f3a01f709f..ac0de36b14fc 100644 --- a/src/gateway/assistant-identity.test.ts +++ b/src/gateway/assistant-identity.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { AVATAR_MAX_DATA_URL_CHARS } from "../shared/avatar-limits.js"; import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js"; import { withTestDir } from "../test-helpers/temp-dir.js"; @@ -68,6 +69,45 @@ describe("resolveAssistantIdentity", () => { expect(identity.avatar).toBe("M"); }); + it("uses the first roster entry for presentation on an explicit fleet", () => { + const identity = resolveAssistantIdentity({ + cfg: { agents: { ownership: "explicit", entries: { ops: {}, research: {} } } }, + workspaceDir: "", + }); + + expect(identity).toEqual({ ...DEFAULT_ASSISTANT_IDENTITY, agentId: "ops" }); + }); + + it("applies ui.assistant identity only as authoritative for the retained owner", () => { + const baseCfg: OpenClawConfig = { + ui: { assistant: { name: "Shared assistant", avatar: "S" } }, + agents: { + ownership: "explicit", + list: [ + { id: "ops", identity: { name: "Ops agent", avatar: "O" } }, + { id: "research", identity: { name: "Research agent", avatar: "R" } }, + ], + }, + }; + const ownerlessCfg = { ...baseCfg }; + const migratedCfg = retainLegacyDefaultAgentId(baseCfg, "ops"); + + expect( + resolveAssistantIdentity({ cfg: migratedCfg, agentId: "ops", workspaceDir: "" }), + ).toEqual({ + agentId: "ops", + name: "Shared assistant", + avatar: "S", + emoji: undefined, + }); + expect( + resolveAssistantIdentity({ cfg: migratedCfg, agentId: "research", workspaceDir: "" }), + ).toMatchObject({ name: "Research agent", avatar: "R" }); + expect( + resolveAssistantIdentity({ cfg: ownerlessCfg, agentId: "ops", workspaceDir: "" }), + ).toMatchObject({ name: "Ops agent", avatar: "O" }); + }); + it("drops sentence-like avatar placeholders", () => { const cfg: OpenClawConfig = { ui: { diff --git a/src/gateway/assistant-identity.ts b/src/gateway/assistant-identity.ts index d97bd6224107..479133963dc5 100644 --- a/src/gateway/assistant-identity.ts +++ b/src/gateway/assistant-identity.ts @@ -2,9 +2,11 @@ // Combines UI, agent config, and workspace identity files for Control UI display. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolveAgentIdentity } from "../agents/identity.js"; import { loadAgentIdentity } from "../commands/agents.config.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { @@ -101,9 +103,12 @@ export function resolveAssistantIdentity(params: { agentId?: string | null; workspaceDir?: string | null; }): ResolvedAssistantIdentity { - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg)); - const agentId = normalizeAgentId(params.agentId ?? defaultAgentId); - const isDefaultAgent = agentId === defaultAgentId; + const compatibilityAgentId = tryResolveLegacyCompatibilityAgentId(params.cfg); + const presentationAgentId = + params.agentId ?? compatibilityAgentId ?? listAgentEntries(params.cfg)[0]?.id ?? "main"; + const agentId = normalizeAgentId(presentationAgentId); + const isDefaultAgent = + compatibilityAgentId !== undefined && agentId === normalizeAgentId(compatibilityAgentId); const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId); const configAssistant = params.cfg.ui?.assistant; const agentIdentity = resolveAgentIdentity(params.cfg, agentId); diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 24516227255c..1258344e624a 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -30,6 +30,7 @@ import { type ChatRunPlanSnapshot, type ChatRunState, } from "./server-chat-state.js"; +import { resolveRequestedSessionAgentId } from "./session-request-agent.js"; import { resolveSessionSubscriptionKey, resolveSessionSubscriptionKeys, @@ -553,12 +554,13 @@ function broadcastChatAborted( ) { const { runId, sessionKey, stopReason, partialText } = params; const errorMessage = readToolValidationErrorSummary(params.errorMessage); + const explicitAgentId = normalizeActiveAgentId(params.agentId); const defaultGlobalAgentId = - sessionKey === "global" ? normalizeActiveAgentId(resolveDefaultGlobalAgentId(ops)) : undefined; + sessionKey === "global" && !explicitAgentId + ? normalizeActiveAgentId(resolveDefaultGlobalAgentId(ops)) + : undefined; const payloadAgentId = - sessionKey === "global" - ? (normalizeActiveAgentId(params.agentId) ?? defaultGlobalAgentId) - : normalizeActiveAgentId(params.agentId); + sessionKey === "global" ? (explicitAgentId ?? defaultGlobalAgentId) : explicitAgentId; const payload = { runId, sessionKey, @@ -584,7 +586,11 @@ function broadcastChatAborted( function resolveDefaultGlobalAgentId(ops: ChatAbortOps): string | undefined { const cfg = ops.getRuntimeConfig?.(); - return cfg ? resolveDefaultAgentId(cfg) : undefined; + if (!cfg) { + return undefined; + } + const resolved = resolveRequestedSessionAgentId(cfg, "global"); + return resolved.ok ? resolved.agentId : undefined; } export function isChatAbortControllerEntryAbortable(entry: ChatAbortControllerEntry): boolean { diff --git a/src/gateway/chat-queued-turns.ts b/src/gateway/chat-queued-turns.ts index 5fdfa41ad05c..a8ca3967bc05 100644 --- a/src/gateway/chat-queued-turns.ts +++ b/src/gateway/chat-queued-turns.ts @@ -7,6 +7,7 @@ * remain abortable by authorized requesters after chat.send terminalizes. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { chatRunBelongsToAgent } from "./chat-run-owner.js"; export type QueuedChatTurnEntry = { controller: AbortController; @@ -204,11 +205,18 @@ export function listQueuedChatTurnsForSession(params: { if (!sessionKeys.has(entry.sessionKey) && !sessionIds.has(entry.sessionId)) { continue; } - if (agentId && entry.sessionKey === "global") { - const entryAgent = (entry.agentId ?? defaultAgentId)?.toLowerCase(); - if (entryAgent !== agentId) { - continue; - } + if ( + agentId && + !chatRunBelongsToAgent( + { + agentId: entry.agentId, + sessionKey: entry.sessionKey, + defaultAgentId, + }, + agentId, + ) + ) { + continue; } matches.push({ runId, entry }); } diff --git a/src/gateway/chat-run-owner.test.ts b/src/gateway/chat-run-owner.test.ts new file mode 100644 index 000000000000..44802fbb3a89 --- /dev/null +++ b/src/gateway/chat-run-owner.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + chatRunBelongsToAgent, + chatRunBelongsToSelectedAgent, + resolveChatRunOwnerAgentId, +} from "./chat-run-owner.js"; + +describe("chat run owner resolution", () => { + it("uses the compatibility owner for a global run without agentId", () => { + const run = { sessionKey: "global", defaultAgentId: "ops" }; + + expect(resolveChatRunOwnerAgentId(run)).toBe("ops"); + expect(chatRunBelongsToAgent(run, "research")).toBe(false); + expect(chatRunBelongsToAgent(run, "ops")).toBe(true); + expect(chatRunBelongsToSelectedAgent({ ...run, selectedAgentId: "research" })).toBe(false); + }); + + it("keeps an explicit active-run owner ahead of the compatibility owner", () => { + expect( + resolveChatRunOwnerAgentId({ + agentId: "research", + sessionKey: "global", + defaultAgentId: "ops", + }), + ).toBe("research"); + }); +}); diff --git a/src/gateway/chat-run-owner.ts b/src/gateway/chat-run-owner.ts new file mode 100644 index 000000000000..0a9d977ea762 --- /dev/null +++ b/src/gateway/chat-run-owner.ts @@ -0,0 +1,23 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; + +type ChatRunOwner = { agentId?: string; sessionKey?: string; defaultAgentId?: string }; + +export function resolveChatRunOwnerAgentId(params: ChatRunOwner): string | undefined { + const ownerAgentId = + normalizeOptionalString(params.agentId) ?? + parseAgentSessionKey(params.sessionKey)?.agentId ?? + normalizeOptionalString(params.defaultAgentId); + return ownerAgentId ? normalizeAgentId(ownerAgentId) : undefined; +} + +export function chatRunBelongsToAgent(params: ChatRunOwner, agentId: string): boolean { + return resolveChatRunOwnerAgentId(params) === normalizeAgentId(agentId); +} + +export function chatRunBelongsToSelectedAgent( + params: ChatRunOwner & { selectedAgentId?: string }, +): boolean { + const selectedAgentId = normalizeOptionalString(params.selectedAgentId); + return selectedAgentId ? chatRunBelongsToAgent(params, selectedAgentId) : false; +} diff --git a/src/gateway/embeddings-http.test.ts b/src/gateway/embeddings-http.test.ts index afab9a3d749b..d8bc2f31f369 100644 --- a/src/gateway/embeddings-http.test.ts +++ b/src/gateway/embeddings-http.test.ts @@ -409,9 +409,17 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { it("rejects explicit unknown agent ids", async () => { try { - testState.agentsConfig = { entries: { main: {}, beta: {} } }; + testState.agentsConfig = { ownership: "explicit", entries: { main: {}, beta: {} } }; resetConfigRuntimeState(); + const missing = await postEmbeddings({ model: "openclaw", input: "hello" }); + expect(missing.status).toBe(400); + const missingJson = (await missing.json()) as { + error?: { type?: string; message?: string }; + }; + expect(missingJson.error?.type).toBe("invalid_request_error"); + expect(missingJson.error?.message).toContain("has no explicit owner"); + const header = await postEmbeddings( { model: "openclaw/default", input: "hello" }, { "x-openclaw-agent-id": "missing-agent" }, diff --git a/src/gateway/embeddings-http.ts b/src/gateway/embeddings-http.ts index e126131afc83..0c22e71989e6 100644 --- a/src/gateway/embeddings-http.ts +++ b/src/gateway/embeddings-http.ts @@ -21,12 +21,12 @@ import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, sendMissingScopeForbidden, watchClientDisconnect } from "./http-common.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; import { - OPENCLAW_MODEL_ID, authorizeOpenAiCompatibleHttpModelOverride, getHeader, + isAgentSelectionRequiredError, + isOpenClawAgentModelId, isUnknownGatewayAgentError, resolveAgentIdForRequest, - resolveAgentIdFromModel, resolveOpenAiCompatibleHttpOperatorScopes, } from "./http-utils.js"; @@ -343,7 +343,7 @@ export async function handleOpenAiEmbeddingsHttpRequest( } const cfg = getRuntimeConfig(); - if (requestModel !== OPENCLAW_MODEL_ID && !resolveAgentIdFromModel(requestModel, cfg)) { + if (!isOpenClawAgentModelId(requestModel)) { sendJson(res, 400, { error: { message: "Invalid `model`. Use `openclaw` or `openclaw/`.", @@ -375,7 +375,7 @@ export async function handleOpenAiEmbeddingsHttpRequest( try { agentId = resolveAgentIdForRequest({ req, model: requestModel }); } catch (err) { - if (isUnknownGatewayAgentError(err)) { + if (isAgentSelectionRequiredError(err) || isUnknownGatewayAgentError(err)) { sendJson(res, 400, { error: { message: err.message, type: "invalid_request_error" }, }); diff --git a/src/gateway/health/collector.legacy-owner.test.ts b/src/gateway/health/collector.legacy-owner.test.ts new file mode 100644 index 000000000000..bc9580f05945 --- /dev/null +++ b/src/gateway/health/collector.legacy-owner.test.ts @@ -0,0 +1,132 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; +import { retainLegacyDefaultAgentId } from "../../config/legacy.default-agent-owner.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +let testConfig: OpenClawConfig = {}; +let healthPluginsForTest: ChannelPlugin[] = []; + +let collectGatewayHealthSnapshot: typeof import("./collector.js").collectGatewayHealthSnapshot; +let createChannelTestPluginBase: typeof import("../../test-utils/channel-plugins.js").createChannelTestPluginBase; + +function createHealthPlugin(): ChannelPlugin { + const resolveAccount = (_cfg: OpenClawConfig, accountId?: string | null) => ({ + accountId: accountId?.trim() || "default", + enabled: true, + configured: true, + }); + return { + ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), + config: { + listAccountIds: (cfg) => { + const telegram = cfg.channels?.telegram as + | { accounts?: Record } + | undefined; + const accountIds = Object.keys(telegram?.accounts ?? {}); + return accountIds.length > 0 ? accountIds : ["default"]; + }, + resolveAccount, + inspectAccount: resolveAccount, + isEnabled: (account) => Boolean((account as { enabled?: boolean }).enabled), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + }, + status: { + buildChannelSummary: ({ snapshot }) => ({ + accountId: snapshot.accountId, + configured: snapshot.configured, + }), + }, + }; +} + +describe("collectGatewayHealthSnapshot legacy owner projection", () => { + beforeAll(async () => { + vi.doMock("../../config/config.js", () => ({ + getRuntimeConfig: () => testConfig, + })); + vi.doMock("../../config/sessions/paths.js", () => ({ + resolveSessionStorePathCore: () => "/tmp/sessions.json", + })); + vi.doMock("../../config/sessions/session-accessor.js", () => ({ + listSessionEntriesReadOnly: () => [], + })); + vi.doMock("../../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: () => healthPluginsForTest, + })); + + const [health, channelTestUtils] = await Promise.all([ + import("./collector.js"), + import("../../test-utils/channel-plugins.js"), + ]); + collectGatewayHealthSnapshot = health.collectGatewayHealthSnapshot; + createChannelTestPluginBase = channelTestUtils.createChannelTestPluginBase; + }); + + beforeEach(() => { + healthPluginsForTest = [createHealthPlugin()]; + }); + + it("projects the retained owner without inventing an explicit fleet default", async () => { + const migratedConfig = { + agents: { + entries: { first: {}, ops: {}, research: {} }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "ops" } }], + channels: { + telegram: { + accounts: { + default: { botToken: "default-token" }, + ops: { botToken: "ops-token" }, + }, + }, + }, + } satisfies OpenClawConfig; + testConfig = retainLegacyDefaultAgentId(migratedConfig, "ops"); + + const migrated = await collectGatewayHealthSnapshot({ audience: "admin", probe: false }); + + expect(migrated.defaultAgentId).toBe("ops"); + const migratedOwner = migrated.agents.find((agent) => agent.isDefault); + expect(migratedOwner?.agentId).toBe("ops"); + expect(migratedOwner?.heartbeat.enabled).toBe(true); + expect(migrated.agents.find((agent) => agent.agentId === "first")?.heartbeat.enabled).toBe( + false, + ); + expect(migrated.heartbeatSeconds).toBe((migratedOwner?.heartbeat.everyMs ?? 0) / 1000); + expect(migrated.channels.telegram?.accountId).toBe("ops"); + + testConfig = { + agents: { + ownership: "explicit", + entries: { first: {}, ops: {}, research: {} }, + }, + }; + + const explicit = await collectGatewayHealthSnapshot({ audience: "admin", probe: false }); + + expect(explicit.defaultAgentId).toBeUndefined(); + expect(explicit.agents.every((agent) => !agent.isDefault)).toBe(true); + expect(explicit.agents.every((agent) => !agent.heartbeat.enabled)).toBe(true); + }); + + it("projects the configured heartbeat owner's cadence", async () => { + testConfig = { + agents: { + ownership: "explicit", + defaults: { heartbeat: { agentId: "research", every: "30m" } }, + entries: { + ops: {}, + research: { heartbeat: { every: "5m" } }, + }, + }, + }; + + const health = await collectGatewayHealthSnapshot({ audience: "admin", probe: false }); + + expect(health.agents.map((agent) => agent.agentId)).toEqual(["ops", "research"]); + expect(health.agents.find((agent) => agent.agentId === "research")?.heartbeat.enabled).toBe( + true, + ); + expect(health.heartbeatSeconds).toBe(5 * 60); + }); +}); diff --git a/src/gateway/health/collector.ts b/src/gateway/health/collector.ts index f9aeb50d94d9..ac21ea1b9309 100644 --- a/src/gateway/health/collector.ts +++ b/src/gateway/health/collector.ts @@ -1,11 +1,13 @@ import { expectDefined } from "@openclaw/normalization-core"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; -import { listAgentEntries, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries } from "../../agents/agent-scope.js"; import { redactChannelStatusSummaryBaseUrl } from "../../channels/account-snapshot-fields.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js"; import { buildChannelAccountSnapshotFromAccount } from "../../channels/plugins/status.js"; import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; import { resolveSessionStorePathCore } from "../../config/sessions/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { listContextEngineQuarantines } from "../../context-engine/registry.js"; @@ -76,7 +78,7 @@ const resolveHeartbeatSummary = (cfg: OpenClawConfig, agentId: string) => resolveHeartbeatSummaryForAgent(cfg, agentId); export function resolveHealthAgentOrder(cfg: OpenClawConfig) { - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = tryResolveLegacyCompatibilityAgentId(cfg); const entries = listAgentEntries(cfg); const seen = new Set(); const ordered: Array<{ id: string; name?: string }> = []; @@ -96,10 +98,10 @@ export function resolveHealthAgentOrder(cfg: OpenClawConfig) { ordered.push({ id, name: typeof entry.name === "string" ? entry.name : undefined }); } - if (!seen.has(defaultAgentId)) { + if (defaultAgentId && !seen.has(defaultAgentId)) { ordered.unshift({ id: defaultAgentId }); } - if (ordered.length === 0) { + if (ordered.length === 0 && defaultAgentId) { ordered.push({ id: defaultAgentId }); } @@ -218,15 +220,24 @@ export async function collectGatewayHealthSnapshot(params: { sessions, }); } - const defaultAgent = agents.find((agent) => agent.isDefault) ?? agents[0]; - const heartbeatSeconds = defaultAgent?.heartbeat.everyMs - ? Math.round(defaultAgent.heartbeat.everyMs / 1000) + const summaryAgent = agents.find((agent) => agent.isDefault) ?? agents[0]; + const configuredHeartbeatAgentId = normalizeOptionalString( + cfg.agents?.defaults?.heartbeat?.agentId, + ); + const heartbeatSummaryAgent = + (configuredHeartbeatAgentId + ? agents.find((agent) => agent.agentId === normalizeAgentId(configuredHeartbeatAgentId)) + : undefined) ?? + agents.find((agent) => agent.heartbeat.enabled) ?? + summaryAgent; + const heartbeatSeconds = heartbeatSummaryAgent?.heartbeat.everyMs + ? Math.round(heartbeatSummaryAgent.heartbeat.everyMs / 1000) : 0; const sessions = - defaultAgent?.sessions ?? + summaryAgent?.sessions ?? (await buildHealthSessionSummary( - resolveSessionStorePathCore(cfg.session?.store, { agentId: defaultAgentId }), - defaultAgentId, + resolveSessionStorePathCore(cfg.session?.store, { agentId: summaryAgent?.agentId }), + summaryAgent?.agentId, )); const start = Date.now(); @@ -247,7 +258,9 @@ export async function collectGatewayHealthSnapshot(params: { cfg, accountIds, }); - const boundAccounts = channelBindings.get(plugin.id)?.get(defaultAgentId) ?? []; + const boundAccounts = defaultAgentId + ? (channelBindings.get(plugin.id)?.get(defaultAgentId) ?? []) + : []; const preferredAccountId = resolvePreferredAccountId({ accountIds, defaultAccountId, @@ -411,7 +424,7 @@ export async function collectGatewayHealthSnapshot(params: { channelOrder, channelLabels, heartbeatSeconds, - defaultAgentId, + ...(defaultAgentId ? { defaultAgentId } : {}), agents, sessions: { path: sessions.path, diff --git a/src/gateway/health/types.ts b/src/gateway/health/types.ts index fc3b23e1b55b..50d1178f861e 100644 --- a/src/gateway/health/types.ts +++ b/src/gateway/health/types.ts @@ -98,7 +98,7 @@ export type HealthSummary = { channelOrder: string[]; channelLabels: Record; heartbeatSeconds: number; - defaultAgentId: string; + defaultAgentId?: string; agents: AgentHealthSummary[]; sessions: { path: string; diff --git a/src/gateway/hooks-test-helpers.ts b/src/gateway/hooks-test-helpers.ts index cc58a96989fb..c4424300ef5b 100644 --- a/src/gateway/hooks-test-helpers.ts +++ b/src/gateway/hooks-test-helpers.ts @@ -12,6 +12,7 @@ export function createHooksConfig(): HooksConfigResolved { mappings: [], agentPolicy: { defaultAgentId: "main", + globalSessionStoreOwner: { kind: "none" }, knownAgentIds: new Set(["main"]), allowedAgentIds: undefined, }, diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index ef02d32e472b..43c0675c5ae0 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -358,6 +358,21 @@ describe("gateway hooks helpers", () => { expect(resolveEffectiveHookTargetAgentId(resolved, " ")).toBe("main"); }); + test("global hook dispatch honors the persisted fixed-store owner", () => { + const resolved = resolveHooksConfigOrThrow({ + hooks: { enabled: true, token: "secret" }, + session: { scope: "global", store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }); + + expect(resolveEffectiveHookTargetAgentId(resolved, undefined)).toBe("ops"); + expect(resolveEffectiveHookTargetAgentId(resolved, "research")).toBeUndefined(); + }); + test("isHookAgentAllowed honors hooks.allowedAgentIds for effective target routing", () => { const resolved = resolveHooksConfigOrThrow(buildHookAgentConfig(["hooks"])); expect(isHookAgentAllowed(resolved, undefined)).toBe(false); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index ecae869b0e40..54cd4f2464c0 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -6,8 +6,13 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { listAgentIds } from "../agents/agent-scope-config.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; +import { + type PersistedSessionStoreOwner, + resolvePersistedSessionStoreOwnerForKey, +} from "../config/sessions/session-store-owner.js"; import type { HookSessionMode } from "../config/types.hooks.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js"; @@ -38,7 +43,8 @@ export type HooksConfigResolved = { }; type HookAgentPolicyResolved = { - defaultAgentId: string; + defaultAgentId?: string; + globalSessionStoreOwner: PersistedSessionStoreOwner; knownAgentIds: Set; allowedAgentIds?: Set; }; @@ -67,7 +73,13 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n throw new Error("hooks.path may not be '/'"); } const mappings = resolveHookMappings(cfg.hooks); - const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultAgentId = tryResolveLegacyCompatibilityAgentId(cfg); + // Global hook runs write a literal shared row, whose durable owner must win + // over ambient hook defaults after migration sidecar state is gone. + const globalSessionStoreOwner = + cfg.session?.scope === "global" + ? resolvePersistedSessionStoreOwnerForKey(cfg, "global") + : { kind: "none" as const }; const knownAgentIds = resolveKnownAgentIds(cfg, defaultAgentId); const allowedAgentIds = resolveAllowedAgentIds(cfg.hooks?.allowedAgentIds); const defaultSessionKey = resolveSessionKey(cfg.hooks?.defaultSessionKey); @@ -102,6 +114,7 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n mappings, agentPolicy: { defaultAgentId, + globalSessionStoreOwner, knownAgentIds, allowedAgentIds, }, @@ -117,9 +130,11 @@ export function commitHooksConfigReload(): void { commitHookTransformMappingReload(); } -function resolveKnownAgentIds(cfg: OpenClawConfig, defaultAgentId: string): Set { +function resolveKnownAgentIds(cfg: OpenClawConfig, defaultAgentId?: string): Set { const known = new Set(listAgentIds(cfg)); - known.add(defaultAgentId); + if (defaultAgentId) { + known.add(defaultAgentId); + } return known; } @@ -210,13 +225,14 @@ export function normalizeHookHeaders(req: IncomingMessage) { /** Validate a hook wake payload. */ export function normalizeWakePayload( payload: Record, -): Result<{ text: string; mode: "now" | "next-heartbeat" }, string> { +): Result<{ text: string; mode: "now" | "next-heartbeat"; agentId?: string }, string> { const normalizedText = normalizeOptionalString(payload.text) ?? ""; if (!normalizedText) { return { ok: false, error: "text required" }; } const mode = payload.mode === "next-heartbeat" ? "next-heartbeat" : "now"; - return { ok: true, value: { text: normalizedText, mode } }; + const agentId = normalizeOptionalString(payload.agentId); + return { ok: true, value: { text: normalizedText, mode, ...(agentId ? { agentId } : {}) } }; } type HookAgentPayload = { @@ -246,6 +262,7 @@ type HookAgentPayload = { /** Normalized agent dispatch payload after hook policy/session resolution. */ export type HookAgentDispatchPayload = Omit & { + effectiveAgentId: string; sessionKey: string; sourcePath: string; allowUnsafeExternalContent?: boolean; @@ -414,8 +431,21 @@ export function resolveHookTargetAgentId( export function resolveEffectiveHookTargetAgentId( hooksConfig: HooksConfigResolved, agentId: string | undefined, -): string { - return resolveHookTargetAgentId(hooksConfig, agentId) ?? hooksConfig.agentPolicy.defaultAgentId; +): string | undefined { + const resolvedAgentId = + resolveHookTargetAgentId(hooksConfig, agentId) ?? hooksConfig.agentPolicy.defaultAgentId; + const persistedOwner = hooksConfig.agentPolicy.globalSessionStoreOwner; + if (persistedOwner.kind === "retired") { + return undefined; + } + if ( + persistedOwner.kind === "configured" && + resolvedAgentId && + resolvedAgentId !== persistedOwner.agentId + ) { + return undefined; + } + return persistedOwner.kind === "configured" ? persistedOwner.agentId : resolvedAgentId; } /** Check the hook agent allowlist against the effective target agent. */ @@ -429,11 +459,15 @@ export function isHookAgentAllowed( } // Omitted agentId still dispatches to the default agent downstream, so the // allowlist must authorize that effective target before dispatch. - return allowed.has(resolveEffectiveHookTargetAgentId(hooksConfig, agentId)); + const effectiveAgentId = resolveEffectiveHookTargetAgentId(hooksConfig, agentId); + return effectiveAgentId !== undefined && allowed.has(effectiveAgentId); } /** Error message for hook agent allowlist failures. */ export const getHookAgentPolicyError = () => "agentId is not allowed by hooks.allowedAgentIds"; + +export const getHookAgentSelectionError = () => + "agentId is required when multiple agents are configured"; const getHookSessionKeyRequestPolicyError = () => "sessionKey is disabled for externally supplied hook payload values; set hooks.allowRequestSessionKey=true to enable"; /** Error message for hook session-key prefix allowlist failures. */ diff --git a/src/gateway/http-utils.request-context.test.ts b/src/gateway/http-utils.request-context.test.ts index 31ba0088d053..685a07ee7e49 100644 --- a/src/gateway/http-utils.request-context.test.ts +++ b/src/gateway/http-utils.request-context.test.ts @@ -161,6 +161,17 @@ describe("resolveGatewayRequestContext", () => { }), ).toThrow("Unknown agent '!!!'."); }); + + it("rejects invalid model syntax before accepting an explicit agent header", () => { + expect(() => + resolveGatewayRequestContext({ + req: createReq({ "x-openclaw-agent-id": "main" }), + model: "gpt-4o", + sessionPrefix: "openai", + defaultMessageChannel: "webchat", + }), + ).toThrow("Invalid `model`. Use `openclaw` or `openclaw/`."); + }); }); describe("resolveTrustedHttpOperatorScopes", () => { diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index a7ef9dfe86d5..342d9e82beb7 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -6,7 +6,11 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + AgentSelectionRequiredError, + listAgentIds, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { modelKey, parseModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js"; import { getRuntimeConfig } from "../config/io.js"; @@ -65,10 +69,25 @@ class GatewaySessionKeyOverrideError extends Error { } } +class InvalidGatewayModelError extends Error { + constructor() { + super("Invalid `model`. Use `openclaw` or `openclaw/`."); + this.name = "InvalidGatewayModelError"; + } +} + export function isUnknownGatewayAgentError(err: unknown): err is UnknownGatewayAgentError { return err instanceof UnknownGatewayAgentError; } +export function isAgentSelectionRequiredError(err: unknown): err is AgentSelectionRequiredError { + return err instanceof AgentSelectionRequiredError; +} + +export function isInvalidGatewayModelError(err: unknown): err is InvalidGatewayModelError { + return err instanceof InvalidGatewayModelError; +} + export function isGatewaySessionKeyOverrideError( err: unknown, ): err is GatewaySessionKeyOverrideError { @@ -119,6 +138,22 @@ export function resolveAgentIdFromModel( return normalizeAgentId(agentId); } +/** Checks OpenClaw routing-model syntax without resolving fleet ownership. */ +export function isOpenClawAgentModelId(model: string | undefined): boolean { + const raw = model?.trim(); + if (!raw) { + return false; + } + const lowered = normalizeLowercaseStringOrEmpty(raw); + if (lowered === OPENCLAW_MODEL_ID || lowered === OPENCLAW_DEFAULT_MODEL_ID) { + return true; + } + return ( + /^openclaw[:/][a-z0-9][a-z0-9_-]{0,63}$/i.test(raw) || + /^agent:[a-z0-9][a-z0-9_-]{0,63}$/i.test(raw) + ); +} + /** Validates and resolves the `x-openclaw-model` override for OpenAI-compatible requests. */ export async function resolveOpenAiCompatModelOverride(params: { req: IncomingMessage; @@ -126,7 +161,7 @@ export async function resolveOpenAiCompatModelOverride(params: { model: string | undefined; }): Promise<{ modelOverride?: string; errorMessage?: string }> { const requestModel = params.model?.trim(); - if (requestModel && !resolveAgentIdFromModel(requestModel)) { + if (requestModel && !isOpenClawAgentModelId(requestModel)) { return { errorMessage: "Invalid `model`. Use `openclaw` or `openclaw/`.", }; @@ -186,6 +221,10 @@ export function resolveAgentIdForRequest(params: { model: string | undefined; }): string { const cfg = getRuntimeConfig(); + if (params.model?.trim() && !isOpenClawAgentModelId(params.model)) { + throw new InvalidGatewayModelError(); + } + const fromHeader = resolveAgentIdFromHeader(params.req); if (fromHeader) { assertKnownAgentId(fromHeader, cfg); diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index ec0272314447..73893a71b9c7 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -1,5 +1,5 @@ import { isAgentDeletionBlocked } from "../agents/agent-lifecycle-registry.js"; -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentIds } from "../agents/agent-scope.js"; // Local embedded Gateway request context. // Lets local agent paths reuse Gateway server methods without starting a server. import { @@ -7,6 +7,10 @@ import { loadResolvedPublishedModelCatalogOwner, } from "../agents/prepared-model-catalog.js"; import type { CliDeps } from "../cli/deps.types.js"; +import { + tryGetLegacyDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, +} from "../config/legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { CronService } from "../cron/service.js"; import { resolveCronJobsStorePath } from "../cron/store.js"; @@ -76,8 +80,10 @@ function createLocalGatewayRequestContext( cronEnabled: cfg.cron?.enabled !== false, cronConfig: cfg.cron, log: getChildLogger({ module: "cron", storePath }), - defaultAgentId: resolveDefaultAgentId(cfg), - resolveDefaultAgentId: () => resolveDefaultAgentId(params.getRuntimeConfig()), + defaultAgentId: tryResolveLegacyCompatibilityAgentId(cfg), + legacyDefaultAgentId: tryGetLegacyDefaultAgentId(cfg), + resolveDefaultAgentId: () => + tryResolveLegacyCompatibilityAgentId(params.getRuntimeConfig()), isAgentAvailable: (id) => !isAgentDeletionBlocked(id) && listAgentIds(params.getRuntimeConfig()).some( diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index 89b9398fdf55..05f76229f745 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -951,6 +951,22 @@ describe("handleManagedOutgoingImageHttpRequest", () => { expect(authorizeGatewayHttpRequestOrReplyMock).not.toHaveBeenCalled(); }); + it("rejects a managed global artifact owned by another agent", async () => { + const { attachmentId } = await createFixture(stateDir, { + sessionKey: "global", + agentId: "ops", + }); + + const download = await resolveManagedOutgoingImageArtifactDownload({ + sessionKey: "global", + agentId: "research", + artifactId: `${MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX}${attachmentId}`, + stateDir, + }); + + expect(download).toBeNull(); + }); + it("keeps serving and deleting an original after the configured media root changes", async () => { const fixture = await createFixture(stateDir); const externalConfigDir = tempDirs.make("managed-image-moved-config-"); diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index d9867739f245..0ca7c2cbedec 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -39,7 +39,7 @@ import { resolvePlaybackTranscode, } from "../media/playback-transcode.js"; import { getMediaDir, MEDIA_MAX_BYTES, saveMediaBuffer, saveMediaSource } from "../media/store.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { buildAssistantMediaContentDisposition } from "./assistant-media-content-disposition.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; @@ -1200,6 +1200,8 @@ async function resolveManagedOutgoingMediaArtifactDownloadForRecord( /** Resolve one transcript-backed media artifact to a short-lived HTTP capability. */ export async function resolveManagedOutgoingMediaArtifactDownload(params: { sessionKey: string; + agentId?: string; + defaultAgentId?: string; artifactId: string; stateDir?: string; }): Promise { @@ -1211,6 +1213,15 @@ export async function resolveManagedOutgoingMediaArtifactDownload(params: { if (!record || record.sessionKey !== params.sessionKey) { return null; } + const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; + const recordAgentId = record.agentId + ? normalizeAgentId(record.agentId) + : params.defaultAgentId + ? normalizeAgentId(params.defaultAgentId) + : undefined; + if (requestedAgentId && recordAgentId !== requestedAgentId) { + return null; + } const kind = resolveManagedRecordKind(record); if (!kind || (parsed.family === "image") !== (kind === "image")) { return null; diff --git a/src/gateway/mcp-app-operations.ts b/src/gateway/mcp-app-operations.ts index e97819f65b75..0b4e4d874b2a 100644 --- a/src/gateway/mcp-app-operations.ts +++ b/src/gateway/mcp-app-operations.ts @@ -25,6 +25,7 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { logWarn } from "../logger.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { restoreMcpAppView } from "./mcp-app-reconstruction.js"; export type McpAppActiveView = { @@ -111,20 +112,28 @@ async function requireCallableTool( export async function resolveMcpAppActiveView(params: { sessionKey: string; + agentId?: string; viewId: string; cfg?: OpenClawConfig; }): Promise { if (params.cfg && params.cfg.mcp?.apps?.enabled !== true) { throw new Error("MCP App runtime is unavailable"); } - const liveView = getMcpAppViewLeaseForSession(params.viewId, params.sessionKey); + const liveView = params.agentId + ? getMcpAppViewLeaseForSession(params.viewId, params.sessionKey, params.agentId) + : undefined; if (liveView) { if (liveView.runtime.mcpAppsEnabled !== true) { throw new Error("MCP App runtime is unavailable"); } return { runtime: liveView.runtime, view: liveView }; } - const existingRuntime = peekSessionMcpRuntime({ sessionKey: params.sessionKey }); + // An unscoped runtime key cannot prove its owning agent. Prefer transcript + // restoration with the prepared owner instead of adopting a sibling runtime. + const existingRuntime = + params.agentId && !parseAgentSessionKey(params.sessionKey) + ? undefined + : peekSessionMcpRuntime({ sessionKey: params.sessionKey }); if (existingRuntime && existingRuntime.mcpAppsEnabled !== true) { throw new Error("MCP App runtime is unavailable"); } @@ -137,6 +146,7 @@ export async function resolveMcpAppActiveView(params: { : params.cfg ? await restoreMcpAppView({ cfg: params.cfg, + agentId: params.agentId, sessionKey: params.sessionKey, viewId: params.viewId, }) diff --git a/src/gateway/mcp-app-reconstruction.test.ts b/src/gateway/mcp-app-reconstruction.test.ts index 68404f6b5fa2..14eaaa4d9dde 100644 --- a/src/gateway/mcp-app-reconstruction.test.ts +++ b/src/gateway/mcp-app-reconstruction.test.ts @@ -116,6 +116,7 @@ describe("MCP App transcript reconstruction", () => { expect(restored).toEqual({ runtime, view }); expect(mocks.fetchMcpAppView).toHaveBeenCalledWith({ runtime, + agentId: "main", serverName: "demo", toolName: "show", uiResourceUri: "ui://demo/app", diff --git a/src/gateway/mcp-app-reconstruction.ts b/src/gateway/mcp-app-reconstruction.ts index 32354eecf27c..5856c2c07c98 100644 --- a/src/gateway/mcp-app-reconstruction.ts +++ b/src/gateway/mcp-app-reconstruction.ts @@ -244,6 +244,7 @@ function getRestoreInFlight(): Map; @@ -251,7 +252,7 @@ async function reconstructMcpAppView(params: { readOnly: boolean; viewId?: string; }): Promise { - const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const agentId = params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey); const loaded = loadGatewaySessionEntryReadOnly(params.sessionKey, { agentId }); const sessionId = loaded.entry?.sessionId; if (!sessionId) { @@ -286,6 +287,7 @@ async function reconstructMcpAppView(params: { } const fetched = await fetchMcpAppView({ runtime, + agentId, serverName: data.descriptor.serverName, toolName: data.descriptor.toolName, uiResourceUri: data.descriptor.uiResourceUri, @@ -305,6 +307,7 @@ async function reconstructMcpAppView(params: { async function restoreMcpAppViewOnce(params: { cfg: OpenClawConfig; + agentId?: string; sessionKey: string; viewId: string; }): Promise { @@ -323,6 +326,7 @@ async function restoreMcpAppViewOnce(params: { export async function mintMcpAppViewFromTranscript(params: { cfg: OpenClawConfig; + agentId?: string; sessionKey: string; descriptor: BoardMcpAppDescriptor; allowedAppToolNames: ReadonlySet; @@ -343,10 +347,11 @@ export async function mintMcpAppViewFromTranscript(params: { export async function restoreMcpAppView(params: { cfg: OpenClawConfig; + agentId?: string; sessionKey: string; viewId: string; }): Promise { - const key = `${params.sessionKey}\0${params.viewId}`; + const key = `${params.agentId ?? ""}\0${params.sessionKey}\0${params.viewId}`; const inFlight = getRestoreInFlight(); return await getOrCreatePromise(inFlight, key, () => restoreMcpAppViewOnce(params), { evictOnSettled: true, diff --git a/src/gateway/mcp-app-standalone.test.ts b/src/gateway/mcp-app-standalone.test.ts index 80828b5ef4b9..cad67ad432f2 100644 --- a/src/gateway/mcp-app-standalone.test.ts +++ b/src/gateway/mcp-app-standalone.test.ts @@ -72,6 +72,7 @@ const runtime = { }; const view = { viewId: "mcp-app-view", + agentId: "main", sessionId: runtime.sessionId, runtime, serverName: "demo", diff --git a/src/gateway/mcp-grant-store.ts b/src/gateway/mcp-grant-store.ts index b4f16d2b9dce..fdfab11844d4 100644 --- a/src/gateway/mcp-grant-store.ts +++ b/src/gateway/mcp-grant-store.ts @@ -18,6 +18,8 @@ import { resolveGlobalMap } from "../shared/global-singleton.js"; export type McpLoopbackRequestContext = { sessionKey: string; runtimePolicySessionKey?: string; + /** Agent whose execution policy applies when it differs from the durable session owner. */ + runtimePolicyAgentId?: string; agentId?: string; sessionId?: string; runId?: string; diff --git a/src/gateway/mcp-http.runtime.test.ts b/src/gateway/mcp-http.runtime.test.ts index a87cb76c887f..264902b0c51a 100644 --- a/src/gateway/mcp-http.runtime.test.ts +++ b/src/gateway/mcp-http.runtime.test.ts @@ -201,6 +201,17 @@ describe("McpLoopbackToolCache", () => { expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(3); }); + it("does not share cache rows across different runtime policy agents", () => { + const cache = new McpLoopbackToolCache(); + const cfg = {} as OpenClawConfig; + + cache.resolve(scopeParams({ cfg, runtimePolicyAgentId: "main" })); + cache.resolve(scopeParams({ cfg, runtimePolicyAgentId: "worker" })); + cache.resolve(scopeParams({ cfg, runtimePolicyAgentId: "main" })); + + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(2); + }); + it("evicts only the revoked grant's cached tool closures", () => { const cache = new McpLoopbackToolCache(); const cfg = {} as OpenClawConfig; diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index cfab9e7b33bb..125532316ebd 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -181,6 +181,7 @@ export class McpLoopbackToolCache { params.grantToken ?? "", params.sessionKey, params.runtimePolicySessionKey ?? "", + params.runtimePolicyAgentId ?? "", params.agentId ?? "", params.sessionId ?? "", params.runId ?? "", diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 37ef30e2d213..8b205242092f 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -1233,7 +1233,8 @@ describe("mcp loopback server", () => { const boundContext = { sessionKey: "agent:main:discord:channel:bound", runtimePolicySessionKey: "agent:worker:discord:default:direct:bound-user", - agentId: "worker", + runtimePolicyAgentId: "worker", + agentId: "main", sessionId: "session-bound", runId: "run-bound", modelProvider: "anthropic", diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 0957042370b4..75a21787e0c8 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -311,6 +311,7 @@ async function startMcpLoopbackServer(port = 0): Promise<{ cfg, sessionKey: requestContext.sessionKey, runtimePolicySessionKey: requestContext.runtimePolicySessionKey, + runtimePolicyAgentId: requestContext.runtimePolicyAgentId, agentId: requestContext.agentId, sessionId: requestContext.sessionId, runId: requestContext.runId, diff --git a/src/gateway/models-http.test.ts b/src/gateway/models-http.test.ts index 103224be7fe7..15e3fde68ec8 100644 --- a/src/gateway/models-http.test.ts +++ b/src/gateway/models-http.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { startOpenAiCompatGatewayServer } from "./openai-compatible-http.test-helpers.js"; import { getGatewayTestPort, installGatewayTestHooks } from "./test-helpers.js"; +import { testState } from "./test-helpers.runtime-state.js"; installGatewayTestHooks({ scope: "suite" }); @@ -95,6 +96,36 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => { expect(json.id).toBe(firstId); }); + it("rejects agent-specific model ids outside the configured roster", async () => { + const res = await getModels("/v1/models/openclaw%2Fnonexistent"); + expect(res.status).toBe(404); + await expect(res.json()).resolves.toEqual({ + error: { + message: "Model 'openclaw/nonexistent' not found.", + type: "invalid_request_error", + }, + }); + }); + + it("keeps generic aliases available for ownerless explicit fleets", async () => { + try { + testState.agentsConfig = { + ownership: "explicit", + entries: { main: {}, research: {} }, + }; + const list = await getModels("/v1/models"); + expect(list.status).toBe(200); + const listJson = (await list.json()) as { data?: Array<{ id?: string }> }; + expect(listJson.data?.map((entry) => entry.id)).toContain("openclaw/default"); + + const detail = await getModels("/v1/models/openclaw%2Fdefault"); + expect(detail.status).toBe(200); + await expect(detail.json()).resolves.toMatchObject({ id: "openclaw/default" }); + } finally { + testState.agentsConfig = undefined; + } + }); + it("rejects operator scopes that lack read access", async () => { const res = await getModels("/v1/models", { "x-openclaw-scopes": "operator.approvals" }); await expectMissingReadScope(res); diff --git a/src/gateway/models-http.ts b/src/gateway/models-http.ts index 7e14a9b308ea..241f6a4d23a8 100644 --- a/src/gateway/models-http.ts +++ b/src/gateway/models-http.ts @@ -1,6 +1,6 @@ // OpenAI-compatible `/v1/models` HTTP route backed by configured OpenClaw agents. import type { IncomingMessage, ServerResponse } from "node:http"; -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentIds, tryResolveLegacyCompatibilityAgentId } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/io.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; @@ -14,8 +14,9 @@ import { OPENCLAW_DEFAULT_MODEL_ID, OPENCLAW_MODEL_ID, authorizeGatewayHttpRequestOrReply, - type AuthorizedGatewayHttpRequest, + isOpenClawAgentModelId, resolveAgentIdFromModel, + type AuthorizedGatewayHttpRequest, resolveOpenAiCompatibleHttpOperatorScopes, } from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; @@ -62,9 +63,11 @@ async function authorizeRequest( function loadAgentModelIds(): string[] { const cfg = getRuntimeConfig(); - const defaultAgentId = resolveDefaultAgentId(cfg); const ids = new Set([OPENCLAW_MODEL_ID, OPENCLAW_DEFAULT_MODEL_ID]); - ids.add(`openclaw/${defaultAgentId}`); + const compatibilityAgentId = tryResolveLegacyCompatibilityAgentId(cfg); + if (compatibilityAgentId) { + ids.add(`openclaw/${compatibilityAgentId}`); + } for (const agentId of listAgentIds(cfg)) { ids.add(`openclaw/${agentId}`); } @@ -126,11 +129,26 @@ export async function handleOpenAiModelsHttpRequest( return true; } - if (decodedId !== OPENCLAW_MODEL_ID && !resolveAgentIdFromModel(decodedId)) { + if (!isOpenClawAgentModelId(decodedId)) { sendInvalidRequest(res, "Invalid model id."); return true; } + const normalizedModelId = decodedId.trim().toLowerCase(); + if (normalizedModelId !== OPENCLAW_MODEL_ID && normalizedModelId !== OPENCLAW_DEFAULT_MODEL_ID) { + const cfg = getRuntimeConfig(); + const agentId = resolveAgentIdFromModel(decodedId, cfg); + if (!agentId || !listAgentIds(cfg).includes(agentId)) { + sendJson(res, 404, { + error: { + message: `Model '${decodedId}' not found.`, + type: "invalid_request_error", + }, + }); + return true; + } + } + if (!ids.includes(decodedId)) { sendJson(res, 404, { error: { diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 6607ee4ef106..414d5a48505d 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -168,6 +168,40 @@ function firstAgentCommandOptions() { } describe("OpenAI-compatible HTTP API (e2e)", () => { + it("returns a typed selection error unless an ownerless fleet request selects an agent", async () => { + try { + testState.agentsConfig = { + ownership: "explicit", + list: [{ id: "main" }, { id: "beta" }], + }; + resetConfigRuntimeState(); + agentCommandMock.mockClear(); + + const missing = await postChatCompletions(enabledPort, { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(missing.status).toBe(400); + const missingJson = (await missing.json()) as { error?: { message?: string; type?: string } }; + expect(missingJson.error?.type).toBe("invalid_request_error"); + expect(missingJson.error?.message).toContain("has no explicit owner"); + expect(agentCommandMock).not.toHaveBeenCalled(); + + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); + const selected = await postChatCompletions( + enabledPort, + { model: "openclaw/default", messages: [{ role: "user", content: "hi" }] }, + { "x-openclaw-agent-id": "main" }, + ); + expect(selected.status).toBe(200); + expect(firstAgentCommandOptions()?.sessionKey ?? "").toMatch(/^agent:main:/); + await selected.text(); + } finally { + testState.agentsConfig = undefined; + resetConfigRuntimeState(); + } + }); + it("handles request validation and routing", async () => { const port = enabledPort; const mockAgentOnce = (payloads: Array<{ text: string }>) => { @@ -225,7 +259,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { }; try { - testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] }; + testState.agentsConfig = { list: [{ id: "main" }] }; resetConfigRuntimeState(); { @@ -243,6 +277,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { method: "POST", headers: { "content-type": "application/json", + "x-openclaw-agent-id": "main", }, body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), }); @@ -252,6 +287,11 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { await res.text(); } + testState.agentsConfig = { + ownership: "explicit", + list: [{ id: "main" }, { id: "beta" }], + }; + resetConfigRuntimeState(); await expectAgentSessionKeyMatch({ body: { model: "openclaw", messages: [{ role: "user", content: "hi" }] }, headers: { "x-openclaw-agent-id": "beta" }, @@ -266,11 +306,15 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { matcher: /^agent:beta:/, }); + testState.agentsConfig = { list: [{ id: "main" }] }; + resetConfigRuntimeState(); + await expectAgentSessionKeyMatch({ body: { model: "openclaw/default", messages: [{ role: "user", content: "hi" }], }, + headers: { "x-openclaw-agent-id": "main" }, matcher: /^agent:main:/, }); @@ -320,6 +364,11 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } { + testState.agentsConfig = { + ownership: "explicit", + list: [{ id: "main" }, { id: "beta" }], + }; + resetConfigRuntimeState(); mockAgentOnce([{ text: "hello" }]); const res = await postChatCompletions( port, @@ -333,6 +382,8 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(firstAgentCommandOptions()?.sessionKey).toBe("agent:beta:openai:custom"); await res.text(); + testState.agentsConfig = { list: [{ id: "main" }] }; + resetConfigRuntimeState(); } { diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index f4d661570340..24572c6b5903 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -54,7 +54,9 @@ import { import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; import { authorizeOpenAiCompatibleHttpModelOverride, + isAgentSelectionRequiredError, isGatewaySessionKeyOverrideError, + isInvalidGatewayModelError, isUnknownGatewayAgentError, resolveGatewayRequestContext, resolveOpenAiCompatModelOverride, @@ -983,7 +985,12 @@ export async function handleOpenAiHttpRequest( useMessageChannelHeader: true, })); } catch (err) { - if (isUnknownGatewayAgentError(err) || isGatewaySessionKeyOverrideError(err)) { + if ( + isAgentSelectionRequiredError(err) || + isUnknownGatewayAgentError(err) || + isInvalidGatewayModelError(err) || + isGatewaySessionKeyOverrideError(err) + ) { sendJson(res, 400, { error: { message: err.message, type: "invalid_request_error" }, }); diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index b45f1edeea16..ba59435b3b54 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -310,6 +310,39 @@ async function expectInvalidRequest( } describe("OpenResponses HTTP API (e2e)", () => { + it("returns a typed selection error unless an ownerless fleet request selects an agent", async () => { + try { + testState.agentsConfig = { + ownership: "explicit", + list: [{ id: "main" }, { id: "beta" }], + }; + resetConfigRuntimeState(); + agentCommandMock.mockClear(); + + const missing = await postResponses(enabledPort, { model: "openclaw", input: "hi" }); + expect(missing.status).toBe(400); + const missingJson = (await missing.json()) as { error?: { message?: string; type?: string } }; + expect(missingJson.error?.type).toBe("invalid_request_error"); + expect(missingJson.error?.message).toContain("has no explicit owner"); + expect(agentCommandMock).not.toHaveBeenCalled(); + + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); + const selected = await postResponses( + enabledPort, + { model: "openclaw/default", input: "hi" }, + { "x-openclaw-agent-id": "main" }, + ); + expect(selected.status).toBe(200); + expect((firstAgentOpts() as { sessionKey?: string }).sessionKey ?? "").toMatch( + /^agent:main:/, + ); + await ensureResponseConsumed(selected); + } finally { + testState.agentsConfig = undefined; + resetConfigRuntimeState(); + } + }); + it.each([false, true])( "accepts the official OpenAI SDK plain-text response format (stream: %s)", async (stream) => { @@ -558,7 +591,7 @@ describe("OpenResponses HTTP API (e2e)", () => { }; try { - testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] }; + testState.agentsConfig = { list: [{ id: "main" }] }; resetConfigRuntimeState(); const resNonPost = await fetch(`http://127.0.0.1:${port}/v1/responses`, { @@ -570,7 +603,7 @@ describe("OpenResponses HTTP API (e2e)", () => { const resMissingAuth = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", "x-openclaw-agent-id": "main" }, body: JSON.stringify({ model: "openclaw", input: "hi" }), }); expect(resMissingAuth.status).toBe(200); @@ -598,6 +631,11 @@ describe("OpenResponses HTTP API (e2e)", () => { await ensureResponseConsumed(resInvalidModel); mockAgentOnce([{ text: "hello" }]); + testState.agentsConfig = { + ownership: "explicit", + list: [{ id: "main" }, { id: "beta" }], + }; + resetConfigRuntimeState(); const resHeader = await postResponses( port, { model: "openclaw", input: "hi" }, @@ -628,6 +666,9 @@ describe("OpenResponses HTTP API (e2e)", () => { ); await ensureResponseConsumed(resSessionOverride); + testState.agentsConfig = { list: [{ id: "main" }] }; + resetConfigRuntimeState(); + agentCommandMock.mockClear(); const resReservedSessionOverride = await postResponses( port, @@ -662,6 +703,11 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(agentCommandMock).toHaveBeenCalledTimes(0); mockAgentOnce([{ text: "hello" }]); + testState.agentsConfig = { + ownership: "explicit", + list: [{ id: "main" }, { id: "beta" }], + }; + resetConfigRuntimeState(); const resModel = await postResponses(port, { model: "openclaw/beta", input: "hi" }); expect(resModel.status).toBe(200); const optsModel = firstAgentOpts(); @@ -670,6 +716,9 @@ describe("OpenResponses HTTP API (e2e)", () => { ); await ensureResponseConsumed(resModel); + testState.agentsConfig = { list: [{ id: "main" }] }; + resetConfigRuntimeState(); + mockAgentOnce([{ text: "hello" }]); const resDefaultAlias = await postResponses(port, { model: "openclaw/default", input: "hi" }); expect(resDefaultAlias.status).toBe(200); diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 4c03f20288f1..e72170ed29d9 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -56,7 +56,9 @@ import { authorizeOpenAiCompatibleHttpModelOverride, getBearerToken, getHeader, + isAgentSelectionRequiredError, isGatewaySessionKeyOverrideError, + isInvalidGatewayModelError, isUnknownGatewayAgentError, resolveAgentIdForRequest, resolveGatewayRequestContext, @@ -464,7 +466,11 @@ export async function handleOpenResponsesHttpRequest( try { agentId = resolveAgentIdForRequest({ req, model }); } catch (err) { - if (isUnknownGatewayAgentError(err)) { + if ( + isAgentSelectionRequiredError(err) || + isInvalidGatewayModelError(err) || + isUnknownGatewayAgentError(err) + ) { sendJson(res, 400, { error: { message: err.message, type: "invalid_request_error" }, }); @@ -610,7 +616,12 @@ export async function handleOpenResponsesHttpRequest( useMessageChannelHeader: true, }); } catch (err) { - if (isUnknownGatewayAgentError(err) || isGatewaySessionKeyOverrideError(err)) { + if ( + isAgentSelectionRequiredError(err) || + isUnknownGatewayAgentError(err) || + isInvalidGatewayModelError(err) || + isGatewaySessionKeyOverrideError(err) + ) { sendJson(res, 400, { error: { message: err.message, type: "invalid_request_error" }, }); diff --git a/src/gateway/server-broadcast.board.test.ts b/src/gateway/server-broadcast.board.test.ts index a67c71bd1462..dc59866c5356 100644 --- a/src/gateway/server-broadcast.board.test.ts +++ b/src/gateway/server-broadcast.board.test.ts @@ -3,6 +3,7 @@ import { GATEWAY_CLIENT_CAPS, GATEWAY_CLIENT_IDS, } from "../../packages/gateway-protocol/src/client-info.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createGatewayBroadcaster } from "./server-broadcast.js"; import { createSessionEventSubscriberRegistry, @@ -159,7 +160,8 @@ describe("collaboration event scope guards", () => { const audience = createSessionObserverAudience({ subscribers, isVisible: () => true, - getDefaultAgentId: () => "main", + getConfig: () => + ({ agents: { list: [{ id: "main", default: true }, { id: "work" }] } }) as OpenClawConfig, }); const { broadcastToConnIds } = createGatewayBroadcaster({ clients: new Set([main.client, legacy.client, both.client, work.client, workRaw.client]), @@ -197,7 +199,8 @@ describe("collaboration event scope guards", () => { subscribers, sessionEventSubscribers, isVisible: () => true, - getDefaultAgentId: () => "main", + getConfig: () => + ({ agents: { list: [{ id: "main", default: true }, { id: "work" }] } }) as OpenClawConfig, }); const { broadcastToConnIds } = createGatewayBroadcaster({ clients: new Set([message.client, eventOnly.client, unrelated.client]), diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index d40b3d9dbfe4..d9d2955ab61e 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -121,6 +121,7 @@ describe("agent event handler", () => { .mockReset() .mockReturnValue({ cfg: {}, + agentId: "main", storePath: "/tmp/sessions.json", store: {}, entry: undefined, @@ -226,6 +227,7 @@ describe("agent event handler", () => { ) { vi.mocked(loadSessionEntry).mockReturnValue({ cfg: {}, + agentId: "main", storePath: "/tmp/sessions.json", store: {}, entry, diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index 89761adcf3cf..5bd00f731111 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -145,7 +145,7 @@ export async function startGatewayCoreRuntime(input: { coreGatewayMethodNames, pluginHostServices, baseMethods, - defaultWorkspaceDir, + pluginWorkspaceDir, ambientEnvTriggers, workerEnvironmentStartup, broadcastPluginEvent, @@ -511,7 +511,7 @@ export async function startGatewayCoreRuntime(input: { }); const nextPluginLookUpTable = loadPluginLookUpTable({ config: nextPluginActivationConfig, - workspaceDir: defaultWorkspaceDir, + workspaceDir: pluginWorkspaceDir, env: params.env, activationSourceConfig: params.nextConfig, // Workers can be created after startup; reload planning needs the live durable set. @@ -574,7 +574,7 @@ export async function startGatewayCoreRuntime(input: { ); const loaded = prepareGatewayPluginLoad({ cfg: params.nextConfig, - workspaceDir: defaultWorkspaceDir, + workspaceDir: pluginWorkspaceDir, log, coreGatewayMethodNames, hostServices: pluginHostServices, @@ -586,12 +586,12 @@ export async function startGatewayCoreRuntime(input: { snapshot: nextPluginLookUpTable, config: params.nextConfig, env: params.env, - workspaceDir: defaultWorkspaceDir, + workspaceDir: pluginWorkspaceDir, }); setCurrentPluginMetadataSnapshot(nextPluginMetadataSnapshot, { config: params.nextConfig, env: params.env, - workspaceDir: defaultWorkspaceDir, + workspaceDir: pluginWorkspaceDir, }); replaceAttachedPluginRuntime(loaded); kernel.setPluginServices(null); @@ -603,7 +603,7 @@ export async function startGatewayCoreRuntime(input: { await startPluginServices({ registry: loaded.pluginRegistry, config: params.nextConfig, - workspaceDir: defaultWorkspaceDir, + workspaceDir: pluginWorkspaceDir, broadcastPluginEvent, }), ); diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 712dbab0766d..97e1f0c1e30d 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -9,6 +9,7 @@ import { createDeferred } from "../../test/helpers/promise.js"; import { AgentDeletionCommitUncertainError } from "../agents/agent-lifecycle-registry.js"; import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { getActiveGatewayRootWorkCount, resetGatewayWorkAdmission, @@ -340,6 +341,99 @@ describe("buildGatewayCronService", () => { }); }); + it("keeps sole-agent ownerless jobs dynamic across a restart and roster rename", async () => { + const tmpDir = path.join(os.tmpdir(), `server-cron-sole-owner-${Date.now()}`); + const store = path.join(tmpDir, "cron.json"); + const opsCfg = { + cron: { store }, + agents: { entries: { ops: {} } }, + } as OpenClawConfig; + loadConfigMock.mockReturnValue(opsCfg); + const initial = buildGatewayCronService({ + cfg: opsCfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + await initial.cron.start(); + const job = await initial.cron.add({ + name: "dynamic sole owner", + enabled: true, + schedule: { kind: "at", at: new Date(Date.now() + 3_600_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "follow the live owner" }, + }); + expect(job.agentId).toBeUndefined(); + initial.cron.stop(); + + const restarted = buildGatewayCronService({ + cfg: opsCfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + await restarted.cron.start(); + expect((await restarted.cron.readJob(job.id))?.agentId).toBeUndefined(); + + loadConfigMock.mockReturnValue({ + ...opsCfg, + agents: { entries: { research: {} } }, + }); + await expect(restarted.cron.run(job.id, "force")).resolves.toEqual({ + ok: true, + ran: true, + }); + expectIsolatedRunFields({ agentId: "research" }); + } finally { + restarted.cron.stop(); + } + }); + + it("pins ownerless jobs only when a retained legacy owner is present", async () => { + const tmpDir = path.join(os.tmpdir(), `server-cron-retained-owner-${Date.now()}`); + const cfg = retainLegacyDefaultAgentId( + { + cron: { store: path.join(tmpDir, "cron.json") }, + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + } as OpenClawConfig, + "ops", + ); + loadConfigMock.mockReturnValue(cfg); + const initial = buildGatewayCronService({ + cfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + await initial.cron.start(); + const job = await initial.cron.add({ + name: "legacy retained owner", + enabled: true, + schedule: { kind: "at", at: new Date(Date.now() + 3_600_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "pin once" }, + }); + expect(job.agentId).toBe("ops"); + initial.cron.stop(); + + const restartedCfg = structuredClone(cfg); + loadConfigMock.mockReturnValue(restartedCfg); + const restarted = buildGatewayCronService({ + cfg: restartedCfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + await restarted.cron.start(); + expect((await restarted.cron.readJob(job.id))?.agentId).toBe("ops"); + } finally { + restarted.cron.stop(); + } + }); + it("passes the persisted payload tool cap to trigger evaluation", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-14T12:00:00.000Z")); @@ -2423,8 +2517,7 @@ describe("buildGatewayCronService", () => { }, agents: { entries: { - primary: { default: true, model: "test/primary" }, - main: { model: "test/main" }, + primary: { model: "test/primary" }, }, }, } as unknown as OpenClawConfig; @@ -2943,11 +3036,11 @@ describe("buildGatewayCronService", () => { const tmpDir = path.join(os.tmpdir(), `server-cron-default-change-${Date.now()}`); const startupCfg = { cron: { store: path.join(tmpDir, "cron.json") }, - agents: { entries: { main: {}, yinze: { default: true }, other: {} } }, + agents: { entries: { yinze: {} } }, } as OpenClawConfig; const runtimeCfg = { ...startupCfg, - agents: { entries: { main: {}, yinze: {}, other: { default: true } } }, + agents: { entries: { other: {} } }, } as OpenClawConfig; loadConfigMock.mockReturnValue(startupCfg); const state = buildGatewayCronService({ diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index c45305a3da3b..b601b7190705 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -2,11 +2,16 @@ // plugin hooks, notifications, and cron lifecycle cleanup. import { retireSessionMcpRuntime } from "../agents/agent-bundle-mcp-tools.js"; import { isAgentDeletionBlocked } from "../agents/agent-lifecycle-registry.js"; -import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries, listAgentIds } from "../agents/agent-scope.js"; import { abortAndDrainEmbeddedAgentRun } from "../agents/embedded-agent.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { CliDeps } from "../cli/deps.types.js"; import { getRuntimeConfig } from "../config/io.js"; +import { + resolveSessionStoreCompatibilityAgentId, + tryGetLegacyDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, +} from "../config/legacy.default-agent-owner.js"; import { canonicalizeMainSessionAlias, resolveAgentIdFromSessionKey, @@ -19,6 +24,7 @@ import { } from "../config/sessions/targets.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveCronJobEffectiveAgentId } from "../cron/agent-id.js"; import { buildCronCommandSummary, redactCronCommandSummaryForExternalDelivery, @@ -372,7 +378,7 @@ export function buildGatewayCronService(params: { const runtimeConfig = getRuntimeConfig(); const normalized = typeof requested === "string" && requested.trim() ? normalizeAgentId(requested) : undefined; - const defaultAgentId = resolveDefaultAgentId(runtimeConfig); + const defaultAgentId = tryResolveLegacyCompatibilityAgentId(runtimeConfig); if ( normalized !== undefined && normalized !== defaultAgentId && @@ -380,7 +386,10 @@ export function buildGatewayCronService(params: { ) { throw new Error(`cron job agent is unavailable: ${normalized}`); } - const agentId = normalized ?? defaultAgentId; + const agentId = resolveCronJobEffectiveAgentId( + normalized ? { agentId: normalized } : {}, + defaultAgentId, + ); if (isAgentDeletionBlocked(agentId)) { throw new Error(`cron job agent is unavailable: ${agentId}`); } @@ -492,10 +501,11 @@ export function buildGatewayCronService(params: { return sanitizeCronHeartbeatOverride(heartbeatOverride); }; - const defaultAgentId = resolveDefaultAgentId(params.cfg); + const defaultAgentId = tryResolveLegacyCompatibilityAgentId(params.cfg); + const legacyDefaultAgentId = tryGetLegacyDefaultAgentId(params.cfg); const resolveSessionStorePath = (agentId?: string) => resolveSessionStorePathCore(params.cfg.session?.store, { - agentId: agentId ?? defaultAgentId, + agentId: agentId ?? resolveSessionStoreCompatibilityAgentId(getRuntimeConfig()), }); const sessionStorePath = resolveSessionStorePath(defaultAgentId); const scriptRuntime = @@ -710,8 +720,9 @@ export function buildGatewayCronService(params: { }), } : {}), - defaultAgentId, - resolveDefaultAgentId: () => resolveDefaultAgentId(getRuntimeConfig()), + ...(defaultAgentId ? { defaultAgentId } : {}), + ...(legacyDefaultAgentId ? { legacyDefaultAgentId } : {}), + resolveDefaultAgentId: () => tryResolveLegacyCompatibilityAgentId(getRuntimeConfig()), resolveSessionStoreAgentIds: () => { const cfg = getRuntimeConfig(); try { diff --git a/src/gateway/server-kernel.test.ts b/src/gateway/server-kernel.test.ts index 6c414ac44676..1e0c556fb33c 100644 --- a/src/gateway/server-kernel.test.ts +++ b/src/gateway/server-kernel.test.ts @@ -154,6 +154,8 @@ describe("createGatewayKernel", () => { "plugins.metadata.scan", "plugins.metadata.freeze", "config.snapshot.read.materialize", + "plugins.metadata.scan", + "plugins.metadata.freeze", "config.snapshot.read.observe", "config.auth", "config.auth.snapshot-validate", diff --git a/src/gateway/server-maintenance.ts b/src/gateway/server-maintenance.ts index 444e4c462485..4489458534ce 100644 --- a/src/gateway/server-maintenance.ts +++ b/src/gateway/server-maintenance.ts @@ -48,6 +48,7 @@ import { hasRegisteredChatRunForSessionKey } from "./server-methods/session-acti import { PENDING_CHAT_SEND_DEDUPE_PREFIX, type DedupeEntry } from "./server-shared.js"; import { formatError } from "./server-utils.js"; import { setBroadcastHealthUpdate } from "./server/health-state.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "./session-request-agent.js"; // Hourly sweep plus a one-day grace bounds orphan storage without racing the // stage-before-row-commit window. @@ -362,12 +363,15 @@ export function startGatewayMaintenanceTimers(params: { (async () => { const { cleanupManagedOutgoingMediaRecords } = await import("./managed-image-attachments.js"); return await cleanupManagedOutgoingMediaRecords({ - hasActiveSessionRun: (sessionKey, agentId) => - hasRegisteredChatRunForSessionKey({ + hasActiveSessionRun: (sessionKey, agentId) => { + const cfg = params.getRuntimeConfig(); + return hasRegisteredChatRunForSessionKey({ context: { chatAbortControllers: params.chatAbortControllers }, sessionKey, agentId, - }), + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, sessionKey), + }); + }, }); }); const managedOutgoingCleanupLoader = createLazyPromiseLoader(async () => { diff --git a/src/gateway/server-methods/agent-cron-continuation.ts b/src/gateway/server-methods/agent-cron-continuation.ts index 1a49f3df8ead..d1a0e5fb9762 100644 --- a/src/gateway/server-methods/agent-cron-continuation.ts +++ b/src/gateway/server-methods/agent-cron-continuation.ts @@ -36,6 +36,7 @@ export function createCronContinuationController(params: { try { const released = await applySessionEntryReplacements({ activeSessionKey: activeClaim.sessionKey, + agentId: activeClaim.sessionAgentId, requireWriteSuccess: true, sessionKeys: baseSessionKey && baseSessionKey !== activeClaim.sessionKey @@ -116,6 +117,7 @@ export function createCronContinuationController(params: { if (released && baseSessionKey) { emitSessionsChanged(params.context, { sessionKey: baseSessionKey, + agentId: activeClaim.sessionAgentId, reason: "cron-continuation", }); } diff --git a/src/gateway/server-methods/agent-id-shared.ts b/src/gateway/server-methods/agent-id-shared.ts index a67de47ab15d..694034b176c8 100644 --- a/src/gateway/server-methods/agent-id-shared.ts +++ b/src/gateway/server-methods/agent-id-shared.ts @@ -1,7 +1,12 @@ // Shared agent-id resolution for gateway handlers that accept optional agent ids // and must reject unknown explicit ids consistently. import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + AgentSelectionRequiredError, + listAgentIds, + resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, +} from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { RespondFn } from "./types.js"; @@ -16,7 +21,22 @@ export function resolveAgentIdOrRespondError(params: { }) { const knownAgents = listAgentIds(params.cfg); const requestedAgentId = params.normalize(params.rawAgentId) ?? ""; - const agentId = requestedAgentId || resolveDefaultAgentId(params.cfg); + let agentId: string; + try { + agentId = + requestedAgentId || + tryResolveLegacyCompatibilityAgentId(params.cfg) || + resolveDefaultAgentId(params.cfg, { + surface: "this Gateway request", + hint: "Set agentId to one of the configured agents.", + }); + } catch (error) { + if (!(error instanceof AgentSelectionRequiredError)) { + throw error; + } + params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); + return null; + } if (requestedAgentId && !knownAgents.includes(agentId)) { params.respond( false, diff --git a/src/gateway/server-methods/agent-identity.ts b/src/gateway/server-methods/agent-identity.ts index 8c3c35eed229..3296cd9c6d58 100644 --- a/src/gateway/server-methods/agent-identity.ts +++ b/src/gateway/server-methods/agent-identity.ts @@ -5,10 +5,10 @@ import { validateAgentIdentityParams, } from "../../../packages/gateway-protocol/src/index.js"; import { resolvePublicAgentAvatarSource } from "../../agents/identity-avatar.js"; -import { resolveAgentIdFromSessionKey } from "../../config/sessions.js"; import { classifySessionKeyShape, normalizeAgentId } from "../../routing/session-key.js"; import { resolveGatewayAssistantAvatar } from "../assistant-avatar.js"; import { resolveAssistantIdentity } from "../assistant-identity.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -22,6 +22,7 @@ export const agentIdentityGetHandler: GatewayRequestHandlers["agent.identity.get } const agentIdRaw = normalizeOptionalString(params.agentId) ?? ""; const sessionKeyRaw = normalizeOptionalString(params.sessionKey) ?? ""; + const cfg = context.getRuntimeConfig(); let agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined; if (sessionKeyRaw) { if (classifySessionKeyShape(sessionKeyRaw) === "malformed_agent") { @@ -35,21 +36,20 @@ export const agentIdentityGetHandler: GatewayRequestHandlers["agent.identity.get ); return; } - const resolved = resolveAgentIdFromSessionKey(sessionKeyRaw); - if (agentId && resolved !== agentId) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid agent.identity.get params: agent "${agentIdRaw}" does not match session key agent "${resolved}"`, - ), - ); + const resolved = resolveRequestedSessionAgentId(cfg, sessionKeyRaw, agentId); + if (!resolved.ok) { + respond(false, undefined, resolved.error); return; } - agentId = resolved; + agentId = resolved.agentId; + } else if (!agentId) { + const resolved = resolveRequestedSessionAgentId(cfg, "main"); + if (!resolved.ok) { + respond(false, undefined, resolved.error); + return; + } + agentId = resolved.agentId; } - const cfg = context.getRuntimeConfig(); const identity = resolveAssistantIdentity({ cfg, agentId }); const avatarProjection = resolveGatewayAssistantAvatar({ cfg, identity }); const avatarResolution = avatarProjection.resolution; diff --git a/src/gateway/server-methods/agent-reset-phase.ts b/src/gateway/server-methods/agent-reset-phase.ts index 99e0b9822afc..0503cdd52488 100644 --- a/src/gateway/server-methods/agent-reset-phase.ts +++ b/src/gateway/server-methods/agent-reset-phase.ts @@ -93,9 +93,7 @@ export async function runAgentResetPhase(params: { try { resetResult = await runSessionResetFromAgent({ key: params.requestedSessionKey, - ...(params.requestedSessionKey === "global" && params.agentId - ? { agentId: params.agentId } - : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), reason: resetReason, creation: resolveAgentRunSessionCreation(params.client), assertCurrent: () => assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration), @@ -184,7 +182,7 @@ export async function runAgentResetPhase(params: { params.respond(true, responsePayload, undefined, { runId: params.runId }); emitSessionsChanged(params.context, { sessionKey: resetResult.key, - ...(resetResult.key === "global" && params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), reason: resetReason, }); return { ...next, stop: true, accepted: true }; diff --git a/src/gateway/server-methods/agent-session-prepare.ts b/src/gateway/server-methods/agent-session-prepare.ts index 86669a94d39d..d2a200228497 100644 --- a/src/gateway/server-methods/agent-session-prepare.ts +++ b/src/gateway/server-methods/agent-session-prepare.ts @@ -1,12 +1,10 @@ import { randomUUID } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { hasGeneratedMediaCompletionEvent } from "../../agents/internal-event-contract.js"; import { evaluateSessionFreshness, hasTerminalMainSessionTranscriptNewerThanRegistrySync, - resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveChannelResetConfig, resolveSessionLifecycleTimestamps, @@ -22,12 +20,14 @@ import { readTranscriptStatsSync } from "../../config/sessions/session-accessor. import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js"; import { isRecoverableTerminalSessionStatus } from "../../config/sessions/terminal-status.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { parseCronRunScopeSuffix } from "../../sessions/session-key-utils.js"; import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { respondDeletedAgentSession, type RestoredCronContinuation, } from "../agent-turn/agent-handler-helpers.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { loadSessionEntry } from "../session-utils.js"; import type { AgentRunRequest } from "./agent-request-types.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; @@ -61,6 +61,7 @@ type PreparedAgentSession = { }; export function prepareAgentSession(params: { + cfg: OpenClawConfig; requestedSessionKey: string; requestedSessionId?: string; expectedExistingSessionId?: string; @@ -73,9 +74,19 @@ export function prepareAgentSession(params: { preAttachmentSession?: { canonicalKey: string; sessionId?: string }; respond: GatewayRequestHandlerOptions["respond"]; }): PreparedAgentSession | undefined { + const requestedSessionAgent = resolveRequestedSessionAgentId( + params.cfg, + params.requestedSessionKey, + params.agentId, + ); + if (!requestedSessionAgent.ok) { + params.respond(false, undefined, requestedSessionAgent.error); + return undefined; + } + const requestedAgentId = requestedSessionAgent.agentId; const { cfg, storePath, entry, canonicalKey, legacyKey, storeKeys } = loadSessionEntry( params.requestedSessionKey, - { ...(params.agentId ? { agentId: params.agentId } : {}), clone: false }, + { agentId: requestedAgentId, clone: false }, ); if (params.expectedExistingSessionId && entry?.sessionId !== params.expectedExistingSessionId) { params.respond( @@ -193,10 +204,7 @@ export function prepareAgentSession(params: { return undefined; } - const canonicalSessionAgentId = - canonicalKey === "global" - ? (params.agentId ?? resolveDefaultAgentId(cfg)) - : resolveAgentIdFromSessionKey(canonicalKey); + const canonicalSessionAgentId = parseAgentSessionKey(canonicalKey)?.agentId ?? requestedAgentId; const now = Date.now(); const resetPolicy = resolveSessionResetPolicy({ sessionCfg: cfg.session, diff --git a/src/gateway/server-methods/agent-session-reset.ts b/src/gateway/server-methods/agent-session-reset.ts index 3c86282de40c..8922f47767e6 100644 --- a/src/gateway/server-methods/agent-session-reset.ts +++ b/src/gateway/server-methods/agent-session-reset.ts @@ -1,5 +1,4 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { AgentCommandOpts } from "../../agents/command/types.js"; import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; import { agentCommandFromIngress } from "../../commands/agent.js"; @@ -255,20 +254,15 @@ export function loadBareSessionResetDeliverySession(params: { entry?: SessionEntry; agentId: string; } { - const selectedGlobalAgentId = - params.sessionKey === "global" && params.agentId ? params.agentId : undefined; const loaded = loadSessionEntry(params.sessionKey, { clone: false, - ...(selectedGlobalAgentId ? { agentId: selectedGlobalAgentId } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), }); const loadedCfg = loaded?.cfg ?? params.cfg; return { cfg: loadedCfg, entry: loaded?.entry, - agentId: - selectedGlobalAgentId ?? - resolveAgentIdFromSessionKey(params.sessionKey) ?? - resolveDefaultAgentId(loadedCfg), + agentId: resolveAgentIdFromSessionKey(params.sessionKey, params.agentId), }; } diff --git a/src/gateway/server-methods/agent.abort-integration.test-utils.ts b/src/gateway/server-methods/agent.abort-integration.test-utils.ts index 2766fcbb212e..e7af40975347 100644 --- a/src/gateway/server-methods/agent.abort-integration.test-utils.ts +++ b/src/gateway/server-methods/agent.abort-integration.test-utils.ts @@ -2499,10 +2499,15 @@ describe("gateway agent handler chat.abort integration", () => { }); expect(mocks.agentCommand).toHaveBeenCalledTimes(1); - expect(duplicateRespond).toHaveBeenCalledWith(true, { runId, status: "in_flight" }, undefined, { - cached: true, - runId, - }); + expect(duplicateRespond).toHaveBeenCalledWith( + true, + { runId, status: "in_flight", agentId: "main" }, + undefined, + { + cached: true, + runId, + }, + ); finishRun({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } }); }); @@ -2580,7 +2585,7 @@ describe("gateway agent handler chat.abort integration", () => { expect(mocks.agentCommand).not.toHaveBeenCalled(); expect(duplicateRespond).toHaveBeenCalledWith( true, - { runId, status: "in_flight", sessionKey: "agent:main:main" }, + { runId, status: "in_flight", sessionKey: "agent:main:main", agentId: "main" }, undefined, { cached: true, diff --git a/src/gateway/server-methods/agent.base.test-utils.ts b/src/gateway/server-methods/agent.base.test-utils.ts index 5c3625ec83cc..cf65a3610124 100644 --- a/src/gateway/server-methods/agent.base.test-utils.ts +++ b/src/gateway/server-methods/agent.base.test-utils.ts @@ -264,10 +264,15 @@ describe("gateway agent handler", () => { respond: duplicateRespond, flushDispatch: false, }); - expect(duplicateRespond).toHaveBeenCalledWith(true, { runId, status: "in_flight" }, undefined, { - cached: true, - runId, - }); + expect(duplicateRespond).toHaveBeenCalledWith( + true, + { runId, status: "in_flight", agentId: "ops" }, + undefined, + { + cached: true, + runId, + }, + ); expect(mocks.resolveAgentExplicitRecipientSession).toHaveBeenCalledTimes(1); finishRoute({ sessionKey }); diff --git a/src/gateway/server-methods/agent.create-event.test.ts b/src/gateway/server-methods/agent.create-event.test.ts index 7f184e476e6b..25dc31f7f802 100644 --- a/src/gateway/server-methods/agent.create-event.test.ts +++ b/src/gateway/server-methods/agent.create-event.test.ts @@ -131,6 +131,7 @@ describe("agent handler session create events", () => { expect(call?.[1]?.reason).toBe("create"); expect(call?.[2]).toEqual(new Set(["conn-1"])); expect(call?.[3]).toEqual({ + agentId: "main", dropIfSlow: true, sessionKeys: ["agent:main:subagent:create-test"], }); diff --git a/src/gateway/server-methods/agent.media-and-routing.test-utils.ts b/src/gateway/server-methods/agent.media-and-routing.test-utils.ts index 84a6d4cb5a5b..29fab0203027 100644 --- a/src/gateway/server-methods/agent.media-and-routing.test-utils.ts +++ b/src/gateway/server-methods/agent.media-and-routing.test-utils.ts @@ -1296,7 +1296,10 @@ describe("gateway agent handler", () => { status: "running", }); expect(mockCallArg(broadcastToConnIds, 0, 2)).toEqual(new Set(["conn-1"])); - expect(mockCallArg(broadcastToConnIds, 0, 3)).toEqual({ dropIfSlow: true }); + expect(mockCallArg(broadcastToConnIds, 0, 3)).toEqual({ + agentId: "main", + dropIfSlow: true, + }); }); it("passes the raw user message to agentCommand for LLM-boundary timestamping", async () => { diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index 0950bd9700ef..2755e5bc0641 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -2158,6 +2158,7 @@ describe("gateway agent handler", () => { meta: { durationMs: 100 }, }); const respond = vi.fn(); + mocks.loadSessionEntry.mockClear(); await invokeAgent( { @@ -2180,10 +2181,13 @@ describe("gateway agent handler", () => { }>(); expect(call.agentId).toBe("work"); expect(call.sessionKey).toBe("global"); - expect(mocks.loadSessionEntry).toHaveBeenCalledWith("global", { - agentId: "work", - clone: false, - }); + const globalLoadCalls = mocks.loadSessionEntry.mock.calls.filter( + ([sessionKey]) => sessionKey === "global", + ); + expect(globalLoadCalls.length).toBeGreaterThan(0); + for (const [, options] of globalLoadCalls) { + expect(options).toEqual({ agentId: "work", clone: false }); + } }); it("routes bare global session keys to the configured default agent", async () => { @@ -2211,6 +2215,7 @@ describe("gateway agent handler", () => { payloads: [{ text: "ok" }], meta: { durationMs: 100 }, }); + mocks.loadSessionEntry.mockClear(); await invokeAgent( { @@ -2227,9 +2232,13 @@ describe("gateway agent handler", () => { }>(); expect(call.agentId).toBe("ops"); expect(call.sessionKey).toBe("global"); - expect(mocks.loadSessionEntry).toHaveBeenCalledWith("global", { - clone: false, - }); + const globalLoadCalls = mocks.loadSessionEntry.mock.calls.filter( + ([sessionKey]) => sessionKey === "global", + ); + expect(globalLoadCalls.length).toBeGreaterThan(0); + for (const [, options] of globalLoadCalls) { + expect(options).toEqual({ agentId: "ops", clone: false }); + } }); it("infers selected-global agent id from agent-prefixed session aliases", async () => { @@ -2417,6 +2426,11 @@ describe("gateway agent handler", () => { it("preserves selected-global agent id on cached accepted responses", async () => { const context = makeContext(); + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadConfigReturn = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; mocks.agentCommand.mockClear(); context.dedupe.set("agent:cached-global-work", { ts: Date.now(), diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index e773f285697f..d29cc39d40e3 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -182,6 +182,10 @@ vi.mock("../../agents/agent-scope.js", () => ({ } return defaults[0]!.id; }, + tryResolveSoleAgentId: (cfg: unknown) => { + const entries = getAgentList(cfg); + return entries.length === 1 ? entries[0]?.id : undefined; + }, resolveAgentDir: mocks.resolveAgentDir, resolveAgentConfig: (cfg: unknown, agentId: string) => getAgentList(cfg).find((entry) => entry.id === agentId), @@ -1281,6 +1285,25 @@ describe("agents.delete", () => { mocks.movePathToTrash.mockReset().mockResolvedValue("/trashed"); }); + it("rejects deleting the auth-inheritance owner before starting cleanup", async () => { + mocks.loadConfigReturn = { + agents: { + defaults: { authInheritance: { agentId: "test-agent" } }, + list: [ + { id: "test-agent", workspace: "/workspace/test-agent" }, + { id: "main", default: true }, + ], + }, + }; + const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); + await promise; + + expectRespondErrorContaining(respond, "agents.defaults.authInheritance.agentId"); + expect(mocks.cronRemoveAgentJobsTransactional).not.toHaveBeenCalled(); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.movePathToTrash).not.toHaveBeenCalled(); + }); + it("removes only the deleted agent's authority before committing its roster removal", async () => { const cronJobs = [ { id: "deleted-job", agentId: "test-agent" }, diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 0c0e9f00d66f..b1b17c286740 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -34,11 +34,11 @@ import { beginAgentDeletion, claimCompletedAgentDeletion, } from "../../agents/agent-lifecycle-registry.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { listAgentIds, resolveAgentDir, resolveAgentWorkspaceDir, + tryResolveSoleAgentId, } from "../../agents/agent-scope.js"; import { createAgentIdentityConfig, @@ -47,6 +47,7 @@ import { sanitizeAgentIdentityLine, } from "../../agents/identity-file.js"; import { resolveAgentIdentity } from "../../agents/identity.js"; +import { resolveLegacyInheritedAuthAgentId } from "../../agents/legacy-inherited-auth-dir.js"; import { prepareLegacyWorkspaceStateReset, removeLegacyWorkspaceStateForReset, @@ -1028,13 +1029,25 @@ export const agentsHandlers: GatewayRequestHandlers = { respondAgentNotFound(respond, agentId); return; } - if (agentId === resolveDefaultAgentId(cfg)) { + if (agentId === tryResolveSoleAgentId(cfg)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, - `Agent "${agentId}" is the default and cannot be deleted. Reassign default first.`, + `Agent "${agentId}" is the only configured agent and cannot be deleted.`, + ), + ); + return; + } + if (agentId === normalizeAgentId(resolveLegacyInheritedAuthAgentId(cfg))) { + // H2-2 owns credential relocation; deleting this directory first destroys the shared store. + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Agent "${agentId}" owns inherited credentials through agents.defaults.authInheritance.agentId and cannot be deleted. Relocate those credentials, then re-point or remove that binding before retrying.`, ), ); return; @@ -1049,9 +1062,12 @@ export const agentsHandlers: GatewayRequestHandlers = { if (!configured && (!lockedJournal || lockedJournal.cleanupCompleted)) { throw new AgentConfigPreconditionError(`agent "${agentId}" not found`); } - if (agentId === resolveDefaultAgentId(lockedConfig)) { + if (agentId === tryResolveSoleAgentId(lockedConfig)) { + throw new AgentConfigPreconditionError(`agent "${agentId}" is the only configured agent`); + } + if (agentId === normalizeAgentId(resolveLegacyInheritedAuthAgentId(lockedConfig))) { throw new AgentConfigPreconditionError( - `agent "${agentId}" is the default; reassign default first`, + `agent "${agentId}" owns agents.defaults.authInheritance.agentId; relocate credentials and re-point it first`, ); } if (configured && lockedJournal?.cleanupCompleted) { diff --git a/src/gateway/server-methods/artifacts-base64.ts b/src/gateway/server-methods/artifacts-base64.ts new file mode 100644 index 000000000000..176a9d398a83 --- /dev/null +++ b/src/gateway/server-methods/artifacts-base64.ts @@ -0,0 +1,100 @@ +export type ArtifactBase64Payload = { + data?: string; + sizeBytes: number; +}; + +export function mimeFromDataUrl(value: string): string | undefined { + const match = /^data:([^;,]+)(?:;[^,]*)?,/i.exec(value.trim()); + return match?.[1]?.toLowerCase(); +} + +export function base64FromDataUrl(value: string): string | undefined { + const trimmed = value.trim(); + const commaIndex = trimmed.indexOf(","); + if (commaIndex < 0 || trimmed.slice(0, 5).toLowerCase() !== "data:") { + return undefined; + } + const metadata = trimmed.slice(0, commaIndex).toLowerCase(); + if (!metadata.includes(";base64")) { + return undefined; + } + return trimmed.slice(commaIndex + 1); +} + +function isBase64Whitespace(value: string): boolean { + return value === " " || value === "\n" || value === "\r" || value === "\t"; +} + +function isArtifactBase64DataChar(value: string): boolean { + const code = value.charCodeAt(0); + return ( + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + (code >= 0x30 && code <= 0x39) || + value === "+" || + value === "/" || + value === "-" || + value === "_" + ); +} + +function normalizeArtifactBase64Char(value: string): string { + if (value === "-") { + return "+"; + } + if (value === "_") { + return "/"; + } + return value; +} + +export function readArtifactBase64Payload( + value: string | undefined, + opts: { includeData: boolean }, +): ArtifactBase64Payload | undefined { + if (!value) { + return undefined; + } + let encodedLength = 0; + let padding = 0; + let sawPadding = false; + let data = opts.includeData ? "" : undefined; + for (const char of value) { + if (isBase64Whitespace(char)) { + continue; + } + if (char === "=") { + padding += 1; + if (padding > 2) { + return undefined; + } + sawPadding = true; + encodedLength += 1; + if (data !== undefined) { + data += char; + } + continue; + } + if (sawPadding || !isArtifactBase64DataChar(char)) { + return undefined; + } + encodedLength += 1; + if (data !== undefined) { + data += normalizeArtifactBase64Char(char); + } + } + if (encodedLength === 0) { + return undefined; + } + const remainder = encodedLength % 4; + if ((padding > 0 && remainder !== 0) || remainder === 1) { + return undefined; + } + if (data !== undefined && padding === 0 && remainder > 0) { + data += "=".repeat(4 - remainder); + } + return { + ...(data !== undefined ? { data } : {}), + sizeBytes: Math.max(0, Math.floor((encodedLength * 3) / 4) - padding), + }; +} diff --git a/src/gateway/server-methods/artifacts.test-support.ts b/src/gateway/server-methods/artifacts.test-support.ts new file mode 100644 index 000000000000..980b1ae2f38f --- /dev/null +++ b/src/gateway/server-methods/artifacts.test-support.ts @@ -0,0 +1,93 @@ +import { expect } from "vitest"; +import { expectRecordFields } from "../test-helpers.assertions.js"; + +type ResponderCalls = Array<{ ok: boolean; payload?: unknown; error?: unknown }>; +type ArtifactListPayload = { artifacts?: Array> }; + +export function runtimeContext(config: Record) { + return { getRuntimeConfig: () => config }; +} + +export function expectOkPayload(calls: ResponderCalls): unknown { + expect(calls[0]?.ok).toBe(true); + return calls[0]?.payload; +} + +export function expectArtifactList(calls: ResponderCalls): ArtifactListPayload { + return expectOkPayload(calls) as ArtifactListPayload; +} + +export function expectFirstArtifact(calls: ResponderCalls): Record | undefined { + const payload = expectArtifactList(calls); + return payload.artifacts?.[0]; +} + +export function expectErrorDetails(calls: ResponderCalls): Record | undefined { + expect(calls[0]?.ok).toBe(false); + return calls[0] ? (calls[0].error as { details?: Record }).details : undefined; +} + +export function assistantImageMessage(params: { + data?: string; + alt: string; + seq?: number; + runId?: string; + taskId?: string; +}) { + return { + role: "assistant", + content: [{ type: "image", data: params.data ?? "aGVsbG8=", alt: params.alt }], + __openclaw: { + seq: params.seq ?? 2, + ...(params.runId ? { runId: params.runId } : {}), + ...(params.taskId ? { messageTaskId: params.taskId } : {}), + }, + }; +} + +export function assistantFileMessage(params: { + data?: string; + title: string; + seq?: number; + runId?: string; + taskId?: string; +}) { + return { + role: "assistant", + content: [ + { + type: "file", + data: params.data ?? "aGVsbG8=", + mimeType: "text/plain", + title: params.title, + }, + ], + __openclaw: { + seq: params.seq ?? 2, + ...(params.runId ? { runId: params.runId } : {}), + ...(params.taskId ? { taskId: params.taskId } : {}), + }, + }; +} + +export function resultImageMessage() { + return { + role: "assistant", + content: [ + { type: "text", text: "see attached" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png", alt: "result.png" }, + ], + __openclaw: { seq: 2 }, + }; +} + +export function requireNonEmptyString(value: unknown, message: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(message); + } + return value; +} + +export function expectFields(value: unknown, expected: Record): void { + expectRecordFields(value, "fields", expected); +} diff --git a/src/gateway/server-methods/artifacts.test.ts b/src/gateway/server-methods/artifacts.test.ts index dd9bb3f10326..5a63ed74c195 100644 --- a/src/gateway/server-methods/artifacts.test.ts +++ b/src/gateway/server-methods/artifacts.test.ts @@ -1,8 +1,20 @@ // Artifact method tests cover collection from transcript messages, run/task // session lookup, list/get/download responses, and validation errors. import { beforeEach, describe, expect, it, vi } from "vitest"; -import { expectRecordFields } from "../test-helpers.assertions.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import { artifactsHandlers } from "./artifacts.js"; +import { + assistantFileMessage, + assistantImageMessage, + expectArtifactList, + expectErrorDetails, + expectFields, + expectFirstArtifact, + expectOkPayload, + requireNonEmptyString, + resultImageMessage, + runtimeContext, +} from "./artifacts.test-support.js"; const hoisted = vi.hoisted(() => ({ getTaskSessionLookupByIdForStatus: vi.fn(), @@ -68,8 +80,7 @@ function createResponder() { } type ArtifactMethod = "artifacts.list" | "artifacts.get" | "artifacts.download"; -type ResponderCalls = ReturnType["calls"]; -type ArtifactListPayload = { artifacts?: Array> }; +type ArtifactResponderCalls = ReturnType["calls"]; async function invokeArtifactHandler( method: ArtifactMethod, @@ -112,102 +123,8 @@ async function downloadArtifact( return await invokeArtifactHandler("artifacts.download", params, options); } -function runtimeContext(config: Record) { - return { getRuntimeConfig: () => config }; -} - -function expectOkPayload(calls: ResponderCalls): unknown { - expect(calls[0]?.ok).toBe(true); - return calls[0]?.payload; -} - -function expectArtifactList(calls: ResponderCalls): ArtifactListPayload { - return expectOkPayload(calls) as ArtifactListPayload; -} - -function expectFirstArtifact(calls: ResponderCalls): Record | undefined { - const payload = expectArtifactList(calls); - return payload.artifacts?.[0]; -} - -function expectErrorDetails(calls: ResponderCalls): Record | undefined { - expect(calls[0]?.ok).toBe(false); - const error = calls[0]?.error as { details?: Record }; - return error.details; -} - -function assistantImageMessage(params: { - data?: string; - alt: string; - seq?: number; - runId?: string; - taskId?: string; -}) { - return { - role: "assistant", - content: [{ type: "image", data: params.data ?? "aGVsbG8=", alt: params.alt }], - __openclaw: { - seq: params.seq ?? 2, - ...(params.runId ? { runId: params.runId } : {}), - ...(params.taskId ? { messageTaskId: params.taskId } : {}), - }, - }; -} - -function assistantFileMessage(params: { - data?: string; - title: string; - seq?: number; - runId?: string; - taskId?: string; -}) { - return { - role: "assistant", - content: [ - { - type: "file", - data: params.data ?? "aGVsbG8=", - mimeType: "text/plain", - title: params.title, - }, - ], - __openclaw: { - seq: params.seq ?? 2, - ...(params.runId ? { runId: params.runId } : {}), - ...(params.taskId ? { taskId: params.taskId } : {}), - }, - }; -} - -function resultImageMessage() { - return { - role: "assistant", - content: [ - { type: "text", text: "see attached" }, - { - type: "image", - data: "aGVsbG8=", - mimeType: "image/png", - alt: "result.png", - }, - ], - __openclaw: { seq: 2 }, - }; -} - -function requireNonEmptyString(value: unknown, message: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new Error(message); - } - return value; -} - -function expectFields(value: unknown, expected: Record): void { - expectRecordFields(value, "fields", expected); -} - function expectArtifactScopeNotFound( - calls: ResponderCalls, + calls: ArtifactResponderCalls, params: { message?: string } = {}, ): void { expect(calls[0]?.ok).toBe(false); @@ -279,7 +196,10 @@ describe("artifacts RPC handlers", () => { it("applies agentId to direct sessionKey aliases", async () => { const { calls } = await listArtifacts( { sessionKey: "main", agentId: "work" }, - { id: "session-alias-agent-scope" }, + { + id: "session-alias-agent-scope", + context: runtimeContext({ agents: { list: [{ id: "main" }, { id: "work" }] } }), + }, ); expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:work:main"); @@ -302,6 +222,26 @@ describe("artifacts RPC handlers", () => { expectFields(expectFirstArtifact(calls), { sessionKey: "agent:work:primary" }); }); + it("loads a bare artifact session through the persisted fixed-store owner", async () => { + const { calls } = await listArtifacts( + { sessionKey: "global" }, + { + id: "session-persisted-owner", + context: runtimeContext({ + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + }), + }, + ); + + expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "ops" }); + expectFields(expectFirstArtifact(calls), { sessionKey: "global" }); + }); + it("preserves agent scope when loading global-scope run artifacts", async () => { hoisted.resolveSessionKeyForRun.mockReturnValue("global"); mockedMessages([assistantFileMessage({ title: "out.txt", runId: "run-global" })]); @@ -324,7 +264,54 @@ describe("artifacts RPC handlers", () => { expectFields(expectFirstArtifact(calls), { sessionKey: "global", runId: "run-global" }); }); - it("preserves inferred task agent scope when loading global-scope task artifacts", async () => { + it("uses the run row owner before default selection", async () => { + hoisted.resolveSessionKeyForRun.mockReturnValue("agent:research:main"); + mockedMessages([assistantFileMessage({ title: "out.txt", runId: "run-owned" })]); + + const { calls } = await listArtifacts( + { runId: "run-owned" }, + { + context: runtimeContext({ + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }), + }, + ); + + expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-owned", {}); + expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:research:main"); + expect(calls[0]?.ok).toBe(true); + }); + + it("translates run lookup selection-required into INVALID_REQUEST", async () => { + hoisted.resolveSessionKeyForRun.mockImplementation(() => { + throw new AgentSelectionRequiredError(["ops", "research"], { + surface: "artifact run", + hint: "Pass agentId to select a configured agent.", + }); + }); + + const { calls } = await listArtifacts( + { runId: "run-ambiguous" }, + { + context: runtimeContext({ + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }), + }, + ); + + expect(calls[0]).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("agent") }, + }); + }); + + it("uses the compatibility owner instead of the executor for a global task requester", async () => { hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({ agentId: "work", requesterSessionKey: "global", @@ -343,10 +330,57 @@ describe("artifacts RPC handlers", () => { }, ); - expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "work" }); + expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "main" }); expectFields(expectFirstArtifact(calls), { sessionKey: "global", taskId: "task-global" }); }); + it("returns typed selection-required instead of adopting the task executor", async () => { + hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({ + agentId: "work", + requesterSessionKey: "global", + ownerKey: "global", + }); + const { calls } = await listArtifacts( + { taskId: "task-global" }, + { + context: runtimeContext({ + session: { scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "work" }], + }, + }), + }, + ); + + expect(calls[0]).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("agent") }, + }); + expect(hoisted.loadSessionEntry).not.toHaveBeenCalled(); + }); + + it("translates a keyless task selection failure into INVALID_REQUEST", async () => { + hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({ runId: "run-keyless" }); + + const { calls } = await listArtifacts( + { taskId: "task-keyless" }, + { + context: runtimeContext({ + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }), + }, + ); + + expect(calls[0]).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("agent") }, + }); + }); + it("gets and downloads an inline artifact", async () => { const listed = await listArtifacts({ sessionKey: "agent:main:main" }, { id: "list-inline" }); const listedPayload = expectArtifactList(listed.calls); @@ -414,6 +448,8 @@ describe("artifacts RPC handlers", () => { }); expect(hoisted.resolveManagedArtifactDownload).toHaveBeenCalledWith({ sessionKey: "agent:main:main", + agentId: "main", + defaultAgentId: "main", artifactId, }); }); @@ -559,9 +595,7 @@ describe("artifacts RPC handlers", () => { mockedMessages([assistantImageMessage({ alt: "run-result.png", runId: "run-1" })]); const { calls } = await listArtifacts({ runId: "run-1" }, { id: "4" }); - expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-1", { - agentId: "main", - }); + expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-1", {}); expectFields(expectFirstArtifact(calls), { runId: "run-1" }); }); diff --git a/src/gateway/server-methods/artifacts.ts b/src/gateway/server-methods/artifacts.ts index c5cbe2289674..387a7df99948 100644 --- a/src/gateway/server-methods/artifacts.ts +++ b/src/gateway/server-methods/artifacts.ts @@ -13,7 +13,9 @@ import { validateArtifactsGetParams, validateArtifactsListParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeAgentId, @@ -28,13 +30,22 @@ import { resolveManagedOutgoingMediaUrlDownload, } from "../managed-image-attachments.js"; import { resolveSessionKeyForRun } from "../server-session-key.js"; +import { + resolveRequestedSessionAgentId, + tryResolveSessionCompatibilityOwnerAgentId, +} from "../session-request-agent.js"; import { resolveSessionStoreAgentId, - resolveSessionStoreKey, resolveStoredSessionKeyForAgentStore, } from "../session-store-key.js"; import { visitSessionMessagesAsync } from "../session-transcript-readers.js"; import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; +import { + type ArtifactBase64Payload, + base64FromDataUrl, + mimeFromDataUrl, + readArtifactBase64Payload, +} from "./artifacts-base64.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -57,16 +68,28 @@ type ArtifactCollectionOptions = { downloadArtifactId?: string; }; -type ArtifactBase64Payload = { - data?: string; - sizeBytes: number; -}; - type ResolvedArtifactSession = { sessionKey: string; agentId?: string; }; +function admitArtifactQuery( + query: T, + cfg: OpenClawConfig | undefined, + respond: RespondFn, +): T | undefined { + const sessionKey = asNonEmptyString(query.sessionKey); + if (!sessionKey || !cfg) { + return query; + } + const owner = resolveRequestedSessionAgentId(cfg, sessionKey, query.agentId); + if (!owner.ok) { + respond(false, undefined, owner.error); + return undefined; + } + return { ...query, agentId: owner.agentId }; +} + function artifactError(type: string, message: string, details?: Record) { return errorShape(ErrorCodes.INVALID_REQUEST, message, { details: { @@ -76,7 +99,7 @@ function artifactError(type: string, message: string, details?: Record= 0x41 && code <= 0x5a) || - (code >= 0x61 && code <= 0x7a) || - (code >= 0x30 && code <= 0x39) || - value === "+" || - value === "/" || - value === "-" || - value === "_" - ); -} - -function normalizeArtifactBase64Char(value: string): string { - if (value === "-") { - return "+"; - } - if (value === "_") { - return "/"; - } - return value; -} - -function readArtifactBase64Payload( - value: string | undefined, - opts: { includeData: boolean }, -): ArtifactBase64Payload | undefined { - if (!value) { - return undefined; - } - let encodedLength = 0; - let padding = 0; - let sawPadding = false; - let data = opts.includeData ? "" : undefined; - for (const char of value) { - if (isBase64Whitespace(char)) { - continue; - } - if (char === "=") { - padding += 1; - if (padding > 2) { - return undefined; - } - sawPadding = true; - encodedLength += 1; - if (data !== undefined) { - data += char; - } - continue; - } - if (sawPadding || !isArtifactBase64DataChar(char)) { - return undefined; - } - encodedLength += 1; - if (data !== undefined) { - data += normalizeArtifactBase64Char(char); - } - } - if (encodedLength === 0) { - return undefined; - } - const remainder = encodedLength % 4; - if ((padding > 0 && remainder !== 0) || remainder === 1) { - return undefined; - } - if (data !== undefined && padding === 0 && remainder > 0) { - data += "=".repeat(4 - remainder); - } - return { - ...(data !== undefined ? { data } : {}), - sizeBytes: Math.max(0, Math.floor((encodedLength * 3) / 4) - padding), - }; -} - function mediaUrlValue(value: unknown): string | undefined { if (typeof value === "string") { return asNonEmptyString(value); @@ -467,8 +397,16 @@ function resolveQuerySession( return { sessionKey, ...(query.agentId ? { agentId: query.agentId } : {}) }; } if (query.runId) { - const agentId = query.agentId ?? resolveDefaultAgentId(cfg ?? {}); - const sessionKey = resolveSessionKeyForRun(query.runId, { agentId }); + // A live run context can resolve its own agent-scoped key. Do not force an + // unrelated default-agent selection before consulting that authoritative row. + const sessionKey = resolveSessionKeyForRun( + query.runId, + query.agentId ? { agentId: query.agentId } : {}, + ); + const agentId = + query.agentId ?? + resolveArtifactSessionAgentId(sessionKey, cfg) ?? + resolveSessionAgentId({ config: cfg }); const scopedSessionKey = resolveScopedArtifactSessionKey(sessionKey, agentId, cfg); return scopedSessionKey ? { sessionKey: scopedSessionKey, agentId } : undefined; } @@ -476,12 +414,15 @@ function resolveQuerySession( const task = getTaskSessionLookupByIdForStatus(query.taskId); const requesterSessionKey = asNonEmptyString(task?.requesterSessionKey); const ownerAgentId = parseAgentSessionKey(task?.ownerKey)?.agentId; + const persistedRequesterOwner = requesterSessionKey + ? resolvePersistedSessionStoreOwnerForKey(cfg ?? {}, requesterSessionKey) + : { kind: "none" as const }; const requesterAgentId = asNonEmptyString(task?.requesterAgentId) ?? ownerAgentId ?? - (requesterSessionKey === "global" - ? undefined - : resolveRequesterSessionAgentId(requesterSessionKey, cfg)); + (persistedRequesterOwner.kind === "configured" + ? persistedRequesterOwner.agentId + : resolveArtifactSessionAgentId(requesterSessionKey, cfg)); const taskAgentId = asNonEmptyString(task?.agentId) ?? requesterAgentId; if ( query.agentId && @@ -493,7 +434,11 @@ function resolveQuerySession( if (requesterSessionKey) { // task.agentId identifies the executor. requesterAgentId keeps global // requester transcripts in the correct agent store across restarts. - const sessionAgentId = requesterAgentId ?? taskAgentId ?? resolveDefaultAgentId(cfg ?? {}); + const sessionAgentId = + requesterAgentId ?? resolveArtifactSessionAgentId(requesterSessionKey, cfg); + if (!sessionAgentId) { + return undefined; + } const scopedSessionKey = resolveScopedArtifactSessionKey( requesterSessionKey, sessionAgentId, @@ -503,7 +448,7 @@ function resolveQuerySession( ? { sessionKey: scopedSessionKey, agentId: sessionAgentId } : undefined; } - const agentId = query.agentId ?? taskAgentId ?? resolveDefaultAgentId(cfg ?? {}); + const agentId = query.agentId ?? taskAgentId ?? resolveSessionAgentId({ config: cfg }); const runId = asNonEmptyString(task?.runId); const sessionKey = runId ? resolveSessionKeyForRun(runId, { agentId }) : undefined; const scopedSessionKey = resolveScopedArtifactSessionKey(sessionKey, agentId, cfg); @@ -512,6 +457,12 @@ function resolveQuerySession( return undefined; } +class ArtifactSessionResolutionError extends Error { + constructor(readonly shape: ReturnType) { + super(shape.message); + } +} + /** Loads artifacts from the transcript selected by sessionKey, runId, or taskId. */ async function loadArtifacts( query: ArtifactQuery, @@ -523,10 +474,9 @@ async function loadArtifacts( return { artifacts: [] }; } const { sessionKey } = resolved; - const scopedGlobalAgentId = - cfg?.session?.scope === "global" && sessionKey === "global" ? resolved.agentId : undefined; - const { storePath, entry } = scopedGlobalAgentId - ? loadGatewaySessionEntryReadOnly(sessionKey, { agentId: scopedGlobalAgentId }) + const unscopedAgentId = parseAgentSessionKey(sessionKey) ? undefined : resolved.agentId; + const { storePath, entry } = unscopedAgentId + ? loadGatewaySessionEntryReadOnly(sessionKey, { agentId: unscopedAgentId }) : loadGatewaySessionEntryReadOnly(sessionKey); const sessionId = entry?.sessionId; if (!sessionId || !storePath) { @@ -580,6 +530,25 @@ function requireQueryable(params: ArtifactQuery, respond: RespondFn): boolean { return false; } +async function runArtifactSessionOperation( + respond: RespondFn, + operation: () => Promise | T, +): Promise<{ ok: true; value: T } | { ok: false }> { + try { + return { ok: true, value: await operation() }; + } catch (error) { + if (error instanceof ArtifactSessionResolutionError) { + respond(false, undefined, error.shape); + return { ok: false }; + } + if (error instanceof AgentSelectionRequiredError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); + return { ok: false }; + } + throw error; + } +} + async function findArtifact( params: ArtifactsGetParams, cfg?: OpenClawConfig, @@ -609,9 +578,18 @@ export const artifactsHandlers: GatewayRequestHandlers = { if (!requireQueryable(params, respond)) { return; } - const { artifacts, sessionKey } = await loadArtifacts(params, context.getRuntimeConfig?.(), { - includeDownloadData: false, - }); + const cfg = context.getRuntimeConfig?.(); + const admittedQuery = admitArtifactQuery(params, cfg, respond); + if (!admittedQuery) { + return; + } + const loaded = await runArtifactSessionOperation(respond, () => + loadArtifacts(admittedQuery, cfg, { includeDownloadData: false }), + ); + if (!loaded.ok) { + return; + } + const { artifacts, sessionKey } = loaded.value; if (!sessionKey && (params.runId || params.taskId)) { respond( false, @@ -629,9 +607,18 @@ export const artifactsHandlers: GatewayRequestHandlers = { if (!requireQueryable(params, respond)) { return; } - const { artifact } = await findArtifact(params, context.getRuntimeConfig?.(), { - includeDownloadData: false, - }); + const cfg = context.getRuntimeConfig?.(); + const admittedQuery = admitArtifactQuery(params, cfg, respond); + if (!admittedQuery) { + return; + } + const found = await runArtifactSessionOperation(respond, () => + findArtifact(admittedQuery, cfg, { includeDownloadData: false }), + ); + if (!found.ok) { + return; + } + const { artifact } = found.value; if (!artifact) { respond( false, @@ -653,16 +640,32 @@ export const artifactsHandlers: GatewayRequestHandlers = { if (!requireQueryable(params, respond)) { return; } + const cfg = context.getRuntimeConfig?.(); + const admittedQuery = admitArtifactQuery(params, cfg, respond); + if (!admittedQuery) { + return; + } if ( - params.sessionKey && - !params.runId && - !params.taskId && + admittedQuery.sessionKey && + !admittedQuery.runId && + !admittedQuery.taskId && parseManagedOutgoingArtifactId(params.artifactId) ) { - const resolved = resolveQuerySession(params, context.getRuntimeConfig?.()); + const resolvedResult = await runArtifactSessionOperation(respond, () => + resolveQuerySession(admittedQuery, cfg), + ); + if (!resolvedResult.ok) { + return; + } + const resolved = resolvedResult.value; + const defaultAgentId = resolved + ? tryResolveSessionCompatibilityOwnerAgentId(cfg ?? {}, resolved.sessionKey) + : undefined; const managed = resolved ? await resolveManagedOutgoingMediaArtifactDownload({ sessionKey: resolved.sessionKey, + ...(resolved.agentId ? { agentId: resolved.agentId } : {}), + ...(defaultAgentId ? { defaultAgentId } : {}), artifactId: params.artifactId, }) : null; @@ -684,9 +687,13 @@ export const artifactsHandlers: GatewayRequestHandlers = { return; } } - const { artifact } = await findArtifact(params, context.getRuntimeConfig?.(), { - downloadArtifactId: params.artifactId, - }); + const found = await runArtifactSessionOperation(respond, () => + findArtifact(admittedQuery, cfg, { downloadArtifactId: params.artifactId }), + ); + if (!found.ok) { + return; + } + const { artifact } = found.value; if (!artifact) { respond( false, diff --git a/src/gateway/server-methods/attach.test.ts b/src/gateway/server-methods/attach.test.ts index 9686a17e3415..f547b2480d61 100644 --- a/src/gateway/server-methods/attach.test.ts +++ b/src/gateway/server-methods/attach.test.ts @@ -25,6 +25,17 @@ const grantOpts = (sessionKey: string, respond: ReturnType) => context: { getRuntimeConfig: () => ({}) }, }) as unknown as GatewayRequestHandlerOptions; +const grantWithAgentOpts = (agentId: string, respond: ReturnType) => + ({ + params: { agentId }, + respond, + context: { + getRuntimeConfig: () => ({ + agents: { ownership: "explicit", list: [{ id: agentId }, { id: "other" }] }, + }), + }, + }) as unknown as GatewayRequestHandlerOptions; + describe("attach gateway methods", () => { beforeEach(() => { loadSessionEntryMock.mockReset(); @@ -62,42 +73,17 @@ describe("attach gateway methods", () => { expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method"); }); - it("preserves explicit ownership only for canonical global sessions", async () => { + it("uses an explicit agent for an omitted session key", async () => { const respond = vi.fn(); await expectDefined( attachHandlers["attach.grant"], 'attachHandlers["attach.grant"] test invariant', - )({ - params: { sessionKey: "global", agentId: "ops" }, - respond, - context: { getRuntimeConfig: () => ({}) }, - } as unknown as GatewayRequestHandlerOptions); + )(grantWithAgentOpts("research", respond)); - const grant = resolveAttachGrant( - (expectDefined(respond.mock.calls[0], "respond call invariant")[1] as { token: string }) - .token, - ); - expect(grant).toMatchObject({ sessionKey: "global", agentId: "ops" }); - - const scopedRespond = vi.fn(); - await expectDefined( - attachHandlers["attach.grant"], - 'attachHandlers["attach.grant"] test invariant', - )({ - params: { sessionKey: "agent:main:attach-method", agentId: "ops" }, - respond: scopedRespond, - context: { getRuntimeConfig: () => ({}) }, - } as unknown as GatewayRequestHandlerOptions); - const scopedGrant = resolveAttachGrant( - ( - expectDefined(scopedRespond.mock.calls[0], "scoped respond call invariant")[1] as { - token: string; - } - ).token, - ); - expect(scopedGrant?.agentId).toBeUndefined(); + expect(respond.mock.calls[0]?.[0]).toBe(true); + const result = respond.mock.calls[0]?.[1] as { sessionKey?: string } | undefined; + expect(result?.sessionKey).toBe("agent:research:main"); }); - it("rejects attach grants for reserved harness sessions", async () => { const respond = vi.fn(); await expectDefined( diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts index 4b60bc29dfed..d90021df6813 100644 --- a/src/gateway/server-methods/attach.ts +++ b/src/gateway/server-methods/attach.ts @@ -2,8 +2,8 @@ import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coer import { asRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveMainSessionKey } from "../../config/sessions.js"; import { resolveSessionEntryAccessTarget } from "../../config/sessions/session-accessor.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, isAgentHarnessSessionKey, @@ -15,22 +15,38 @@ import { createMcpAttachGrantServerConfig, getActiveMcpLoopbackRuntime, } from "../mcp-http.loopback-runtime.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveSessionStoreKey } from "../session-utils.js"; import type { GatewayRequestHandlers } from "./types.js"; export const attachHandlers: GatewayRequestHandlers = { "attach.grant": async ({ params, respond, context }) => { const grantParams = asRecord(params); const cfg = context.getRuntimeConfig(); - const sessionKey = - normalizeOptionalString(grantParams.sessionKey) ?? resolveMainSessionKey(cfg); - const agentId = - sessionKey === "global" ? normalizeOptionalString(grantParams.agentId) : undefined; - const harnessEntry = isAgentHarnessSessionKey(sessionKey) - ? resolveSessionEntryAccessTarget({ cfg, sessionKey }).entry + const requestedSessionKey = normalizeOptionalString(grantParams.sessionKey) ?? "main"; + const requestedAgent = resolveRequestedSessionAgentId( + cfg, + requestedSessionKey, + normalizeOptionalString(grantParams.agentId), + ); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const storageSessionKey = resolveSessionStoreKey({ + cfg, + sessionKey: requestedSessionKey, + storeAgentId: requestedAgent.agentId, + }); + const sessionKey = parseAgentSessionKey(storageSessionKey) + ? storageSessionKey + : `agent:${requestedAgent.agentId}:${storageSessionKey}`; + const harnessEntry = isAgentHarnessSessionKey(storageSessionKey) + ? resolveSessionEntryAccessTarget({ cfg, sessionKey: storageSessionKey }).entry : undefined; if ( - isAgentHarnessSessionKey(sessionKey) && - (!harnessEntry || isAgentHarnessSessionStoreEntryProtected(sessionKey, harnessEntry)) + isAgentHarnessSessionKey(storageSessionKey) && + (!harnessEntry || isAgentHarnessSessionStoreEntryProtected(storageSessionKey, harnessEntry)) ) { respond( false, @@ -51,7 +67,6 @@ export const attachHandlers: GatewayRequestHandlers = { } const grant = mintAttachGrant({ sessionKey, - ...(agentId ? { agentId } : {}), ttlMs: asPositiveFiniteNumber(grantParams.ttlMs), }); respond(true, { diff --git a/src/gateway/server-methods/board.test-support.ts b/src/gateway/server-methods/board.test-support.ts index 952a0822378e..865ea5b03d7a 100644 --- a/src/gateway/server-methods/board.test-support.ts +++ b/src/gateway/server-methods/board.test-support.ts @@ -68,7 +68,10 @@ export function createBoardHarness( context: { broadcast, getMcpAppSandboxPort: () => 18790, - getRuntimeConfig: () => ({ mcp: { apps: { enabled: true } } }), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main" }] }, + mcp: { apps: { enabled: true } }, + }), ...contextOverrides, } as unknown as GatewayRequestContext, }); diff --git a/src/gateway/server-methods/board.test.ts b/src/gateway/server-methods/board.test.ts index 4635a10e0795..202bd8276542 100644 --- a/src/gateway/server-methods/board.test.ts +++ b/src/gateway/server-methods/board.test.ts @@ -84,6 +84,38 @@ describe("board gateway methods", () => { expect(store.listSessionsWithBoards()).toEqual([]); }); + it("scopes bare boards by explicit owner and rejects ambiguous ownerless requests", async () => { + const { invoke, store } = createHarness(undefined, undefined, undefined, { + getRuntimeConfig: () => ({ + agents: { ownership: "explicit", list: [{ id: "main" }, { id: "work" }] }, + }), + }); + const work = await invoke("board.widget.put", { + sessionKey: "global", + agentId: "work", + name: "owner", + content: { kind: "html", html: "work" }, + }); + expect(work).toHaveBeenCalledWith( + true, + expect.objectContaining({ sessionKey: "agent:work:global" }), + ); + expect(store.listSessionsWithBoards()).toContain("agent:work:global"); + + const main = await invoke("board.get", { sessionKey: "global", agentId: "main" }); + expect(main).toHaveBeenCalledWith( + true, + expect.objectContaining({ sessionKey: "agent:main:global", revision: 0 }), + ); + + const ambiguous = await invoke("board.get", { sessionKey: "global" }); + expect(ambiguous).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + }); + it("adds fresh frame URLs only to admitted HTML widgets on board.get", async () => { const { invoke, store } = createHarness(); await invoke("board.widget.put", { diff --git a/src/gateway/server-methods/board.ts b/src/gateway/server-methods/board.ts index ff9a75ced2b6..3f3a1e28cba5 100644 --- a/src/gateway/server-methods/board.ts +++ b/src/gateway/server-methods/board.ts @@ -5,6 +5,7 @@ import { type BoardActionParams, type BoardDataReadParams, type BoardEventParams, + type BoardGetParams, type BoardPromptAuthorizeParams, type BoardWidgetAppViewParams, type BoardUpdateParams, @@ -50,6 +51,9 @@ import { resolveMcpAppAllowedToolNames, } from "../mcp-app-operations.js"; import { mintMcpAppViewFromTranscript } from "../mcp-app-reconstruction.js"; +import { sessionObserverScopeKey } from "../session-observer-model.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveSessionStoreKey } from "../session-store-key.js"; import type { GatewayRequestHandlers } from "./types.js"; type NoticeAppender = typeof appendBoardEventNotice; @@ -100,6 +104,25 @@ function respondBoardError( respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(error))); } +function resolveBoardSessionKey( + params: { sessionKey: string; agentId?: string | undefined }, + context: Parameters[0]["context"], + respond: Parameters[0]["respond"], +): string | undefined { + const cfg = context.getRuntimeConfig(); + const requested = resolveRequestedSessionAgentId(cfg, params.sessionKey, params.agentId); + if (!requested.ok) { + respond(false, undefined, requested.error); + return undefined; + } + const canonicalKey = resolveSessionStoreKey({ + cfg, + sessionKey: params.sessionKey, + storeAgentId: requested.agentId, + }); + return sessionObserverScopeKey(canonicalKey, requested.agentId); +} + function assertCapabilityParamsSize( params: Record, capability: "action" | "data binding", @@ -135,9 +158,12 @@ export function createBoardHandlers( invalidParams("board.get", validateBoardGetParams.errors, respond); return; } - const { snapshot, htmlViewMetadata } = store.getSnapshotWithHtmlViewMetadata( - params.sessionKey, - ); + const boardParams = params as BoardGetParams; + const boardSessionKey = resolveBoardSessionKey(boardParams, context, respond); + if (!boardSessionKey) { + return; + } + const { snapshot, htmlViewMetadata } = store.getSnapshotWithHtmlViewMetadata(boardSessionKey); let sandboxPort = context.getMcpAppSandboxPort?.(); let sandboxOrigin: string | undefined; let sandboxOriginResolved = false; @@ -193,7 +219,11 @@ export function createBoardHandlers( } try { const boardParams = params as BoardUpdateParams; - const snapshot = store.applyOps(boardParams.sessionKey, boardParams.ops); + const boardSessionKey = resolveBoardSessionKey(boardParams, context, respond); + if (!boardSessionKey) { + return; + } + const snapshot = store.applyOps(boardSessionKey, boardParams.ops); if (boardParams.ops.length > 0) { context.broadcast("board.changed", { sessionKey: snapshot.sessionKey, @@ -212,8 +242,16 @@ export function createBoardHandlers( } try { const requestParams = params as BoardWidgetPutParams; - const boardSessionKey = store.getSnapshot(requestParams.sessionKey).sessionKey; - const { declared: requestDeclared, ...requestWithoutDeclared } = requestParams; + const requestedBoardSessionKey = resolveBoardSessionKey(requestParams, context, respond); + if (!requestedBoardSessionKey) { + return; + } + const boardSessionKey = store.getSnapshot(requestedBoardSessionKey).sessionKey; + const { + agentId: _agentId, + declared: requestDeclared, + ...requestWithoutDeclared + } = requestParams; let content: BoardWidgetMaterializedPutParams["content"]; let declared = requestDeclared; if (requestParams.content.kind === "canvas-doc") { @@ -306,8 +344,12 @@ export function createBoardHandlers( } try { const boardParams = params as BoardWidgetGrantParams; + const boardSessionKey = resolveBoardSessionKey(boardParams, context, respond); + if (!boardSessionKey) { + return; + } const snapshot = store.grant( - boardParams.sessionKey, + boardSessionKey, boardParams.name, boardParams.decision, boardParams.revision, @@ -329,7 +371,11 @@ export function createBoardHandlers( } try { const boardParams = params as BoardWidgetAppViewParams; - const snapshot = store.getSnapshot(boardParams.sessionKey); + const boardSessionKey = resolveBoardSessionKey(boardParams, context, respond); + if (!boardSessionKey) { + return; + } + const snapshot = store.getSnapshot(boardSessionKey); const widget = snapshot.widgets.find((candidate) => candidate.name === boardParams.name); const document = store.readWidgetMcpApp(snapshot.sessionKey, boardParams.name); if ( @@ -377,7 +423,7 @@ export function createBoardHandlers( respondBoardError(error, respond); } }, - "board.event": ({ params, respond }) => { + "board.event": ({ params, respond, context }) => { if (!validateBoardEventParams(params)) { invalidParams("board.event", validateBoardEventParams.errors, respond); return; @@ -388,7 +434,11 @@ export function createBoardHandlers( "ticket" in boardParams ? resolveAuthorizedBoardWidgetView(store, boardParams.ticket) : (() => { - const snapshot = store.getSnapshot(boardParams.sessionKey); + const boardSessionKey = resolveBoardSessionKey(boardParams, context, respond); + if (!boardSessionKey) { + return undefined; + } + const snapshot = store.getSnapshot(boardSessionKey); const widget = snapshot.widgets.some( (candidate) => candidate.name === boardParams.widget, ); @@ -400,6 +450,9 @@ export function createBoardHandlers( } return { sessionKey: snapshot.sessionKey, name: boardParams.widget }; })(); + if (!identity) { + return; + } const appended = appendNotice({ sessionKey: identity.sessionKey, widget: identity.name, diff --git a/src/gateway/server-methods/chat-abort-authorization.ts b/src/gateway/server-methods/chat-abort-authorization.ts index 59439992b4cc..d71c06c0a1af 100644 --- a/src/gateway/server-methods/chat-abort-authorization.ts +++ b/src/gateway/server-methods/chat-abort-authorization.ts @@ -1,9 +1,8 @@ // Authorization and pending-run state transitions for chat cancellation. import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { normalizeAgentId } from "../../routing/session-key.js"; -import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js"; import type { ChatAbortControllerEntry } from "../chat-abort.js"; +import { chatRunBelongsToAgent, resolveChatRunOwnerAgentId } from "../chat-run-owner.js"; import { ADMIN_SCOPE } from "../method-scopes.js"; import { createChatAbortMarker } from "../server-chat-state.js"; import { pendingChatSendDedupeKey } from "../server-shared.js"; @@ -87,7 +86,7 @@ export function readPreRegisteredAgentDedupePayloadForSession(params: { runId: string; sessionKey: string; agentId?: string; - defaultAgentId: string; + defaultAgentId?: string; includeHidden?: boolean; }): PreRegisteredAgentDedupePayload | undefined { if (!params.entry?.ok) { @@ -119,17 +118,12 @@ export function readPreRegisteredAgentDedupePayloadForSession(params: { } const agentId = normalizeOptionalText(params.agentId)?.toLowerCase(); if (agentId) { - const parsed = parseAgentSessionKey(params.sessionKey); - const sessionAgentId = - params.sessionKey === "global" - ? resolveStoredGlobalRunAgentId( - normalizeUnknownText(payload.agentId), - params.defaultAgentId, - ) - : parsed?.agentId - ? normalizeAgentId(parsed.agentId) - : undefined; - if (sessionAgentId && sessionAgentId !== agentId) { + const sessionAgentId = resolveChatRunOwnerAgentId({ + agentId: normalizeUnknownText(payload.agentId), + sessionKey: params.sessionKey, + defaultAgentId: params.defaultAgentId, + }); + if (sessionAgentId !== agentId) { return undefined; } } @@ -190,13 +184,6 @@ function resolvePreRegisteredAgentDedupeKeys( return uniqueStrings(keys); } -export function resolveStoredGlobalRunAgentId( - agentId: string | undefined, - defaultAgentId: string, -): string { - return normalizeOptionalText(agentId)?.toLowerCase() ?? defaultAgentId.toLowerCase(); -} - export function writePreRegisteredAgentAbort(params: { context: GatewayRequestContext; runId: string; @@ -263,7 +250,7 @@ export function resolveAuthorizedPreRegisteredRunsForSessionKeys(params: { context: GatewayRequestContext; sessionKeys: Iterable; agentId?: string; - defaultAgentId: string; + defaultAgentId?: string; requester: ChatAbortRequester; keyPrefix: string; preserveSideRuns?: boolean; @@ -307,14 +294,15 @@ export function resolveAuthorizedPreRegisteredRunsForSessionKeys(params: { const agentId = normalizeOptionalText(params.agentId)?.toLowerCase(); if ( agentId && - run.sessionKey === "global" && - resolveStoredGlobalRunAgentId( - normalizeUnknownText(run.payload.agentId), - params.defaultAgentId, - ) !== agentId + !chatRunBelongsToAgent( + { + agentId: normalizeUnknownText(run.payload.agentId), + sessionKey: run.sessionKey, + defaultAgentId: params.defaultAgentId, + }, + agentId, + ) ) { - // Global keys are shared across agent stores; another agent's run is - // outside the selected global-agent scope. continue; } const requesterCanAbort = canRequesterAbortPreRegisteredRun(run.payload, params.requester); @@ -350,7 +338,7 @@ export function resolveAuthorizedRunsForSessionKeys(params: { sessionKeys: Iterable; sessionIds?: Iterable; agentId?: string; - defaultAgentId: string; + defaultAgentId?: string; requester: ChatAbortRequester; preserveSideRuns?: boolean; includeProtectedRuns?: boolean; @@ -385,11 +373,15 @@ export function resolveAuthorizedRunsForSessionKeys(params: { } if ( agentId && - active.sessionKey === "global" && - resolveStoredGlobalRunAgentId(active.agentId, params.defaultAgentId) !== agentId + !chatRunBelongsToAgent( + { + agentId: active.agentId, + sessionKey: active.sessionKey, + defaultAgentId: params.defaultAgentId, + }, + agentId, + ) ) { - // Global keys are shared across agent stores; another agent's run is - // outside the selected global-agent scope. continue; } matchedRunIds.push(runId); diff --git a/src/gateway/server-methods/chat-abort-handler.ts b/src/gateway/server-methods/chat-abort-handler.ts index e4dd5654da5c..2210cacf74f0 100644 --- a/src/gateway/server-methods/chat-abort-handler.ts +++ b/src/gateway/server-methods/chat-abort-handler.ts @@ -4,12 +4,16 @@ import { errorShape, validateChatAbortParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { abortChatRunById, type ChatAbortControllerEntry } from "../chat-abort.js"; import { abortQueuedChatTurnById, type QueuedChatTurnEntry } from "../chat-queued-turns.js"; +import { chatRunBelongsToAgent } from "../chat-run-owner.js"; import { pendingChatSendDedupeKey } from "../server-shared.js"; +import { + resolveRequestedSessionAgentId, + tryResolveSessionCompatibilityOwnerAgentId, +} from "../session-request-agent.js"; import { loadSessionEntry, resolveSessionStoreKey } from "../session-utils.js"; import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; import { @@ -17,7 +21,6 @@ import { canRequesterAbortPreRegisteredRun, readPreRegisteredAgentDedupePayloadForSession, resolveChatAbortRequester, - resolveStoredGlobalRunAgentId, writePreRegisteredAgentAbort, writePreRegisteredChatAbort, } from "./chat-abort-authorization.js"; @@ -63,18 +66,38 @@ export async function handleChatAbortRequestWithLifecycle( }; const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId); const abortCfg = context.getRuntimeConfig(); - const defaultAgentId = resolveDefaultAgentId(abortCfg); const parsedAbortSessionKey = parseAgentSessionKey(rawSessionKey); - const abortSessionResolvesGlobal = - resolveSessionStoreKey({ cfg: abortCfg, sessionKey: rawSessionKey }) === "global"; - const inferredGlobalAgentId = - !agentIdOverride && parsedAbortSessionKey && abortSessionResolvesGlobal + const compatibilityDefaultAgentId = tryResolveSessionCompatibilityOwnerAgentId( + abortCfg, + rawSessionKey, + ); + const inferredSessionAgentId = + !agentIdOverride && parsedAbortSessionKey ? normalizeAgentId(parsedAbortSessionKey.agentId) : undefined; - const abortAgentId = - agentIdOverride ?? - inferredGlobalAgentId ?? - (abortSessionResolvesGlobal ? defaultAgentId : undefined); + const bareSessionAgentResolution = !parsedAbortSessionKey + ? resolveRequestedSessionAgentId(abortCfg, rawSessionKey, agentIdOverride) + : undefined; + if (bareSessionAgentResolution && !bareSessionAgentResolution.ok) { + respond(false, undefined, bareSessionAgentResolution.error); + return; + } + const abortAgentId = parsedAbortSessionKey + ? (agentIdOverride ?? inferredSessionAgentId) + : bareSessionAgentResolution?.agentId; + if (!abortAgentId) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + rawSessionKey.trim().toLowerCase() === "global" + ? "agentId is required for global chat.abort when no compatibility owner exists" + : "agentId is required for unscoped chat.abort when no compatibility owner exists", + ), + ); + return; + } if ( agentIdOverride && parsedAbortSessionKey && @@ -90,14 +113,19 @@ export async function handleChatAbortRequestWithLifecycle( ); return; } - const canonicalAbortSessionKey = - abortAgentId && abortSessionResolvesGlobal ? "global" : rawSessionKey; - + const canonicalAbortSessionKey = resolveSessionStoreKey({ + cfg: abortCfg, + sessionKey: rawSessionKey, + storeAgentId: abortAgentId, + }); const ops = createChatAbortOps(context); const requester = resolveChatAbortRequester(client); - const sessionLoadOptions = abortAgentId ? { agentId: abortAgentId } : undefined; - const { entry: abortSessionEntry } = loadSessionEntry(rawSessionKey, sessionLoadOptions); + const sessionLoadOptions = { agentId: abortAgentId }; + const { entry: abortSessionEntry } = loadSessionEntry( + canonicalAbortSessionKey, + sessionLoadOptions, + ); const cancelWorkerRun = (sessionId = abortSessionEntry?.sessionId): string[] => requester.isAdmin ? cancelWorkerInferenceForSession({ context, sessionId, ...(runId ? { runId } : {}) }) @@ -115,7 +143,7 @@ export async function handleChatAbortRequestWithLifecycle( sessionKeyAliases: canonicalAbortSessionKey === rawSessionKey ? undefined : [rawSessionKey], agentId: abortAgentId, sessionId: abortSessionEntry?.sessionId, - defaultAgentId, + defaultAgentId: compatibilityDefaultAgentId, abortOrigin: "rpc", stopReason: "rpc", requester, @@ -130,7 +158,7 @@ export async function handleChatAbortRequestWithLifecycle( respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds }); return; } - const normalizedAgentIdOverride = abortAgentId?.toLowerCase(); + const normalizedAgentIdOverride = normalizeAgentId(abortAgentId); const authorizeRunTarget = (target: ChatAbortTarget): boolean => { if ( target.sessionKey !== rawSessionKey && @@ -145,9 +173,14 @@ export async function handleChatAbortRequestWithLifecycle( return false; } if ( - normalizedAgentIdOverride && - target.sessionKey === "global" && - resolveStoredGlobalRunAgentId(target.agentId, defaultAgentId) !== normalizedAgentIdOverride + !chatRunBelongsToAgent( + { + agentId: target.agentId, + sessionKey: target.sessionKey, + defaultAgentId: compatibilityDefaultAgentId, + }, + normalizedAgentIdOverride, + ) ) { respond( false, @@ -174,7 +207,7 @@ export async function handleChatAbortRequestWithLifecycle( runId, sessionKey, agentId: abortAgentId, - defaultAgentId, + defaultAgentId: compatibilityDefaultAgentId, includeHidden: true, }); if (payload) { diff --git a/src/gateway/server-methods/chat-abort-runtime.ts b/src/gateway/server-methods/chat-abort-runtime.ts index 819fb7bf184b..7e35f9088d60 100644 --- a/src/gateway/server-methods/chat-abort-runtime.ts +++ b/src/gateway/server-methods/chat-abort-runtime.ts @@ -66,10 +66,7 @@ export async function persistAbortedPartials(params: { return; } for (const snapshot of params.snapshots) { - const sessionLoadOptions = - params.sessionKey === "global" && snapshot.agentId - ? { agentId: snapshot.agentId } - : undefined; + const sessionLoadOptions = snapshot.agentId ? { agentId: snapshot.agentId } : undefined; const { cfg, storePath, entry } = loadSessionEntry(params.sessionKey, sessionLoadOptions); const sessionId = entry?.sessionId ?? snapshot.sessionId ?? snapshot.runId; const appended = await appendAssistantTranscriptMessage({ @@ -113,7 +110,7 @@ function resolveAuthorizedQueuedTurnsForSession(params: { sessionKeys: string[]; sessionId?: string; agentId?: string; - defaultAgentId: string; + defaultAgentId?: string; requester: ChatAbortRequester; }) { const matches = listQueuedChatTurnsForSession({ @@ -137,7 +134,7 @@ type SessionAbortOwnerParams = { sessionKeys: string[]; sessionId?: string; agentId?: string; - defaultAgentId: string; + defaultAgentId?: string; }; /** Authoritative active, pending, or queued Gateway owner for an exact session. */ @@ -207,7 +204,7 @@ export async function abortChatRunsForSessionKeyWithPartials(params: { agentId?: string; sessionId?: string; persistSessionKey?: string; - defaultAgentId: string; + defaultAgentId?: string; abortOrigin: AbortOrigin; stopReason?: string; requester: ChatAbortRequester; diff --git a/src/gateway/server-methods/chat-assistant-content.ts b/src/gateway/server-methods/chat-assistant-content.ts index 80b0f2c6f1ff..d73a05ed5ffe 100644 --- a/src/gateway/server-methods/chat-assistant-content.ts +++ b/src/gateway/server-methods/chat-assistant-content.ts @@ -12,6 +12,7 @@ import { cleanupManagedOutgoingMediaRecords, createManagedOutgoingMediaBlocks, } from "../managed-image-attachments.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { formatForLog } from "../ws-log.js"; import { hasRegisteredChatRunForSessionKey } from "./session-active-runs.js"; import type { GatewayRequestContext } from "./types.js"; @@ -269,7 +270,7 @@ export async function buildAssistantDisplayContentFromReplyPayloads(params: { for (const [groupIndex, mediaUrl] of mediaGroup.mediaUrls.entries()) { const mediaBlocks = await createManagedOutgoingMediaBlocks({ sessionKey: params.sessionKey, - ...(params.sessionKey === "global" && params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), mediaUrls: [mediaUrl], attachments: [mediaGroup.attachments[groupIndex] ?? {}], localRoots: params.managedMediaLocalRoots, @@ -427,20 +428,25 @@ export function hasManagedOutgoingAssistantContent( export function scheduleChatHistoryManagedMediaCleanup(params: { sessionKey: string; agentId?: string; + cfg: import("../../config/types.openclaw.js").OpenClawConfig; context: Pick; }) { - const cleanupKey = - params.sessionKey === "global" && params.agentId - ? `agent:${params.agentId}:global` - : params.sessionKey; + const cleanupKey = params.agentId + ? `agent:${params.agentId}:${params.sessionKey}` + : params.sessionKey; if (chatHistoryManagedMediaCleanupState.has(cleanupKey)) { return; } const pending = cleanupManagedOutgoingMediaRecords({ sessionKey: params.sessionKey, - ...(params.sessionKey === "global" && params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), hasActiveSessionRun: (sessionKey, agentId) => - hasRegisteredChatRunForSessionKey({ context: params.context, sessionKey, agentId }), + hasRegisteredChatRunForSessionKey({ + context: params.context, + sessionKey, + agentId, + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(params.cfg, sessionKey), + }), }) .then(() => undefined) .catch((error: unknown) => { diff --git a/src/gateway/server-methods/chat-broadcast.test.ts b/src/gateway/server-methods/chat-broadcast.test.ts index 00c8fd00e599..29a906129ddb 100644 --- a/src/gateway/server-methods/chat-broadcast.test.ts +++ b/src/gateway/server-methods/chat-broadcast.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js"; import type { GatewayRequestContext } from "./types.js"; @@ -134,3 +135,41 @@ describe("chat terminal broadcasts", () => { expect(deleteSpy).not.toHaveBeenCalled(); }); }); + +describe("global chat broadcast ownership", () => { + it("keeps the bare global subscription for its persisted fixed-store owner", () => { + const broadcast = vi.fn(); + const nodeSendToSession = vi.fn(); + const context = { + agentRunSeq: new Map(), + broadcast, + getRuntimeConfig: () => + ({ + session: { scope: "global", store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }) satisfies OpenClawConfig, + nodeSendToSession, + }; + + broadcastChatFinal({ + context, + runId: "run-ops-global", + sessionKey: "global", + agentId: "ops", + }); + + expect(broadcast).toHaveBeenCalledWith( + "chat", + expect.objectContaining({ agentId: "ops", sessionKey: "global" }), + { sessionKeys: ["agent:ops:global", "global"] }, + ); + expect(nodeSendToSession.mock.calls.map(([key]) => key)).toEqual([ + "agent:ops:global", + "global", + ]); + }); +}); diff --git a/src/gateway/server-methods/chat-broadcast.ts b/src/gateway/server-methods/chat-broadcast.ts index c4fa2c7998da..ad0abc576f09 100644 --- a/src/gateway/server-methods/chat-broadcast.ts +++ b/src/gateway/server-methods/chat-broadcast.ts @@ -1,10 +1,8 @@ -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getReplyPayloadMetadata, type ReplyPayload } from "../../auto-reply/reply-payload.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { projectChatDisplayMessage } from "../chat-display-projection.js"; -import { - resolveSessionSubscriptionKey, - resolveSessionSubscriptionKeys, -} from "../session-subscription-keys.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import type { GatewayRequestContext } from "./types.js"; type ChatBroadcastContext = Pick< @@ -30,21 +28,43 @@ function nextChatSeq(context: { agentRunSeq: Map }, runId: strin return next; } +export function resolveGlobalAwareNodeChatDeliveryKeys(params: { + cfg: OpenClawConfig; + sessionKey: string; + agentId?: string; +}): string[] { + if (parseAgentSessionKey(params.sessionKey)) { + return [params.sessionKey]; + } + const unscopedOwnerAgentId = tryResolveSessionCompatibilityOwnerAgentId( + params.cfg, + params.sessionKey, + ); + const selectedAgentId = params.agentId ?? unscopedOwnerAgentId; + if (!selectedAgentId) { + return [params.sessionKey]; + } + const scopedAgentId = normalizeAgentId(selectedAgentId); + const keys = [`agent:${scopedAgentId}:${params.sessionKey}`]; + if ( + unscopedOwnerAgentId && + normalizeAgentId(unscopedOwnerAgentId) === normalizeAgentId(scopedAgentId) + ) { + keys.push(params.sessionKey); + } + return keys; +} + function resolveChatSessionKeys(params: { context: Partial>; sessionKey: string; agentId?: string; }): string[] { - const canonicalKey = resolveSessionSubscriptionKey(params.sessionKey, params.agentId ?? ""); - if (canonicalKey === params.sessionKey) { - return [canonicalKey]; - } - const defaultAgentId = resolveDefaultAgentId(params.context.getRuntimeConfig?.() ?? {}); - return resolveSessionSubscriptionKeys( - params.sessionKey, - params.agentId ?? defaultAgentId, - defaultAgentId, - ); + return resolveGlobalAwareNodeChatDeliveryKeys({ + cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig), + sessionKey: params.sessionKey, + agentId: params.agentId, + }); } export function sendGlobalAwareNodeChatPayload(params: { @@ -78,7 +98,7 @@ type ChatTerminal = function broadcastChatTerminal(params: ChatBroadcastParams & ChatTerminal): void { const seq = nextChatSeq(params.context, params.runId); - const payloadAgentId = params.sessionKey === "global" ? params.agentId : undefined; + const payloadAgentId = parseAgentSessionKey(params.sessionKey) ? undefined : params.agentId; const terminal = params.state === "final" ? { state: params.state, message: projectChatDisplayMessage(params.message) } @@ -130,8 +150,9 @@ export function broadcastSideResult(params: { payload: SideResultPayload; }): void { const seq = nextChatSeq(params.context, params.payload.runId); - const payloadAgentId = - params.payload.sessionKey === "global" ? params.payload.agentId : undefined; + const payloadAgentId = parseAgentSessionKey(params.payload.sessionKey) + ? undefined + : params.payload.agentId; const payload = { ...params.payload, ...(payloadAgentId ? { agentId: payloadAgentId } : {}), diff --git a/src/gateway/server-methods/chat-history-handler.test.ts b/src/gateway/server-methods/chat-history-handler.test.ts new file mode 100644 index 000000000000..9de0183b9652 --- /dev/null +++ b/src/gateway/server-methods/chat-history-handler.test.ts @@ -0,0 +1,43 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { chatHistoryHandlers } from "./chat-history-handler.js"; +import type { GatewayRequestContext, RespondFn } from "./types.js"; + +describe("chat metadata ownership", () => { + it("returns a typed selection error for an ownerless explicit fleet", async () => { + const config: OpenClawConfig = { + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + }; + const respond = vi.fn(); + const readChatMetadata = vi.fn(); + + await expectDefined( + chatHistoryHandlers["chat.metadata"], + 'chatHistoryHandlers["chat.metadata"] test invariant', + )({ + params: {}, + respond: respond as unknown as RespondFn, + req: {} as never, + client: null, + isWebchatConnect: () => false, + context: { + getRuntimeConfig: () => config, + readChatMetadata, + } as unknown as GatewayRequestContext, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("has no explicit owner"), + }), + ); + expect(readChatMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/chat-history-handler.ts b/src/gateway/server-methods/chat-history-handler.ts index 5cc24869489d..93f524aaf12b 100644 --- a/src/gateway/server-methods/chat-history-handler.ts +++ b/src/gateway/server-methods/chat-history-handler.ts @@ -5,16 +5,13 @@ import { } from "../../../packages/gateway-protocol/src/client-info.js"; import { ErrorCodes, + type AgentsListResult, errorShape, validateChatHistoryParams, validateChatMetadataParams, } from "../../../packages/gateway-protocol/src/index.js"; import { CHAT_HISTORY_MAX_ENTRIES } from "../../../packages/gateway-protocol/src/schema/chat-history-constants.js"; -import { - listAgentIds, - resolveDefaultAgentId, - resolveSessionAgentId, -} from "../../agents/agent-scope.js"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { isSessionTranscriptProjectionUnavailableError, resolveTranscriptSessionKeyBySessionId, @@ -32,6 +29,7 @@ import { } from "../chat-abort.js"; import { resolveEffectiveChatHistoryMaxChars } from "../chat-display-projection.js"; import { getMaxChatHistoryMessagesBytes } from "../server-constants.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { capArrayByJsonBytes } from "../session-transcript-readers.js"; import { buildGatewaySessionInfo, @@ -42,6 +40,7 @@ import { resolveSessionStoreKey, } from "../session-utils.js"; import { prepareSessionWorkspaceIcon } from "../workspace-icon-http.js"; +import { resolveAgentIdOrRespondError } from "./agent-id-shared.js"; import { scheduleChatHistoryManagedMediaCleanup } from "./chat-assistant-content.js"; import { CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, @@ -79,22 +78,22 @@ async function handleChatMetadataRequest({ } const metadataParams = params; const cfg = context.getRuntimeConfig(); - const requestedAgentId = - typeof metadataParams.agentId === "string" && metadataParams.agentId.trim() - ? normalizeAgentId(metadataParams.agentId) - : resolveDefaultAgentId(cfg); - if (!listAgentIds(cfg).includes(requestedAgentId)) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${metadataParams.agentId}"`), - ); + const resolvedAgent = resolveAgentIdOrRespondError({ + rawAgentId: metadataParams.agentId, + respond, + cfg, + normalize: (rawAgentId) => + typeof rawAgentId === "string" && rawAgentId.trim() + ? normalizeAgentId(rawAgentId) + : undefined, + }); + if (!resolvedAgent) { return; } respond( true, await context.readChatMetadata({ - agentId: requestedAgentId, + agentId: resolvedAgent.agentId, }), ); } @@ -194,11 +193,16 @@ async function handleChatHistoryRequest({ } const requestConfig = context.getRuntimeConfig(); const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId); - const requestedAgentId = resolveRequestedChatAgentId({ + const requestedAgent = resolveRequestedChatAgentId({ cfg: requestConfig, requestedSessionKey: sessionKey, agentId: agentIdOverride, }); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined; const { cfg, storePath, store, entry, canonicalKey } = measureDiagnosticsTimelineSpanSync( `gateway.${method}.session_entry`, @@ -343,6 +347,7 @@ async function handleChatHistoryRequest({ scheduleChatHistoryManagedMediaCleanup({ sessionKey, ...(selectedAgent.agentId ? { agentId: selectedAgent.agentId } : {}), + cfg, context, }); const capped = messageId @@ -386,12 +391,11 @@ async function handleChatHistoryRequest({ }); const modelCatalogSnapshot = await modelCatalogPromise; const catalogOwnedBySessionAgent = modelCatalogSnapshot?.agentId === sessionAgentId; - const catalogConfig = catalogOwnedBySessionAgent ? modelCatalogSnapshot.config : cfg; const modelCatalog = catalogOwnedBySessionAgent ? modelCatalogSnapshot.entries : undefined; - const defaultAgentId = resolveDefaultAgentId(catalogConfig); + const compatibilityOwnerAgentId = tryResolveSessionCompatibilityOwnerAgentId(cfg, sessionKey); let startupProjection: ChatStartupProjectionResult | undefined; let startupMetadata: ChatMetadataResult | undefined; - let startupAgentsList: ReturnType | undefined; + let startupAgentsList: AgentsListResult | undefined; if (method === "chat.startup") { const includeSystem = hasGatewayClientCap(client?.connect.caps, GATEWAY_CLIENT_CAPS.AGENT_KIND); const startupProjections = await measureDiagnosticsTimelineSpan( @@ -465,15 +469,14 @@ async function handleChatHistoryRequest({ }, }, ); - const activeRunAgentId = - canonicalKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId; + const activeRunAgentId = selectedAgent.agentId; const activeRunState = resolveVisibleActiveSessionRunState({ context, requestedKey: sessionKey, canonicalKey, sessionId: entry?.sessionId, ...(activeRunAgentId ? { agentId: activeRunAgentId } : {}), - defaultAgentId, + defaultAgentId: compatibilityOwnerAgentId, }); sessionInfo.hasActiveRun = activeRunState.active; sessionInfo.activeRunIds = activeRunState.runIds; @@ -495,7 +498,7 @@ async function handleChatHistoryRequest({ requestedSessionKey: sessionKey, canonicalSessionKey: resolveSessionStoreKey({ cfg, sessionKey }), agentId: activeRunAgentId, - defaultAgentId, + defaultAgentId: compatibilityOwnerAgentId, }); const boundedInFlightRun = boundInFlightRunSnapshotForChatHistory({ snapshot: inFlightRun, diff --git a/src/gateway/server-methods/chat-message-get-handler.ts b/src/gateway/server-methods/chat-message-get-handler.ts index a2863d96ef30..16055b64a198 100644 --- a/src/gateway/server-methods/chat-message-get-handler.ts +++ b/src/gateway/server-methods/chat-message-get-handler.ts @@ -68,11 +68,16 @@ export const chatMessageGetHandlers: GatewayRequestHandlers = { maxChars?: number; }; const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId); - const requestedAgentId = resolveRequestedChatAgentId({ + const requestedAgent = resolveRequestedChatAgentId({ cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(), requestedSessionKey: sessionKey, agentId: agentIdOverride, }); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined; const { cfg, storePath, entry } = loadGatewaySessionEntryReadOnly( sessionKey, diff --git a/src/gateway/server-methods/chat-metadata-runtime.ts b/src/gateway/server-methods/chat-metadata-runtime.ts index 80d1a34eebfa..cb7675439fb9 100644 --- a/src/gateway/server-methods/chat-metadata-runtime.ts +++ b/src/gateway/server-methods/chat-metadata-runtime.ts @@ -1,8 +1,4 @@ -import { - listAgentIds, - resolveAgentWorkspaceDir, - resolveDefaultAgentId, -} from "../../agents/agent-scope.js"; +import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { getPreparedRuntimeAuthProfileStoreSnapshot, getRuntimeAuthProfileStoreSnapshotRevision, @@ -557,13 +553,7 @@ export function createGatewayChatMetadataRuntime(params: { `prepared chat startup projection is unavailable for agent "${sessionAgentId}"`, ); } - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(generation.facts.config)); - const defaultAgent = generation.agentsById.get(defaultAgentId); - if (!defaultAgent) { - throw new ChatMetadataSnapshotUnavailableError( - `prepared chat startup projection is unavailable for default agent "${defaultAgentId}"`, - ); - } + const defaultAgentId = sessionAgentId; const profileNeutralProjections = await Promise.all( [...generation.agentsById.values()].map( async (agent) => [agent.agentId, await projectAgent(generation, agent)] as const, diff --git a/src/gateway/server-methods/chat-origin-routing.test.ts b/src/gateway/server-methods/chat-origin-routing.test.ts new file mode 100644 index 000000000000..ebd6cc7c941e --- /dev/null +++ b/src/gateway/server-methods/chat-origin-routing.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveRequestedChatAgentId } from "./chat-origin-routing.js"; + +describe("chat session owner resolution", () => { + it("uses configured fixed-store ownership for bare keys", () => { + const cfg: OpenClawConfig = { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + expect(resolveRequestedChatAgentId({ cfg, requestedSessionKey: "global" })).toEqual({ + ok: true, + agentId: "ops", + }); + }); + + it("returns the typed selection error for ownerless bare keys", () => { + const cfg: OpenClawConfig = { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + }; + + expect(resolveRequestedChatAgentId({ cfg, requestedSessionKey: "global" })).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("has no explicit owner") }, + }); + }); +}); diff --git a/src/gateway/server-methods/chat-origin-routing.ts b/src/gateway/server-methods/chat-origin-routing.ts index dccb54e689de..1d1ca508efa0 100644 --- a/src/gateway/server-methods/chat-origin-routing.ts +++ b/src/gateway/server-methods/chat-origin-routing.ts @@ -2,8 +2,8 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../../packages/gateway-protocol/src/client-info.js"; +import type { ErrorShape } from "../../../packages/gateway-protocol/src/index.js"; import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js"; -import { listAgentIds } from "../../agents/agent-scope.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; @@ -22,7 +22,7 @@ import { } from "../../utils/message-channel.js"; import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js"; import { ADMIN_SCOPE } from "../method-scopes.js"; -import { resolveSessionStoreKey } from "../session-utils.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([ @@ -104,56 +104,34 @@ export function validateChatSelectedAgent(params: { requestedSessionKey: string; agentId?: string; }): { ok: true; agentId?: string } | { ok: false; error: string } { - const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; - if (!agentId) { - return { ok: true }; - } - if (!listAgentIds(params.cfg).includes(agentId)) { - return { ok: false, error: `Unknown agent id "${params.agentId}"` }; - } - const requestedSessionKey = params.requestedSessionKey.trim(); - const parsed = parseAgentSessionKey(requestedSessionKey); - if (parsed && normalizeAgentId(parsed.agentId) !== agentId) { - return { - ok: false, - error: `agentId "${params.agentId}" does not match session key "${params.requestedSessionKey}"`, - }; - } - if (requestedSessionKey.toLowerCase() === "global") { - return { ok: true, agentId }; - } - if (resolveSessionStoreKey({ cfg: params.cfg, sessionKey: requestedSessionKey }) === "global") { - return { ok: true, agentId }; - } - if (!parsed || normalizeAgentId(parsed.agentId) !== agentId) { - return { - ok: false, - error: `agentId "${params.agentId}" does not match session key "${params.requestedSessionKey}"`, - }; - } - return { ok: true, agentId }; + const resolved = resolveRequestedSessionAgentId( + params.cfg, + params.requestedSessionKey, + params.agentId, + ); + return resolved.ok + ? { ok: true, agentId: resolved.agentId } + : { ok: false, error: resolved.error.message }; } export function resolveRequestedChatAgentId(params: { cfg?: OpenClawConfig; requestedSessionKey: string; agentId?: string; -}): string | undefined { +}): { ok: true; agentId?: string } | { ok: false; error: ErrorShape } { const explicitAgentId = normalizeOptionalText(params.agentId); - if (explicitAgentId) { - return normalizeAgentId(explicitAgentId); - } if (!params.cfg) { - return undefined; + return { ok: true, ...(explicitAgentId ? { agentId: normalizeAgentId(explicitAgentId) } : {}) }; } - const parsed = parseAgentSessionKey(params.requestedSessionKey.trim()); - if ( - !parsed?.agentId || - resolveSessionStoreKey({ cfg: params.cfg, sessionKey: params.requestedSessionKey }) !== "global" - ) { - return undefined; + const resolved = resolveRequestedSessionAgentId( + params.cfg, + params.requestedSessionKey, + explicitAgentId, + ); + if (!resolved.ok) { + return resolved; } - return normalizeAgentId(parsed.agentId); + return { ok: true, ...(resolved.agentId ? { agentId: resolved.agentId } : {}) }; } export function resolveChatSendActiveScopeKey(params: { @@ -161,7 +139,7 @@ export function resolveChatSendActiveScopeKey(params: { agentId?: string; mainKey?: string; }): string { - if (params.sessionKey !== "global" || !params.agentId) { + if (parseAgentSessionKey(params.sessionKey) || !params.agentId) { return params.sessionKey; } return ( diff --git a/src/gateway/server-methods/chat-restart-recovery.ts b/src/gateway/server-methods/chat-restart-recovery.ts index 7e202def1c55..ccb620278bf7 100644 --- a/src/gateway/server-methods/chat-restart-recovery.ts +++ b/src/gateway/server-methods/chat-restart-recovery.ts @@ -27,7 +27,9 @@ import { isAgentHarnessSessionKey } from "../../sessions/agent-harness-session-k import { isAcpSessionKey } from "../../sessions/session-key-utils.js"; import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { parseInlineDirectives } from "../../utils/directive-tags.js"; +import { resolveChatRunOwnerAgentId } from "../chat-run-owner.js"; import type { GatewayRecoveryRuntime } from "../server-instance-runtime.types.js"; +import { resolveChatSendActiveScopeKey } from "./chat-origin-routing.js"; import type { GatewayRequestContext } from "./types.js"; export { hasRestartRecoveryTerminalRun }; @@ -245,21 +247,41 @@ function hasRestartUnsafeChatWork(params: { Partial>; sessionId: string; sessionKey: string; + agentId: string; }): boolean { if ( findRestartRecoveryUnsafeChatAdmissionHook() !== undefined || listActiveEmbeddedRunSessionIds().includes(params.sessionId) || - replyRunRegistry.isActive(params.sessionKey) + replyRunRegistry.isActive( + resolveChatSendActiveScopeKey({ + sessionKey: params.sessionKey, + agentId: params.agentId, + }), + ) ) { return true; } for (const active of params.context.chatAbortControllers.values()) { - if (active.sessionKey === params.sessionKey || active.sessionId === params.sessionId) { + if ( + (active.sessionKey === params.sessionKey || active.sessionId === params.sessionId) && + resolveChatRunOwnerAgentId({ + agentId: active.agentId, + sessionKey: active.sessionKey, + defaultAgentId: params.agentId, + }) === params.agentId + ) { return true; } } for (const queued of params.context.chatQueuedTurns?.values() ?? []) { - if (queued.sessionKey === params.sessionKey || queued.sessionId === params.sessionId) { + if ( + (queued.sessionKey === params.sessionKey || queued.sessionId === params.sessionId) && + resolveChatRunOwnerAgentId({ + agentId: queued.agentId, + sessionKey: queued.sessionKey, + defaultAgentId: params.agentId, + }) === params.agentId + ) { return true; } } diff --git a/src/gateway/server-methods/chat-send-admission.ts b/src/gateway/server-methods/chat-send-admission.ts index 768ec5ef6ea6..4c3687ec9f3a 100644 --- a/src/gateway/server-methods/chat-send-admission.ts +++ b/src/gateway/server-methods/chat-send-admission.ts @@ -110,9 +110,7 @@ export async function admitChatSend(params: { status: "accepted" as const, sessionKey, ...(rawSessionKey === sessionKey ? {} : { sessionKeyAliases: [rawSessionKey] }), - ...(sessionKey === "global" && selectedAgent.agentId - ? { agentId: selectedAgent.agentId } - : {}), + ...(selectedAgent.agentId ? { agentId: selectedAgent.agentId } : {}), ownerConnId: normalizeOptionalChatText(client?.connId), ownerDeviceId: normalizeOptionalChatText(client?.connect?.device?.id), expiresAtMs: pendingExpiresAtMs, diff --git a/src/gateway/server-methods/chat-send-agent-dispatch.ts b/src/gateway/server-methods/chat-send-agent-dispatch.ts index d43a750dcff0..90f60a7a199b 100644 --- a/src/gateway/server-methods/chat-send-agent-dispatch.ts +++ b/src/gateway/server-methods/chat-send-agent-dispatch.ts @@ -5,7 +5,6 @@ import { hasGatewayClientCap, } from "../../../packages/gateway-protocol/src/client-info.js"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { dispatchInboundMessageWithProjectedDispatcher } from "../../auto-reply/dispatch.js"; import type { ReplyMessageInjectionAttempt } from "../../auto-reply/reply/reply-run-registry.js"; @@ -14,7 +13,9 @@ import { retainGatewayRootWorkAdmissionContinuation } from "../../process/gatewa import { isOperatorUiClient } from "../../utils/message-channel.js"; import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js"; import { updateChatRunProvider } from "../chat-abort.js"; +import { chatRunBelongsToSelectedAgent } from "../chat-run-owner.js"; import type { ChatRunTiming } from "../server-chat-state.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js"; import type { AdmittedChatSend } from "./chat-send-admission.js"; import type { prepareChatSendAttachments } from "./chat-send-attachments.js"; @@ -322,23 +323,21 @@ export function startChatDispatch(params: StartChatDispatchParams): void { // Register for any other active runs *in the same session* so // late-joining clients (e.g. page refresh mid-response) receive // in-progress tool events without leaking cross-session data. - const defaultAgentId = resolveDefaultAgentId(cfg); - const selectedGlobalAgentId = - sessionKey === "global" - ? (selectedAgent.agentId ?? defaultAgentId) - : undefined; + const compatibilityOwnerAgentId = tryResolveSessionCompatibilityOwnerAgentId( + cfg, + sessionKey, + ); + const selectedSessionAgentId = selectedAgent.agentId; for (const [activeRunId, active] of context.chatAbortControllers) { - const activeGlobalAgentId = - active.sessionKey === "global" - ? (active.agentId ?? defaultAgentId) - : undefined; - const sameSelectedGlobalAgent = - sessionKey === "global" && - selectedGlobalAgentId !== undefined && - activeGlobalAgentId === selectedGlobalAgentId; - const sameSession = - active.sessionKey === sessionKey && - (sessionKey !== "global" || sameSelectedGlobalAgent); + const sameSelectedAgent = + selectedSessionAgentId !== undefined && + chatRunBelongsToSelectedAgent({ + agentId: active.agentId, + sessionKey: active.sessionKey, + defaultAgentId: compatibilityOwnerAgentId, + selectedAgentId: selectedSessionAgentId, + }); + const sameSession = active.sessionKey === sessionKey && sameSelectedAgent; if (activeRunId !== runId && sameSession) { context.registerToolEventRecipient(activeRunId, connId); } diff --git a/src/gateway/server-methods/chat-send-dispatch-errors.test.ts b/src/gateway/server-methods/chat-send-dispatch-errors.test.ts index c941c8c12a88..8f44ebbb1ae2 100644 --- a/src/gateway/server-methods/chat-send-dispatch-errors.test.ts +++ b/src/gateway/server-methods/chat-send-dispatch-errors.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import { retainLegacyDefaultAgentId } from "../../config/legacy.default-agent-owner.js"; import { onAgentRuntimeEvent } from "../../infra/agent-events.js"; import { abortChatRunById, registerChatAbortController } from "../chat-abort.js"; import { createChatRunState } from "../server-chat-state.js"; +import * as sessionLifecycleState from "../session-lifecycle-state.js"; import { createChatSendDispatchErrorLifecycle } from "./chat-send-dispatch-errors.js"; describe("createChatSendDispatchErrorLifecycle", () => { @@ -286,4 +288,92 @@ describe("createChatSendDispatchErrorLifecycle", () => { expect.anything(), ); }); + + it("cleans up a failed non-default global send beside the compatibility owner's run", async () => { + const cfg = retainLegacyDefaultAgentId( + { + agents: { + list: [{ id: "main" }, { id: "ops" }], + }, + }, + "main", + ); + const persistLifecycleEvent = vi + .spyOn(sessionLifecycleState, "persistGatewaySessionLifecycleEvent") + .mockResolvedValue(undefined); + const cleanupAdmittedRun = vi.fn(); + const activeRunCleanup = vi.fn(); + const clientRunId = "failed-ops-global-send"; + const chatAbortControllers = new Map([ + [ + "compat-owner-run", + { + controller: new AbortController(), + sessionId: "sess-main", + sessionKey: "global", + }, + ], + ]); + + try { + const lifecycle = createChatSendDispatchErrorLifecycle({ + admission: { + activeRunAbort: { + cleanup: activeRunCleanup, + controller: new AbortController(), + entry: undefined, + registered: true, + } as never, + cleanupAdmittedRun, + lifecycleGeneration: "test-generation", + restartSafeAdmission: undefined, + }, + context: { + agentRunSeq: new Map(), + broadcast: vi.fn(), + broadcastToConnIds: vi.fn(), + chatAbortControllers, + chatRunState: createChatRunState(), + dedupe: new Map(), + getRuntimeConfig: () => cfg, + getSessionEventSubscriberConnIds: () => new Set(), + logGateway: { warn: vi.fn() }, + nodeSendToSession: vi.fn(), + removeChatRun: vi.fn(), + } as never, + isQueuedFollowupEnqueued: () => false, + persistUserTurnTranscript: vi.fn(), + session: { + agentId: "ops", + backingSessionId: "sess-ops", + cfg, + clientRunId, + now: 1, + rawSessionKey: "global", + sessionKey: "global", + }, + terminalizeRestartSafeAdmission: vi.fn(), + userTurnRecorder: { hasPersisted: () => true, isBlocked: () => false }, + }); + + await lifecycle.handleError(new Error("dispatch rejected")); + lifecycle.finalize(); + + await vi.waitFor(() => { + expect(persistLifecycleEvent).toHaveBeenCalledWith({ + sessionKey: "global", + agentId: "ops", + event: expect.objectContaining({ + runId: clientRunId, + sessionId: "sess-ops", + data: expect.objectContaining({ phase: "error" }), + }), + }); + }); + expect(activeRunCleanup).toHaveBeenCalledWith({ force: true }); + expect(cleanupAdmittedRun).toHaveBeenCalledOnce(); + } finally { + persistLifecycleEvent.mockRestore(); + } + }); }); diff --git a/src/gateway/server-methods/chat-send-dispatch-errors.ts b/src/gateway/server-methods/chat-send-dispatch-errors.ts index a8e2ab7ca47b..f1af2d3e2ade 100644 --- a/src/gateway/server-methods/chat-send-dispatch-errors.ts +++ b/src/gateway/server-methods/chat-send-dispatch-errors.ts @@ -1,11 +1,11 @@ import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { clearAgentRunContext } from "../../infra/agent-run-registry.js"; import { retainGatewayRootWorkAdmissionContinuation } from "../../process/gateway-work-admission.js"; import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js"; import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js"; import { chatAbortMarkerTimestampMs } from "../server-chat-state.js"; import { persistGatewaySessionLifecycleEvent } from "../session-lifecycle-state.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { formatForLog } from "../ws-log.js"; import { buildAbortedChatSendPayload } from "./chat-abort-authorization.js"; import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js"; @@ -289,8 +289,8 @@ export function createChatSendDispatchErrorLifecycle(params: { context, requestedKey: rawSessionKey, canonicalKey: sessionKey, - ...(sessionKey === "global" && agentId ? { agentId } : {}), - defaultAgentId: resolveDefaultAgentId(cfg), + ...(agentId ? { agentId } : {}), + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, sessionKey), }); if (hasActiveRun) { return; @@ -298,7 +298,7 @@ export function createChatSendDispatchErrorLifecycle(params: { try { await persistGatewaySessionLifecycleEvent({ sessionKey, - ...(sessionKey === "global" && agentId ? { agentId } : {}), + ...(agentId ? { agentId } : {}), event: { runId: clientRunId, sessionId: dispatchError.sessionId, diff --git a/src/gateway/server-methods/chat-send-nonagent-finalization.ts b/src/gateway/server-methods/chat-send-nonagent-finalization.ts index e83083e4e912..81dc35c1afee 100644 --- a/src/gateway/server-methods/chat-send-nonagent-finalization.ts +++ b/src/gateway/server-methods/chat-send-nonagent-finalization.ts @@ -162,7 +162,7 @@ export async function finalizeChatSendNonAgentReplies(params: { kind: "btw", runId: clientRunId, sessionKey, - ...(sessionKey === "global" && agentId ? { agentId } : {}), + ...(agentId ? { agentId } : {}), ...btwResult, ts: Date.now(), }, diff --git a/src/gateway/server-methods/chat-send-pre-admission.test.ts b/src/gateway/server-methods/chat-send-pre-admission.test.ts new file mode 100644 index 000000000000..c1d1c592ccfb --- /dev/null +++ b/src/gateway/server-methods/chat-send-pre-admission.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveChatSendStopOwnerScope } from "./chat-send-stop-owner-scope.js"; + +describe("chat send stop ownership", () => { + it("keeps the selected filter separate from the compatibility run fallback", () => { + const cfg: OpenClawConfig = { + session: { scope: "global", store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + expect( + resolveChatSendStopOwnerScope({ + cfg, + selectedAgentId: "research", + sessionKey: "global", + }), + ).toEqual({ agentId: "research", defaultAgentId: "ops" }); + }); +}); diff --git a/src/gateway/server-methods/chat-send-pre-admission.ts b/src/gateway/server-methods/chat-send-pre-admission.ts index c5c43efaf2fc..46a9eb5312a0 100644 --- a/src/gateway/server-methods/chat-send-pre-admission.ts +++ b/src/gateway/server-methods/chat-send-pre-admission.ts @@ -1,5 +1,4 @@ import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveSessionWorkStartError } from "../../config/sessions.js"; import { SESSION_ROUTING_CHANGED_ERROR_REASON } from "../../config/sessions/main-session.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -20,6 +19,7 @@ import { import { resolveDurableChatClaim } from "./chat-restart-recovery.js"; import type { NormalizedChatSendRequest } from "./chat-send-request.js"; import type { PreparedChatSendSession } from "./chat-send-session.js"; +import { resolveChatSendStopOwnerScope } from "./chat-send-stop-owner-scope.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; export const ACTIVE_LEAF_CHANGED_ERROR_REASON = "active-leaf-changed"; @@ -90,18 +90,20 @@ export async function runChatSendPreAdmission(params: { respondChatSessionRoutingChanged(respond); return false; } - const defaultAgentId = resolveDefaultAgentId(cfg); - const stopAgentId = - sessionKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId; + const stopOwnerScope = resolveChatSendStopOwnerScope({ + cfg, + selectedAgentId: selectedAgent.agentId, + sessionKey, + }); const res = await abortChatRunsForSessionKeyWithPartials({ context, ops: createChatAbortOps(context), sessionKey: rawSessionKey, sessionKeyAliases: sessionKey === rawSessionKey ? undefined : [sessionKey], - agentId: stopAgentId, + agentId: stopOwnerScope.agentId, sessionId: entry?.sessionId, persistSessionKey: sessionKey, - defaultAgentId, + defaultAgentId: stopOwnerScope.defaultAgentId, abortOrigin: "stop-command", stopReason: "stop", requester: resolveChatAbortRequester(client), diff --git a/src/gateway/server-methods/chat-send-session.ts b/src/gateway/server-methods/chat-send-session.ts index c38ce9692d00..3ec173ec6c4c 100644 --- a/src/gateway/server-methods/chat-send-session.ts +++ b/src/gateway/server-methods/chat-send-session.ts @@ -40,11 +40,15 @@ function loadChatSendSessionContext(params: { const clientRunId = p.idempotencyKey; const pendingChatSendKey = pendingChatSendDedupeKey(clientRunId); const runtimeConfig = context.getRuntimeConfig?.(); - const requestedAgentId = resolveRequestedChatAgentId({ + const requestedAgent = resolveRequestedChatAgentId({ cfg: runtimeConfig, requestedSessionKey: rawSessionKey, agentId: agentIdOverride, }); + if (!requestedAgent.ok) { + return { ok: false as const, error: requestedAgent.error }; + } + const requestedAgentId = requestedAgent.agentId; // Outside configured global scope, `global` + agentId is the shipped webchat // alias for that agent's main thread. Resolve it before every store lookup so // reconnect replay cannot create a parallel literal `global` transcript. @@ -81,21 +85,24 @@ function loadChatSendSessionContext(params: { expectedSessionRoutingContract !== undefined && expectedSessionRoutingContract.toLowerCase() !== resolveSessionRoutingContract(candidateConfig); return { - rawSessionKey, - sessionLoadKey, - clientRunId, - pendingChatSendKey, - sessionLoadOptions, - sessionLoadMs, - cfg, - storePath, - entry, - sessionKey, - legacyKey, - sessionRoutingChanged, - expectedLeafEntryId, - expectedRunId, - requestedAgentId, + ok: true as const, + value: { + rawSessionKey, + sessionLoadKey, + clientRunId, + pendingChatSendKey, + sessionLoadOptions, + sessionLoadMs, + cfg, + storePath, + entry, + sessionKey, + legacyKey, + sessionRoutingChanged, + expectedLeafEntryId, + expectedRunId, + requestedAgentId, + }, }; } @@ -106,9 +113,13 @@ export function prepareChatSendSession(params: { client: GatewayRequestHandlerOptions["client"]; }) { const loaded = loadChatSendSessionContext(params); + if (!loaded.ok) { + return loaded; + } + const loadedValue = loaded.value; const { request, client } = params; const { p, explicitOrigin, normalizedAttachments, turnKind, rawMessage } = request; - const { cfg, sessionKey, entry, legacyKey, rawSessionKey, requestedAgentId } = loaded; + const { cfg, sessionKey, entry, legacyKey, rawSessionKey, requestedAgentId } = loadedValue; if (isIncognitoSessionKey(sessionKey) && !entry) { return { ok: false as const, error: `Incognito session "${sessionKey}" was not found.` }; } @@ -176,7 +187,7 @@ export function prepareChatSendSession(params: { return { ok: true as const, value: { - ...loaded, + ...loadedValue, selectedAgent, requestedSessionId, backingSessionId, diff --git a/src/gateway/server-methods/chat-send-setup.ts b/src/gateway/server-methods/chat-send-setup.ts index ea01210a0f06..8978abf22c16 100644 --- a/src/gateway/server-methods/chat-send-setup.ts +++ b/src/gateway/server-methods/chat-send-setup.ts @@ -34,7 +34,13 @@ export async function prepareAndAdmitChatSend( client, }); if (!preparedSession.ok) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, preparedSession.error)); + respond( + false, + undefined, + typeof preparedSession.error === "string" + ? errorShape(ErrorCodes.INVALID_REQUEST, preparedSession.error) + : preparedSession.error, + ); return undefined; } const shouldAdmit = await runChatSendPreAdmission({ diff --git a/src/gateway/server-methods/chat-send-stop-owner-scope.ts b/src/gateway/server-methods/chat-send-stop-owner-scope.ts new file mode 100644 index 000000000000..7265496410ac --- /dev/null +++ b/src/gateway/server-methods/chat-send-stop-owner-scope.ts @@ -0,0 +1,13 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; + +export function resolveChatSendStopOwnerScope(params: { + cfg: OpenClawConfig; + selectedAgentId?: string; + sessionKey: string; +}): { agentId?: string; defaultAgentId?: string } { + return { + agentId: params.selectedAgentId, + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(params.cfg, params.sessionKey), + }; +} diff --git a/src/gateway/server-methods/chat-startup-projection-contract.ts b/src/gateway/server-methods/chat-startup-projection-contract.ts index 294ac4c90414..50d4091cf61c 100644 --- a/src/gateway/server-methods/chat-startup-projection-contract.ts +++ b/src/gateway/server-methods/chat-startup-projection-contract.ts @@ -1,6 +1,5 @@ +import type { AgentsListResult } from "../../../packages/gateway-protocol/src/index.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; -import type { SessionScope } from "../../config/types.base.js"; -import type { GatewayAgentRow } from "../../shared/session-types.js"; import type { ChatMetadataResult, ChatMetadataSessionEntry } from "./chat-metadata-contract.js"; export type ChatStartupProjectionReadParams = { @@ -13,10 +12,5 @@ export type ChatStartupProjectionResult = { metadata: ChatMetadataResult; sessionModelCatalog: ModelCatalogEntry[]; defaultModelCatalog: ModelCatalogEntry[]; - agentsList: { - defaultId: string; - mainKey: string; - scope: SessionScope; - agents: GatewayAgentRow[]; - }; + agentsList: AgentsListResult; }; diff --git a/src/gateway/server-methods/chat.abort-authorization.test-helpers.ts b/src/gateway/server-methods/chat.abort-authorization.test-helpers.ts new file mode 100644 index 000000000000..7bab26e7e815 --- /dev/null +++ b/src/gateway/server-methods/chat.abort-authorization.test-helpers.ts @@ -0,0 +1,80 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { expect } from "vitest"; +import { handleChatAbortRequestWithLifecycle } from "./chat-abort-handler.js"; +import { + createActiveRun, + createChatAbortContext, + invokeChatAbortHandler, +} from "./chat.abort.test-helpers.js"; +import { chatHandlers } from "./chat.js"; + +export type AbortResponsePayload = { aborted?: boolean; runIds?: string[] }; +type AbortRespond = Awaited>; + +export async function invokeAbort({ + context, + sessionKey = "main", + runId, + connId, + deviceId, + preserveSideRuns, + scopes = ["operator.write"], + onAuthorizedAfterQueuedAbort, + excludeRunIds, +}: { + context: ReturnType; + sessionKey?: string; + runId?: string; + connId: string; + deviceId: string; + preserveSideRuns?: boolean; + scopes?: string[]; + onAuthorizedAfterQueuedAbort?: () => boolean; + excludeRunIds?: ReadonlySet; +}) { + return await invokeChatAbortHandler({ + handler: + onAuthorizedAfterQueuedAbort || excludeRunIds + ? (options) => + handleChatAbortRequestWithLifecycle(options, { + onAuthorizedAfterQueuedAbort, + excludeRunIds, + }) + : expectDefined(chatHandlers["chat.abort"], 'chatHandlers["chat.abort"] test invariant'), + context, + request: { + sessionKey, + ...(runId ? { runId } : {}), + ...(preserveSideRuns ? { preserveSideRuns: true } : {}), + }, + client: { connId, connect: { device: { id: deviceId }, scopes } }, + }); +} + +export function createSingleAbortContext() { + return createChatAbortContext({ + chatAbortControllers: new Map([ + [ + "run-1", + createActiveRun("main", { owner: { connId: "conn-owner", deviceId: "dev-owner" } }), + ], + ]), + }); +} + +export function requireLastRespondCall(respond: AbortRespond) { + const call = respond.mock.calls.at(-1); + if (!call) { + throw new Error("expected respond call"); + } + return call; +} + +export function expectAbortPayload( + payload: unknown, + expected: { aborted: boolean; runIds: string[] }, +): void { + const abortPayload = payload as AbortResponsePayload | undefined; + expect(abortPayload?.aborted).toBe(expected.aborted); + expect(abortPayload?.runIds).toEqual(expected.runIds); +} diff --git a/src/gateway/server-methods/chat.abort-authorization.test.ts b/src/gateway/server-methods/chat.abort-authorization.test.ts index dba706d72932..743760de03f2 100644 --- a/src/gateway/server-methods/chat.abort-authorization.test.ts +++ b/src/gateway/server-methods/chat.abort-authorization.test.ts @@ -1,16 +1,21 @@ /** * Tests chat abort authorization checks for gateway clients and session owners. */ -import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi } from "vitest"; import { createChatRunState } from "../server-chat-state.js"; import { handleChatAbortRequestWithLifecycle } from "./chat-abort-handler.js"; +import { + type AbortResponsePayload, + createSingleAbortContext, + expectAbortPayload, + invokeAbort, + requireLastRespondCall, +} from "./chat.abort-authorization.test-helpers.js"; import { createActiveRun, createChatAbortContext, invokeChatAbortHandler, } from "./chat.abort.test-helpers.js"; -import { chatHandlers } from "./chat.js"; vi.mock("../session-utils.js", async () => { return { @@ -19,84 +24,6 @@ vi.mock("../session-utils.js", async () => { }; }); -type AbortResponsePayload = { - aborted?: boolean; - runIds?: string[]; -}; -type AbortRespond = Awaited>; - -async function invokeAbort({ - context, - sessionKey = "main", - runId, - connId, - deviceId, - preserveSideRuns, - scopes = ["operator.write"], - onAuthorizedAfterQueuedAbort, - excludeRunIds, -}: { - context: ReturnType; - sessionKey?: string; - runId?: string; - connId: string; - deviceId: string; - preserveSideRuns?: boolean; - scopes?: string[]; - onAuthorizedAfterQueuedAbort?: () => boolean; - excludeRunIds?: ReadonlySet; -}) { - return await invokeChatAbortHandler({ - handler: - onAuthorizedAfterQueuedAbort || excludeRunIds - ? (options) => - handleChatAbortRequestWithLifecycle(options, { - onAuthorizedAfterQueuedAbort, - excludeRunIds, - }) - : expectDefined(chatHandlers["chat.abort"], 'chatHandlers["chat.abort"] test invariant'), - context, - request: { - sessionKey, - ...(runId ? { runId } : {}), - ...(preserveSideRuns ? { preserveSideRuns: true } : {}), - }, - client: { - connId, - connect: { device: { id: deviceId }, scopes }, - }, - }); -} - -function createSingleAbortContext() { - return createChatAbortContext({ - chatAbortControllers: new Map([ - [ - "run-1", - createActiveRun("main", { owner: { connId: "conn-owner", deviceId: "dev-owner" } }), - ], - ]), - }); -} - -function requireLastRespondCall(respond: AbortRespond) { - const calls = respond.mock.calls; - const call = calls[calls.length - 1]; - if (!call) { - throw new Error("expected respond call"); - } - return call; -} - -function expectAbortPayload( - payload: unknown, - expected: { aborted: boolean; runIds: string[] }, -): void { - const abortPayload = payload as AbortResponsePayload | undefined; - expect(abortPayload?.aborted).toBe(expected.aborted); - expect(abortPayload?.runIds).toEqual(expected.runIds); -} - describe("chat.abort authorization", () => { it("rejects non-admin worker-only inference aborts", async () => { const cancelInferenceForSession = vi.fn(() => ["worker-run"]); @@ -1064,4 +991,76 @@ describe("chat.abort queued-turn contract", () => { expect(foreign.signal.aborted).toBe(false); expect(context.chatQueuedTurns.has("queued-foreign")).toBe(true); }); + + it("rejects an ownerless global abort on an explicit fleet", async () => { + const active = createActiveRun("global", { agentId: "research" }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([["run-research", active]]), + getRuntimeConfig: () => ({ + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + session: { scope: "global" }, + }), + }); + const respond = await invokeChatAbortHandler({ + handler: handleChatAbortRequestWithLifecycle, + context, + request: { sessionKey: "global", runId: "run-research" }, + }); + expect(respond.mock.calls.at(-1)?.[2]).toMatchObject({ + code: "INVALID_REQUEST", + message: expect.stringContaining("has no explicit owner"), + }); + expect(active.controller.signal.aborted).toBe(false); + }); + + it("uses the persisted fixed-store owner for a bare global abort", async () => { + const active = createActiveRun("global", { agentId: "ops" }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([["run-ops", active]]), + getRuntimeConfig: () => ({ + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + session: { scope: "global", store: "/tmp/shared-sessions.sqlite" }, + }), + }); + + const respond = await invokeChatAbortHandler({ + handler: handleChatAbortRequestWithLifecycle, + context, + request: { sessionKey: "global", runId: "run-ops" }, + }); + + expect(respond.mock.calls.at(-1)?.[0]).toBe(true); + expect(active.controller.signal.aborted).toBe(true); + }); + + it("rejects a bare global abort owned by a retired fixed-store agent", async () => { + const active = createActiveRun("global", { agentId: "research" }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([["run-research", active]]), + getRuntimeConfig: () => ({ + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "retired" } }, + entries: { ops: {}, research: {} }, + }, + session: { scope: "global", store: "/tmp/shared-sessions.sqlite" }, + }), + }); + + const respond = await invokeChatAbortHandler({ + handler: handleChatAbortRequestWithLifecycle, + context, + request: { sessionKey: "global", runId: "run-research" }, + }); + + expect(respond.mock.calls.at(-1)?.[2]).toMatchObject({ + code: "INVALID_REQUEST", + message: 'session key belongs to retired agent "retired"', + }); + expect(active.controller.signal.aborted).toBe(false); + }); }); diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 7f7b5c458ba1..21010d325598 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -544,8 +544,12 @@ describe("chat abort transcript persistence", () => { ["scopes bare global stop commands to the default agent", "main", "default"], ])("%s", async (_name, selectedAgentId, fixtureId) => { const { sessionId } = await createTranscriptFixture(`openclaw-chat-stop-global-${fixtureId}-`); + const cfg = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" as const }, + }; sessionEntryState.canonicalKey = "global"; - sessionEntryState.cfg = { agents: { list: [{ id: "main", default: true }, { id: "work" }] } }; + sessionEntryState.cfg = cfg; const respond = vi.fn(); const mainActive = createActiveRun("global", { sessionId: selectedAgentId === "main" ? sessionId : "sess-main-global", @@ -569,6 +573,7 @@ describe("chat abort transcript persistence", () => { agentId: selectedAgentId, clientRunId: runId, }), + getRuntimeConfig: () => cfg, }); await expectDefined( @@ -611,6 +616,11 @@ describe("chat abort transcript persistence", () => { true, ], ])("%s", async (_name, sessionKey, agentId, needsGlobalConfig) => { + const expectedAgentId = agentId ?? (sessionKey.startsWith("agent:work:") ? "work" : "main"); + const cfg = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" as const }, + }; const respond = vi.fn(); const mainActive = createActiveRun("global", { sessionId: "sess-main-global", @@ -625,14 +635,7 @@ describe("chat abort transcript persistence", () => { ["run-main-global", mainActive], ["run-work-global", workActive], ]), - ...(needsGlobalConfig - ? { - getRuntimeConfig: () => ({ - agents: { list: [{ id: "main", default: true }, { id: "work" }] }, - session: { scope: "global" }, - }), - } - : {}), + getRuntimeConfig: () => cfg, }); const agentEvents: Array<{ runId: string; sessionKey?: string; agentId?: string }> = []; const unsubscribe = onAgentEvent((event) => { @@ -659,7 +662,6 @@ describe("chat abort transcript persistence", () => { unsubscribe(); } - const expectedAgentId = agentId ?? (sessionKey.startsWith("agent:work:") ? "work" : "main"); const [ok, payload] = requireLastRespondCall(respond); expect(ok).toBe(true); expectAbortPayload(payload, { runIds: [`run-${expectedAgentId}-global`] }); @@ -983,7 +985,12 @@ describe("chat abort transcript persistence", () => { ["aborts pending default global agent runs for the default selected agent", "main", true], ])("%s", async (_name, agentId, shouldAbort) => { const respond = vi.fn(); - const context = createChatAbortContext(); + const context = createChatAbortContext({ + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + }); context.dedupe.set("agent:run-main-global", { ts: Date.now(), ok: true, @@ -1145,7 +1152,10 @@ describe("chat.abort session identity matching", () => { expect(ok).toBe(true); expectAbortPayload(payload, { runIds: [runId] }); expect(active.controller.signal.aborted).toBe(true); - expect(sessionEntryState.loadCalls).toContainEqual({ sessionKey: "main", opts: undefined }); + expect(sessionEntryState.loadCalls).toContainEqual({ + sessionKey: "agent:main:main", + opts: { agentId: "main" }, + }); }); it("does not match a run whose sessionId differs from the stored entry", async () => { diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 19acbf091ebc..92f154c60c17 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -230,7 +230,9 @@ vi.mock("../session-utils.js", async () => { const canonicalKey = typeof mockState.sessionEntry.canonicalKey === "string" ? mockState.sessionEntry.canonicalKey - : rawKey || "main"; + : rawKey === "main" + ? `agent:${opts?.agentId ?? "main"}:${mockState.mainSessionKey}` + : rawKey || `agent:${opts?.agentId ?? "main"}:${mockState.mainSessionKey}`; const entry = mockState.sessionMissing ? undefined : { @@ -913,10 +915,10 @@ function expectUserUpdateIdentity(update: ReturnType) { expect(update?.target).toEqual({ agentId: "main", sessionId: mockState.sessionId, - sessionKey: "main", + sessionKey: "agent:main:main", storePath: mockState.storePath, }); - expect(update?.sessionKey).toBe("main"); + expect(update?.sessionKey).toBe("agent:main:main"); expect(update?.agentId).toBe("main"); } @@ -1450,7 +1452,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const { context, respond, send } = createChatRequestFixture(); const queueMessage = vi.fn(async () => {}); const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, }); @@ -1492,7 +1494,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const { context, respond, send } = createChatRequestFixture(); const queueMessage = vi.fn(async () => {}); const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "current-leaf", @@ -1536,7 +1538,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const { context, respond, send } = createChatRequestFixture(); const queueMessage = vi.fn(async () => {}); const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "different-owner-leaf", @@ -1580,7 +1582,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const originalQueue = vi.fn(async () => {}); const successorQueue = vi.fn(async () => {}); const original = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "current-leaf", @@ -1622,7 +1624,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => async () => { original.complete(); successor = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "current-leaf", @@ -1662,7 +1664,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); const { context, respond, send } = createChatRequestFixture(); const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "leaf-before-active-run-output", @@ -1713,7 +1715,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const { context, respond, send } = createChatRequestFixture(); const delivery = createDeferred(); const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: null, @@ -1769,7 +1771,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const { context, send } = createChatRequestFixture(); const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length; const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: null, @@ -1856,7 +1858,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => return delivery.promise; }); const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "current-leaf", @@ -1943,7 +1945,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const successorQueue = vi.fn(async () => {}); const successorCancel = vi.fn(); const original = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "current-leaf", @@ -1972,7 +1974,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(originalQueue).not.toHaveBeenCalled(); original.complete(); successor = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "current-leaf", @@ -2068,7 +2070,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length; const delivery = createDeferred(); const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: null, @@ -2118,7 +2120,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => errorMessage: string; }>(); const first = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: null, @@ -2143,7 +2145,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => first.complete(); const successorCancel = vi.fn(); const successor = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: null, @@ -2177,7 +2179,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const { respond, send } = createChatRequestFixture(); const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length; const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: null, @@ -2218,7 +2220,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const { context, respond, send } = createChatRequestFixture(); const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length; const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "leaf-before-active-run-output", @@ -2282,7 +2284,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length; vi.useFakeTimers({ toFake: ["Date"] }); const operation = replyRunRegistry.begin({ - sessionKey: "main", + sessionKey: "agent:main:main", sessionId: mockState.sessionId, resetTriggered: false, originatingLeafEntryId: "leaf-before-stale-run-output", @@ -2370,7 +2372,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const payload = call?.[1] as { ts?: unknown } | undefined; expect(call?.[0]).toBe("sessions.changed"); expect(call?.[2]).toEqual(new Set(["conn-1"])); - expect(call?.[3]).toEqual({ dropIfSlow: true }); + expect(call?.[3]).toEqual({ agentId: "main", dropIfSlow: true }); expect(payload).toMatchObject({ sessionKey: "agent:main:main", reason: "command-metadata", @@ -2610,7 +2612,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => context.chatAbortControllers.set("run-same-session", { controller: new AbortController(), sessionId: "sess-prev", - sessionKey: "main", + sessionKey: "agent:main:main", startedAtMs: Date.now(), expiresAtMs: Date.now() + 10_000, }); @@ -3286,12 +3288,12 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(broadcast).toMatchObject({ runId: "idem-agent-source-reply", - sessionKey: "main", + sessionKey: "agent:main:main", state: "final", }); expect(extractFirstTextBlock(broadcast)).toBe("Codex source reply"); const nodeSend = lastNodeSendCall(context); - expect(nodeSend?.[0]).toBe("main"); + expect(nodeSend?.[0]).toBe("agent:main:main"); expect(nodeSend?.[1]).toBe("chat"); expect(extractFirstTextBlock(nodeSend?.[2])).toBe("Codex source reply"); const assistantUpdates = findAssistantTranscriptUpdates(); @@ -3323,7 +3325,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(broadcast).toMatchObject({ runId: "idem-agent-status-notice", - sessionKey: "main", + sessionKey: "agent:main:main", state: "final", }); expect(extractFirstTextBlock(broadcast)).toBe("⚙️ Codex compaction started • Context 2k/200k"); @@ -3358,7 +3360,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(broadcast).toMatchObject({ runId: "idem-agent-block-status-notice", - sessionKey: "main", + sessionKey: "agent:main:main", state: "final", }); expect(extractFirstTextBlock(broadcast)).toBe("Model set to openai/gpt-5.5 for this session."); @@ -3450,7 +3452,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(broadcast).toMatchObject({ runId: "idem-agent-source-reply-media", - sessionKey: "main", + sessionKey: "agent:main:main", state: "final", }); expect(extractFirstTextBlock(broadcast)).toBe("Codex source reply with media"); @@ -4082,7 +4084,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(broadcast).toMatchObject({ runId: "idem-agent-source-reply-error", - sessionKey: "main", + sessionKey: "agent:main:main", state: "final", }); expect(extractFirstTextBlock(broadcast)).toBe("Codex source reply"); @@ -4178,7 +4180,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(broadcast).toMatchObject({ runId: "idem-agent-status-notice-error", - sessionKey: "main", + sessionKey: "agent:main:main", state: "error", errorMessage, }); @@ -4211,7 +4213,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(broadcast).toMatchObject({ runId: "idem-agent-returned-error", - sessionKey: "main", + sessionKey: "agent:main:main", state: "error", errorMessage, }); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index cf9bde1228ed..f412f3e8ef83 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -5,7 +5,7 @@ import { validateChatInjectParams, validateChatToolTitlesParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveSessionWorkStartError } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js"; @@ -14,14 +14,16 @@ import { projectChatDisplayMessage, resolveEffectiveChatHistoryMaxChars, } from "../chat-display-projection.js"; -import { resolveSessionSubscriptionKeys } from "../session-subscription-keys.js"; import { loadSessionEntry, loadGatewaySessionEntryReadOnly, resolveSessionModelRef, } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; -import { sendGlobalAwareNodeChatPayload } from "./chat-broadcast.js"; +import { + resolveGlobalAwareNodeChatDeliveryKeys, + sendGlobalAwareNodeChatPayload, +} from "./chat-broadcast.js"; import { chatHistoryHandlers } from "./chat-history-handler.js"; import { chatMessageGetHandlers } from "./chat-message-get-handler.js"; import { resolveRequestedChatAgentId, validateChatSelectedAgent } from "./chat-origin-routing.js"; @@ -62,11 +64,16 @@ export const chatHandlers: GatewayRequestHandlers = { return; } const agentIdOverride = normalizeOptionalText(params.agentId); - const requestedAgentId = resolveRequestedChatAgentId({ + const requestedAgent = resolveRequestedChatAgentId({ cfg, requestedSessionKey: params.sessionKey, agentId: agentIdOverride, }); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; const selectedAgent = validateChatSelectedAgent({ cfg, requestedSessionKey: params.sessionKey, @@ -118,11 +125,16 @@ export const chatHandlers: GatewayRequestHandlers = { // Load session to find transcript file const rawSessionKey = p.sessionKey; - const requestedAgentId = resolveRequestedChatAgentId({ + const requestedAgent = resolveRequestedChatAgentId({ cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(), requestedSessionKey: rawSessionKey, agentId: p.agentId, }); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined; const { cfg, @@ -209,13 +221,13 @@ export const chatHandlers: GatewayRequestHandlers = { const chatPayload = { runId: `inject-${appended.messageId}`, sessionKey, - ...(sessionKey === "global" && agentId ? { agentId } : {}), + ...(agentId ? { agentId } : {}), seq: 0, state: "final" as const, message, }; context.broadcast("chat", chatPayload, { - sessionKeys: resolveSessionSubscriptionKeys(sessionKey, agentId, resolveDefaultAgentId(cfg)), + sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({ cfg, sessionKey, agentId }), }); sendGlobalAwareNodeChatPayload({ context, diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 16bda54eb545..437f373d68fb 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -188,8 +188,10 @@ vi.mock("../../config/config.js", () => ({ getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../../agents/agent-scope.js", () => ({ + AgentSelectionRequiredError: class AgentSelectionRequiredError extends Error {}, listAgentIds: vi.fn(() => ["main", "dev"]), resolveDefaultAgentId: vi.fn(() => "main"), + tryResolveLegacyCompatibilityAgentId: vi.fn(() => "main"), })); vi.mock("../../channels/plugins/index.js", () => ({ getLoadedChannelPlugin: vi.fn((provider: string) => { diff --git a/src/gateway/server-methods/conversations.test.ts b/src/gateway/server-methods/conversations.test.ts index 6dad43197546..9d748cccfa2e 100644 --- a/src/gateway/server-methods/conversations.test.ts +++ b/src/gateway/server-methods/conversations.test.ts @@ -131,13 +131,15 @@ describe("conversations.list Gateway handler", () => { await invokeList({ handler, context: context(), respond }); - expect(runConversationList).toHaveBeenCalledWith({ - config: {}, - agentId: "main", - channel: "reef", - query: "@molty", - limit: 50, - }); + expect(runConversationList).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.any(Object), + agentId: "main", + channel: "reef", + query: "@molty", + limit: 50, + }), + ); expect(respond).toHaveBeenCalledWith(true, listed, undefined); }); @@ -163,6 +165,26 @@ describe("conversations.list Gateway handler", () => { }); describe("conversations.send Gateway handler", () => { + it("rejects a source session owned by another agent", async () => { + const runConversationSend = vi.fn(); + const handler = createConversationHandlers({ runConversationSend })["conversations.send"]!; + const respond = vi.fn(); + + await invokeSend({ + handler, + context: context(), + respond, + request: { ...sendRequest, sourceSessionKey: "agent:ops:main" }, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST", message: expect.stringContaining("ops") }), + ); + expect(runConversationSend).not.toHaveBeenCalled(); + }); + it("owns the send and rejects operation-id reuse with different source input", async () => { const runConversationSend = vi.fn(async () => sendResult); const handler = createConversationHandlers({ @@ -227,7 +249,11 @@ describe("conversations.send Gateway handler", () => { handler, context: gatewayContext, respond: otherRespond, - request: { ...sendRequest, agentId: "other-agent" }, + request: { + ...sendRequest, + agentId: "other-agent", + sourceSessionKey: "agent:other-agent:telegram:direct:operator", + }, }); await vi.waitFor(() => expect(runConversationSend).toHaveBeenCalledTimes(2)); finishMain?.(sendResult); @@ -241,6 +267,26 @@ describe("conversations.send Gateway handler", () => { }); describe("conversations.turn Gateway handler", () => { + it("rejects a source session owned by another agent", async () => { + const runConversationTurn = vi.fn(); + const handler = createConversationHandlers({ runConversationTurn })["conversations.turn"]!; + const respond = vi.fn(); + + await invoke({ + handler, + context: context(), + respond, + request: { ...request, sourceSessionKey: "agent:ops:main" }, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST", message: expect.stringContaining("ops") }), + ); + expect(runConversationTurn).not.toHaveBeenCalled(); + }); + it("validates requests before entering the correlation service", async () => { const runConversationTurn = vi.fn(); const handler = createConversationHandlers({ @@ -355,7 +401,11 @@ describe("conversations.turn Gateway handler", () => { handler, context: gatewayContext, respond: otherRespond, - request: { ...request, agentId: "other-agent" }, + request: { + ...request, + agentId: "other-agent", + sourceSessionKey: "agent:other-agent:telegram:direct:operator", + }, }); await vi.waitFor(() => expect(runConversationTurn).toHaveBeenCalledTimes(2)); finish?.(result); diff --git a/src/gateway/server-methods/conversations.ts b/src/gateway/server-methods/conversations.ts index 539610b09ba4..3846576d5498 100644 --- a/src/gateway/server-methods/conversations.ts +++ b/src/gateway/server-methods/conversations.ts @@ -11,6 +11,7 @@ import { type ConversationTurnCancelParams, type ConversationTurnParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { cancelPendingConversationTurn } from "../../sessions/conversation-turns.js"; import { ConversationInputError, @@ -21,6 +22,7 @@ import { runGatewayConversationSend } from "../conversation-send.js"; import { runGatewayConversationTurn } from "../conversation-turn.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { formatForLog } from "../ws-log.js"; import { cacheGatewayDedupeResult, @@ -49,6 +51,42 @@ function isAuthenticatedOwner(client: GatewayClient | null): boolean { return client?.connect?.scopes?.includes(ADMIN_SCOPE) === true; } +function validateConversationSourceSession(params: { + config: ReturnType; + agentId: string; + sourceSessionKey?: string; + respond: RespondFn; +}): boolean { + if (!params.sourceSessionKey) { + return true; + } + const parsed = parseAgentSessionKey(params.sourceSessionKey); + if (parsed) { + if (normalizeAgentId(parsed.agentId) === normalizeAgentId(params.agentId)) { + return true; + } + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `agent "${params.agentId}" does not match session key agent "${parsed.agentId}"`, + ), + ); + return false; + } + const owner = resolveRequestedSessionAgentId( + params.config, + params.sourceSessionKey, + params.agentId, + ); + if (owner.ok) { + return true; + } + params.respond(false, undefined, owner.error); + return false; +} + function conversationOperationKey(params: { method: "send" | "turn"; agentId: string; @@ -237,6 +275,17 @@ export function createConversationHandlers( return; } const request = params as ConversationSendParams; + const config = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); + if ( + !validateConversationSourceSession({ + config, + agentId: request.agentId, + sourceSessionKey: request.sourceSessionKey, + respond, + }) + ) { + return; + } const requestIdentity = bindConversationOperationIdentity(context, { method: "send", operationId: request.operationId, @@ -268,7 +317,7 @@ export function createConversationHandlers( respond, execute: async () => await deps.runConversationSend({ - config: resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }), + config, agentId: request.agentId, senderIsOwner: isAuthenticatedOwner(client), ...(request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {}), @@ -308,6 +357,17 @@ export function createConversationHandlers( return; } const request = params as ConversationTurnParams; + const config = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); + if ( + !validateConversationSourceSession({ + config, + agentId: request.agentId, + sourceSessionKey: request.sourceSessionKey, + respond, + }) + ) { + return; + } const requestIdentity = bindConversationOperationIdentity(context, { method: "turn", operationId: request.turnId, @@ -340,7 +400,7 @@ export function createConversationHandlers( respond, execute: async () => await deps.runConversationTurn({ - config: resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }), + config, agentId: request.agentId, senderIsOwner: isAuthenticatedOwner(client), ...(request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {}), diff --git a/src/gateway/server-methods/cron-caller-scope.ts b/src/gateway/server-methods/cron-caller-scope.ts index 861b58dbd66b..78e086f7f61f 100644 --- a/src/gateway/server-methods/cron-caller-scope.ts +++ b/src/gateway/server-methods/cron-caller-scope.ts @@ -78,24 +78,33 @@ export function resolveCronScheduledToolPolicyForCaller( } return policy; } -function parseAgentIdFromSessionRef(value: string | undefined | null): string | undefined { +function parseAgentIdFromSessionRef( + value: string | undefined | null, + fallbackAgentId?: string, +): string | undefined { const trimmed = value?.trim(); - return trimmed ? parseAgentSessionKey(trimmed)?.agentId : undefined; + return trimmed ? (parseAgentSessionKey(trimmed)?.agentId ?? fallbackAgentId) : undefined; } -function parseAgentIdFromCronSessionTarget(value: string | undefined | null): string | undefined { +function parseAgentIdFromCronSessionTarget( + value: string | undefined | null, + fallbackAgentId?: string, +): string | undefined { const trimmed = value?.trim(); return trimmed?.startsWith("session:") - ? parseAgentIdFromSessionRef(trimmed.slice("session:".length)) + ? parseAgentIdFromSessionRef(trimmed.slice("session:".length), fallbackAgentId) : undefined; } function cronJobSessionRefsMatchCaller(job: CronJob, callerScope: CronCallerScope): boolean { - const sessionAgentId = parseAgentIdFromSessionRef(job.sessionKey); + const sessionAgentId = parseAgentIdFromSessionRef(job.sessionKey, callerScope.agentId); if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) { return false; } - const sessionTargetAgentId = parseAgentIdFromCronSessionTarget(job.sessionTarget); + const sessionTargetAgentId = parseAgentIdFromCronSessionTarget( + job.sessionTarget, + callerScope.agentId, + ); return !sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === callerScope.agentId; } @@ -251,11 +260,17 @@ export function cronCreateMatchesCallerScope(params: { if (effectiveAgentId !== params.callerScope.agentId) { return false; } - const sessionAgentId = parseAgentIdFromSessionRef(params.job.sessionKey); + const sessionAgentId = parseAgentIdFromSessionRef( + params.job.sessionKey, + params.callerScope.agentId, + ); if (sessionAgentId && normalizeAgentId(sessionAgentId) !== params.callerScope.agentId) { return false; } - const sessionTargetAgentId = parseAgentIdFromCronSessionTarget(params.job.sessionTarget); + const sessionTargetAgentId = parseAgentIdFromCronSessionTarget( + params.job.sessionTarget, + params.callerScope.agentId, + ); return ( !sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === params.callerScope.agentId ); @@ -288,14 +303,14 @@ export function cronPatchSessionRefsMatchCaller( } const sessionAgentId = "sessionKey" in patch && typeof patch.sessionKey === "string" - ? parseAgentIdFromSessionRef(patch.sessionKey) + ? parseAgentIdFromSessionRef(patch.sessionKey, callerScope.agentId) : undefined; if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) { return false; } const sessionTargetAgentId = "sessionTarget" in patch && typeof patch.sessionTarget === "string" - ? parseAgentIdFromCronSessionTarget(patch.sessionTarget) + ? parseAgentIdFromCronSessionTarget(patch.sessionTarget, callerScope.agentId) : undefined; return !sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === callerScope.agentId; } diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 461e0a17534b..c50341a3b13a 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -54,6 +54,7 @@ import { import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { consumeCronCreatorAuthorityGrant } from "../cron-creator-authority-grant.js"; import { getGatewayProcessInstanceId } from "../process-instance.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import { assertActiveAgentRuntimeAuthority, @@ -404,8 +405,24 @@ export const cronHandlers: GatewayRequestHandlers = { }; const sessionKey = p.sessionKey?.trim() || undefined; const agentId = p.agentId?.trim() || undefined; + const callerScope = readCronCallerScope(client); + const requestedOwner = sessionKey + ? resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + sessionKey, + agentId ?? callerScope?.agentId, + ) + : undefined; + if (requestedOwner && !requestedOwner.ok) { + respond(false, undefined, requestedOwner.error); + return; + } + const resolvedAgentId = requestedOwner?.agentId ?? callerScope?.agentId ?? agentId; if (sessionKey && isAgentHarnessSessionKey(sessionKey)) { - const loaded = loadGatewaySessionEntryReadOnly(sessionKey, agentId ? { agentId } : {}); + const loaded = loadGatewaySessionEntryReadOnly( + sessionKey, + resolvedAgentId ? { agentId: resolvedAgentId } : {}, + ); const harnessSessionError = loaded.entry ? resolveAgentHarnessSessionStoreEntryError(loaded.canonicalKey, loaded.entry) : AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE; @@ -432,7 +449,6 @@ export const cronHandlers: GatewayRequestHandlers = { const sessionKeyAgentId = sessionKey ? parseAgentSessionKey(sessionKey)?.agentId?.trim().toLowerCase() : undefined; - const callerScope = readCronCallerScope(client); if (callerScope && agentId && normalizeAgentId(agentId) !== callerScope.agentId) { respond( false, @@ -474,7 +490,7 @@ export const cronHandlers: GatewayRequestHandlers = { mode: p.mode, text: p.text, ...(sessionKey ? { sessionKey } : {}), - ...(callerScope ? { agentId: callerScope.agentId } : agentId ? { agentId } : {}), + ...(resolvedAgentId ? { agentId: resolvedAgentId } : {}), }); respond(true, result, undefined); }, diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 832fbd28b652..a70dab96d37d 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -1217,6 +1217,7 @@ describe("cron method validation", () => { }); expect(context.cron.wake).toHaveBeenCalledWith({ + agentId: "main", mode: "now", text: "ping", sessionKey, @@ -1238,6 +1239,7 @@ describe("cron method validation", () => { }); expect(context.cron.wake).toHaveBeenCalledWith({ + agentId: "main", mode: "now", text: "ping", sessionKey, @@ -3945,6 +3947,14 @@ describe("cron method validation", () => { }); describe("wake", () => { + beforeEach(() => { + setRuntimeConfig({ + agents: { + entries: { main: {}, ops: {}, "agent-123": {}, "agent-456": {} }, + }, + }); + }); + it("forwards sessionKey to context.cron.wake when provided", async () => { const { context, respond } = await invokeWake({ mode: "now", @@ -3952,6 +3962,7 @@ describe("cron method validation", () => { sessionKey: "agent:main:telegram:dm:42", }); expect(context.cron.wake).toHaveBeenCalledWith({ + agentId: "main", mode: "now", text: "ping", sessionKey: "agent:main:telegram:dm:42", @@ -4003,7 +4014,10 @@ describe("cron method validation", () => { agentId: "ops", }); expect(context.cron.wake).not.toHaveBeenCalled(); - expectResponseError(respond, { code: "INVALID_REQUEST", messageIncludes: "contradicts" }); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "does not match session key agent", + }); }); it("accepts an explicit agentId matching the agent that owns the sessionKey", async () => { @@ -4030,7 +4044,7 @@ describe("cron method validation", () => { { name: "sessionKey", params: { sessionKey: "agent:agent-456:discord:thread-xyz" }, - message: "wake sessionKey outside caller scope", + message: "does not match session key agent", }, ])("rejects a cross-agent $name for agent-runtime callers", async ({ params, message }) => { const { context, respond } = await invokeWake( diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 620201d85391..405615a2ca53 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../../config/config.js"; const getRuntimeConfig = vi.hoisted(() => vi.fn(() => ({}) as OpenClawConfig)); @@ -240,6 +241,7 @@ const expectEmbeddingErrorResponse = (respond: ReturnType, error: describe("doctor.memory agent targeting", () => { beforeEach(() => { listAgentIds.mockClear(); + resolveDefaultAgentId.mockReset().mockReturnValue("main"); resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw"); getMemorySearchManager.mockReset().mockResolvedValue({ manager: null, @@ -257,6 +259,31 @@ describe("doctor.memory agent targeting", () => { dedupeDreamDiaryEntries.mockReset().mockResolvedValue({ removed: 0, kept: 0 }); }); + it.each(DOCTOR_MEMORY_TARGET_METHODS)( + "%s returns typed selection-required when agentId is omitted", + async (method) => { + resolveDefaultAgentId.mockImplementationOnce(() => { + throw new AgentSelectionRequiredError(["ops", "research"], { + surface: "doctor memory", + hint: "Pass agentId to select a configured agent.", + }); + }); + const respond = vi.fn(); + + await invokeDoctorMemory(method, respond, { includeCron: true }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining("agent"), + }), + ); + expect(resolveAgentWorkspaceDir).not.toHaveBeenCalled(); + }, + ); + it.each(DOCTOR_MEMORY_TARGET_METHODS)( "%s rejects an unknown agent before resolving agent state", async (method) => { diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 7e3e3a314e66..59cf760fe4f1 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -6,6 +6,7 @@ import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number- import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import { listAgentIds, resolveAgentWorkspaceDir, @@ -659,7 +660,21 @@ function resolveDoctorMemoryAgent( } const requestedAgentId = typeof rawAgentId === "string" ? normalizeAgentId(rawAgentId) : undefined; - const agentId = requestedAgentId ?? resolveDefaultAgentId(cfg); + let agentId = requestedAgentId; + if (!agentId) { + try { + agentId = resolveDefaultAgentId(cfg, { + surface: "doctor memory", + hint: "Pass agentId to select a configured agent.", + }); + } catch (error) { + if (!(error instanceof AgentSelectionRequiredError)) { + throw error; + } + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); + return null; + } + } if (requestedAgentId && !listAgentIds(cfg).includes(agentId)) { respond( false, diff --git a/src/gateway/server-methods/hooks-status.test.ts b/src/gateway/server-methods/hooks-status.test.ts index 7e86f9569dc5..72c32794f4cc 100644 --- a/src/gateway/server-methods/hooks-status.test.ts +++ b/src/gateway/server-methods/hooks-status.test.ts @@ -84,6 +84,46 @@ async function dispatchHooksStatus(params: { } describe("hooks.status", () => { + it("returns typed selection-required for an ownerless explicit fleet", async () => { + const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-status-")); + tempDirs.push(workspaceDir); + const fixture = createHookRegistry(workspaceDir); + const config = { + ...fixture.config, + agents: { + ownership: "explicit", + defaults: { workspace: workspaceDir }, + list: [{ id: "ops" }, { id: "research", workspace: workspaceDir }], + }, + }; + + const missing = await dispatchHooksStatus({ + ...fixture, + config, + scopes: ["operator.read"], + }); + expect(missing).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("agent"), + }), + ); + + const selected = await dispatchHooksStatus({ + ...fixture, + config, + scopes: ["operator.read"], + requestParams: { agentId: "research" }, + }); + expect(selected).toHaveBeenCalledWith( + true, + expect.objectContaining({ workspaceDir }), + undefined, + ); + }); + it("returns registered plugin hooks from the request-attached live registry", async () => { const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-status-")); tempDirs.push(workspaceDir); diff --git a/src/gateway/server-methods/hooks-status.ts b/src/gateway/server-methods/hooks-status.ts index 6cf715448e93..605fa868e56b 100644 --- a/src/gateway/server-methods/hooks-status.ts +++ b/src/gateway/server-methods/hooks-status.ts @@ -1,9 +1,10 @@ import { validateHooksStatusParams } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { buildWorkspaceHookStatus } from "../../hooks/hooks-status.js"; import { loadWorkspaceHookEntries } from "../../hooks/workspace.js"; import { getActivePluginRegistry } from "../../plugins/runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; +import { resolveAgentIdOrRespondError } from "./agent-id-shared.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -14,7 +15,16 @@ export const hooksStatusHandlers: GatewayRequestHandlers = { return; } const config = context.getRuntimeConfig(); - const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const resolved = resolveAgentIdOrRespondError({ + rawAgentId: params.agentId, + respond, + cfg: config, + normalize: (value) => (typeof value === "string" ? value.trim() || undefined : undefined), + }); + if (!resolved) { + return; + } + const workspaceDir = resolveAgentWorkspaceDir(config, resolved.agentId); const registry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? getActivePluginRegistry(); // Native plugin hooks are registration facts. Reuse the request's live registry instead diff --git a/src/gateway/server-methods/mcp-app.test.ts b/src/gateway/server-methods/mcp-app.test.ts index dd7b37cb98d4..87a8f3ccc3e4 100644 --- a/src/gateway/server-methods/mcp-app.test.ts +++ b/src/gateway/server-methods/mcp-app.test.ts @@ -35,6 +35,7 @@ import { mcpAppHandlers } from "./mcp-app.js"; const view = { viewId: "cv_app", + agentId: "main", sessionId: "session-1", serverName: "demo", toolName: "show", @@ -91,6 +92,7 @@ async function invoke( method: keyof typeof mcpAppHandlers, params: Record, mcpAppsEnabled = true, + config: Record = {}, ) { const respond = vi.fn(); await expectDefined( @@ -102,6 +104,7 @@ async function invoke( context: { getMcpAppSandboxPort: () => 18790, getRuntimeConfig: () => ({ + ...config, mcp: { apps: { enabled: mcpAppsEnabled, sandboxOrigin: "https://apps.example.com" } }, }), }, @@ -129,6 +132,39 @@ describe("MCP App gateway bridge", () => { }); }); + it("returns typed selection-required for a bare key without an owner", async () => { + const config = { + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }; + const missing = await invoke( + "mcp.app.view", + { sessionKey: "global", viewId: "cv_app" }, + true, + config, + ); + expect(missing).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("agent"), + }), + ); + + await invoke( + "mcp.app.view", + { sessionKey: "global", agentId: "research", viewId: "cv_app" }, + true, + config, + ); + expect(mocks.restoreMcpAppView).toHaveBeenCalledWith( + expect.objectContaining({ sessionKey: "global", agentId: "research" }), + ); + }); + it("returns the ephemeral view payload only for the bound session", async () => { const respond = await invoke("mcp.app.view", { sessionKey: "agent:main:main", @@ -172,10 +208,39 @@ describe("MCP App gateway bridge", () => { expect(respond.mock.calls[0]?.[0]).toBe(true); expect(respond.mock.calls[0]?.[1]).toMatchObject({ html: "demo" }); - expect(mocks.getMcpAppViewLeaseForSession).toHaveBeenCalledWith("cv_app", "agent:main:main"); + expect(mocks.getMcpAppViewLeaseForSession).toHaveBeenCalledWith( + "cv_app", + "agent:main:main", + "main", + ); expect(mocks.restoreMcpAppView).not.toHaveBeenCalled(); }); + it("does not reuse a live bare-key view owned by another agent", async () => { + const nativeRuntime = runtime(); + const nativeView = { ...view, agentId: "ops", runtime: nativeRuntime }; + mocks.peekSessionMcpRuntime.mockReturnValue(undefined); + mocks.getMcpAppViewLeaseForSession.mockImplementation( + (_viewId: string, _sessionKey: string, agentId: string) => + agentId === "ops" ? nativeView : undefined, + ); + + const respond = await invoke( + "mcp.app.view", + { sessionKey: "global", agentId: "research", viewId: "cv_app" }, + true, + { + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }, + ); + + expect(respond.mock.calls[0]?.[0]).toBe(false); + expect(mocks.getMcpAppViewLeaseForSession).toHaveBeenCalledWith("cv_app", "global", "research"); + }); + it("preserves the existing view payload when standalone ticket issuance is unavailable", async () => { mocks.createMcpAppStandaloneTicket.mockImplementation(() => { throw new Error("ticket unavailable"); diff --git a/src/gateway/server-methods/mcp-app.ts b/src/gateway/server-methods/mcp-app.ts index 2a79bf0454fb..cc1e337e7147 100644 --- a/src/gateway/server-methods/mcp-app.ts +++ b/src/gateway/server-methods/mcp-app.ts @@ -5,6 +5,7 @@ import { } from "../../../packages/gateway-protocol/src/index.js"; import { updateMcpAppModelContext } from "../../agents/mcp-app-model-context.js"; import { buildMcpAppSandboxPath } from "../../agents/mcp-app-sandbox.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { logWarn } from "../../logger.js"; import { @@ -16,6 +17,7 @@ import { withMcpAppActiveView, } from "../mcp-app-operations.js"; import { createMcpAppStandaloneTicket } from "../mcp-app-standalone.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import type { GatewayRequestHandlers } from "./types.js"; function requireString(params: Record, key: string): string { @@ -31,12 +33,31 @@ function optionalCursor(params: Record): { cursor?: string } | return typeof cursor === "string" && cursor.trim() ? { cursor: cursor.trim() } : undefined; } +class McpAppRequestError extends Error { + constructor(readonly shape: ReturnType) { + super(shape.message); + } +} + +function resolveMcpAppSessionOwner(params: Record, cfg: OpenClawConfig): string { + const sessionKey = requireString(params, "sessionKey"); + const explicitAgentId = + typeof params.agentId === "string" && params.agentId.trim() ? params.agentId.trim() : undefined; + const owner = resolveRequestedSessionAgentId(cfg, sessionKey, explicitAgentId); + if (!owner.ok) { + throw new McpAppRequestError(owner.error); + } + return owner.agentId; +} + async function runOperation( params: Record, operation: McpAppOperation, + cfg: OpenClawConfig, ): Promise { const active = await resolveMcpAppActiveView({ sessionKey: requireString(params, "sessionKey"), + agentId: resolveMcpAppSessionOwner(params, cfg), viewId: requireString(params, "viewId"), }); return await executeMcpAppOperation(active, operation); @@ -52,13 +73,15 @@ async function handle( respond( false, undefined, - errorShape( - ErrorCodes.UNAVAILABLE, - formatErrorMessage(error), - error instanceof McpAppViewExpiredError - ? { details: { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED } } - : undefined, - ), + error instanceof McpAppRequestError + ? error.shape + : errorShape( + ErrorCodes.UNAVAILABLE, + formatErrorMessage(error), + error instanceof McpAppViewExpiredError + ? { details: { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED } } + : undefined, + ), ); } } @@ -68,6 +91,7 @@ export const mcpAppHandlers: GatewayRequestHandlers = { await handle(respond, async () => { const active = await resolveMcpAppActiveView({ sessionKey: requireString(params, "sessionKey"), + agentId: resolveMcpAppSessionOwner(params, context.getRuntimeConfig()), viewId: requireString(params, "viewId"), cfg: context.getRuntimeConfig(), }); @@ -120,10 +144,11 @@ export const mcpAppHandlers: GatewayRequestHandlers = { }); }); }, - "mcp.app.updateModelContext": async ({ respond, params }) => { + "mcp.app.updateModelContext": async ({ respond, params, context }) => { await handle(respond, async () => { const active = await resolveMcpAppActiveView({ sessionKey: requireString(params, "sessionKey"), + agentId: resolveMcpAppSessionOwner(params, context.getRuntimeConfig()), viewId: requireString(params, "viewId"), }); return await withMcpAppActiveView(active, "read", async () => { @@ -133,54 +158,74 @@ export const mcpAppHandlers: GatewayRequestHandlers = { }); }); }, - "mcp.app.callTool": async ({ respond, params }) => { + "mcp.app.callTool": async ({ respond, params, context }) => { await handle( respond, async () => - await runOperation(params, { - method: "tools/call", - params: { - name: requireString(params, "toolName"), - arguments: (params.arguments ?? {}) as Record, + await runOperation( + params, + { + method: "tools/call", + params: { + name: requireString(params, "toolName"), + arguments: (params.arguments ?? {}) as Record, + }, }, - }), + context.getRuntimeConfig(), + ), ); }, - "mcp.app.listTools": async ({ respond, params }) => { + "mcp.app.listTools": async ({ respond, params, context }) => { await handle( respond, async () => - await runOperation(params, { method: "tools/list", params: optionalCursor(params) ?? {} }), + await runOperation( + params, + { method: "tools/list", params: optionalCursor(params) ?? {} }, + context.getRuntimeConfig(), + ), ); }, - "mcp.app.listResources": async ({ respond, params }) => { + "mcp.app.listResources": async ({ respond, params, context }) => { await handle( respond, async () => - await runOperation(params, { - method: "resources/list", - params: optionalCursor(params) ?? {}, - }), + await runOperation( + params, + { + method: "resources/list", + params: optionalCursor(params) ?? {}, + }, + context.getRuntimeConfig(), + ), ); }, - "mcp.app.listResourceTemplates": async ({ respond, params }) => { + "mcp.app.listResourceTemplates": async ({ respond, params, context }) => { await handle( respond, async () => - await runOperation(params, { - method: "resources/templates/list", - params: optionalCursor(params) ?? {}, - }), + await runOperation( + params, + { + method: "resources/templates/list", + params: optionalCursor(params) ?? {}, + }, + context.getRuntimeConfig(), + ), ); }, - "mcp.app.readResource": async ({ respond, params }) => { + "mcp.app.readResource": async ({ respond, params, context }) => { await handle( respond, async () => - await runOperation(params, { - method: "resources/read", - params: { uri: requireString(params, "uri") }, - }), + await runOperation( + params, + { + method: "resources/read", + params: { uri: requireString(params, "uri") }, + }, + context.getRuntimeConfig(), + ), ); }, }; diff --git a/src/gateway/server-methods/memory-search.test.ts b/src/gateway/server-methods/memory-search.test.ts index f4019d3c6fd1..e92525a7e61b 100644 --- a/src/gateway/server-methods/memory-search.test.ts +++ b/src/gateway/server-methods/memory-search.test.ts @@ -1,5 +1,6 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { MemorySearchResult } from "../../memory-host-sdk/host/types.js"; import { @@ -129,6 +130,33 @@ describe("memory.search gateway method", () => { expect(getActiveMemorySearchManagerCore).not.toHaveBeenCalled(); }); + it("returns typed selection-required when an explicit fleet omits agentId", async () => { + const cfg = createConfig(testState.workspaceDir); + cfg.agents = { + ...cfg.agents, + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }; + resolveDefaultAgentId.mockImplementationOnce(() => { + throw new AgentSelectionRequiredError(["ops", "research"], { + surface: "memory search", + hint: "Pass agentId to select a configured agent.", + }); + }); + + const respond = await invokeMemorySearch({ query: "lantern" }, cfg); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("agent"), + }), + ); + expect(getActiveMemorySearchManagerCore).not.toHaveBeenCalled(); + }); + it("rejects a non-string agentId without acquiring a manager", async () => { const cfg = createConfig(testState.workspaceDir); @@ -215,7 +243,10 @@ describe("memory.search gateway method", () => { const respond = await invokeMemorySearch({ query: "lantern" }, cfg); - expect(resolveDefaultAgentId).toHaveBeenCalledWith(cfg); + expect(resolveDefaultAgentId).toHaveBeenCalledWith(cfg, { + surface: "memory search", + hint: "Pass agentId to select a configured agent.", + }); expect(respond).toHaveBeenCalledWith( false, undefined, diff --git a/src/gateway/server-methods/memory-search.ts b/src/gateway/server-methods/memory-search.ts index 52037c1bb204..b4c62154993e 100644 --- a/src/gateway/server-methods/memory-search.ts +++ b/src/gateway/server-methods/memory-search.ts @@ -1,4 +1,5 @@ import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { @@ -107,7 +108,21 @@ export const memorySearchHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agentId")); return; } - const agentId = requestedAgentId ?? resolveDefaultAgentId(cfg); + let agentId = requestedAgentId; + if (!agentId) { + try { + agentId = resolveDefaultAgentId(cfg, { + surface: "memory search", + hint: "Pass agentId to select a configured agent.", + }); + } catch (error) { + if (!(error instanceof AgentSelectionRequiredError)) { + throw error; + } + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); + return; + } + } let acquired: Awaited>; try { // Use the transient CLI lifecycle so request cleanup cannot close a shared manager. diff --git a/src/gateway/server-methods/model-auth-agent-scope.ts b/src/gateway/server-methods/model-auth-agent-scope.ts index 9371abf36f49..23ff529741cf 100644 --- a/src/gateway/server-methods/model-auth-agent-scope.ts +++ b/src/gateway/server-methods/model-auth-agent-scope.ts @@ -4,21 +4,37 @@ import { GatewayErrorDetailCodes, errorShape, } from "../../../packages/gateway-protocol/src/index.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import { listAgentIds, resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeAgentId } from "../../routing/session-key.js"; type ModelAuthAgentScopeResult = | { ok: true; agentId: string; agentDir: string } - | { ok: false; agentId: string }; + | { ok: false; agentId: string; error?: ReturnType }; /** Resolves model-auth RPC scope without letting explicit garbage reach the default store. */ export function resolveModelAuthAgentScope( cfg: OpenClawConfig, requestedAgentId: unknown, ): ModelAuthAgentScopeResult { - const defaultAgentId = resolveDefaultAgentId(cfg); if (requestedAgentId === undefined || requestedAgentId === "") { + let defaultAgentId: string; + try { + defaultAgentId = resolveDefaultAgentId(cfg, { + surface: "model auth", + hint: "Pass agentId to select a configured agent.", + }); + } catch (error) { + if (!(error instanceof AgentSelectionRequiredError)) { + throw error; + } + return { + ok: false, + agentId: "", + error: errorShape(ErrorCodes.INVALID_REQUEST, error.message), + }; + } return { ok: true, agentId: defaultAgentId, @@ -47,7 +63,11 @@ export function resolveModelAuthAgentScope( return { ok: true, agentId, agentDir: resolveAgentDir(cfg, agentId) }; } -export function unknownModelAuthAgentIdError(agentId: string) { +export function modelAuthAgentScopeError(scope: Extract) { + return scope.error ?? unknownModelAuthAgentIdError(scope.agentId); +} + +function unknownModelAuthAgentIdError(agentId: string) { const details: UnknownAgentIdErrorDetails = { code: GatewayErrorDetailCodes.UNKNOWN_AGENT_ID, agentId, diff --git a/src/gateway/server-methods/models-auth-status-usage-cache.ts b/src/gateway/server-methods/models-auth-status-usage-cache.ts index 12fea8de1ee1..3a6847a49a3c 100644 --- a/src/gateway/server-methods/models-auth-status-usage-cache.ts +++ b/src/gateway/server-methods/models-auth-status-usage-cache.ts @@ -1,4 +1,3 @@ -import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; // Stale-while-revalidate cache for models.authStatus provider usage enrichment. import { ensureAuthProfileStore, @@ -10,6 +9,10 @@ import { fingerprintAuthProfileOwnerShape, fingerprintResolvedProviderAuth, } from "../../agents/execution-auth-binding.js"; +import { + resolveLegacyInheritedAuthAgentId, + resolveLegacyInheritedAuthDir, +} from "../../agents/legacy-inherited-auth-dir.js"; import { resolveEnvApiKey } from "../../agents/model-auth-env.js"; import { resolveUsableCustomProviderApiKey } from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -302,8 +305,8 @@ export async function loadUsageStatusStaleWhileRevalidate(params: { config: OpenClawConfig; now?: number; }): Promise { - const agentId = resolveDefaultAgentId(params.config); - const agentDir = resolveAgentDir(params.config, agentId); + const agentId = resolveLegacyInheritedAuthAgentId(params.config); + const agentDir = resolveLegacyInheritedAuthDir(params.config); const store = ensureAuthProfileStore(agentDir, { externalCli: externalCliDiscoveryForConfigStatus({ cfg: params.config }), }); diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index fc800acdf832..5108ac488099 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -9,6 +9,7 @@ import type { AuthHealthSummary } from "../../agents/auth-health.js"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js"; import type { UsageSummary } from "../../infra/provider-usage.types.js"; +import { resolveProviderAuthLookupMaps } from "../../secrets/provider-env-vars.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { createChatRunState } from "../server-chat-state.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; @@ -229,6 +230,13 @@ async function readAuthStatus(params: Record = {}) { } function resetAuthStatusMocks(): void { + for (const envVarNames of Object.values( + resolveProviderAuthLookupMaps({ env: {} }).envCandidateMap, + )) { + for (const envVarName of envVarNames) { + vi.stubEnv(envVarName, ""); + } + } vi.stubEnv("OPENAI_API_KEY", ""); vi.clearAllMocks(); invalidateModelAuthStatusCache(); diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index f3711caf5a6c..46dcc6a9387a 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -52,10 +52,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { refreshActiveProviderAuthRuntimeSnapshot } from "../../secrets/runtime.js"; import { abortChatRunsForProvider, type ChatAbortOps } from "../chat-abort.js"; import { formatForLog } from "../ws-log.js"; -import { - resolveModelAuthAgentScope, - unknownModelAuthAgentIdError, -} from "./model-auth-agent-scope.js"; +import { modelAuthAgentScopeError, resolveModelAuthAgentScope } from "./model-auth-agent-scope.js"; import { clearModelAuthStatusUsageCache, fingerprintProviderUsageCredentials, @@ -476,7 +473,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { const cfg = context.getRuntimeConfig(); const scope = resolveModelAuthAgentScope(cfg, params.agentId); if (!scope.ok) { - respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); + respond(false, undefined, modelAuthAgentScopeError(scope)); return; } const { agentDir } = scope; @@ -569,7 +566,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { let cfg = context.getRuntimeConfig(); let scope = resolveModelAuthAgentScope(cfg, params.agentId); if (!scope.ok) { - respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); + respond(false, undefined, modelAuthAgentScopeError(scope)); return; } if (refreshRequested) { @@ -577,7 +574,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { cfg = context.getRuntimeConfig(); scope = resolveModelAuthAgentScope(cfg, params.agentId); if (!scope.ok) { - respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); + respond(false, undefined, modelAuthAgentScopeError(scope)); return; } } diff --git a/src/gateway/server-methods/models-probe.test.ts b/src/gateway/server-methods/models-probe.test.ts index 07af4d31797f..0bc86555a942 100644 --- a/src/gateway/server-methods/models-probe.test.ts +++ b/src/gateway/server-methods/models-probe.test.ts @@ -1,6 +1,7 @@ // Model probe RPC tests cover validation, normalization, bounded execution, and redacted mapping. import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import type { AuthProbeSummary } from "../../commands/models/list.probe.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; @@ -89,6 +90,28 @@ describe("models.probe", () => { expect(mocks.runAuthProbes).not.toHaveBeenCalled(); }); + it("returns typed selection-required when agentId is omitted", async () => { + mocks.resolveDefaultAgentId.mockImplementationOnce(() => { + throw new AgentSelectionRequiredError(["main", "writer"], { + surface: "model auth", + hint: "Pass agentId to select a configured agent.", + }); + }); + const { options, respond } = createOptions({ provider: "openai" }); + + await handler(options); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("agent"), + }), + ); + expect(mocks.runAuthProbes).not.toHaveBeenCalled(); + }); + it("normalizes providers, trims profiles, and clamps the timeout", async () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/gateway/server-methods/models-probe.ts b/src/gateway/server-methods/models-probe.ts index c31fec5cd7d5..782ada103f00 100644 --- a/src/gateway/server-methods/models-probe.ts +++ b/src/gateway/server-methods/models-probe.ts @@ -14,10 +14,7 @@ import { runAuthProbes, } from "../../commands/models/list.probe.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { - resolveModelAuthAgentScope, - unknownModelAuthAgentIdError, -} from "./model-auth-agent-scope.js"; +import { modelAuthAgentScopeError, resolveModelAuthAgentScope } from "./model-auth-agent-scope.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -118,7 +115,7 @@ export const modelsProbeHandlers: GatewayRequestHandlers = { const cfg = context.getRuntimeConfig(); const scope = resolveModelAuthAgentScope(cfg, request.agentId); if (!scope.ok) { - respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); + respond(false, undefined, modelAuthAgentScopeError(scope)); return; } const workspaceDir = resolveAgentWorkspaceDir(cfg, scope.agentId); diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index cc524b27c273..710b75a8476b 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -46,6 +46,7 @@ function createDemoOAuthStore(params: { access: string; expires: number }) { function requestModelsList(params: { view: "default" | "configured" | "provider-config" | "all"; + agentId?: string; respond?: ReturnType; runtimeConfig?: OpenClawConfig; getRuntimeConfig?: () => OpenClawConfig; @@ -56,7 +57,6 @@ function requestModelsList(params: { workspaceDir?: string; }) => Promise>>; reqId?: string; - agentId?: string; includeProviderCapabilities?: boolean; }) { const respond = params.respond ?? vi.fn(); @@ -134,6 +134,38 @@ describe("models.list", () => { ); }); + it("returns typed selection-required until an explicit fleet selects an agent", async () => { + const runtimeConfig = { + agents: { + ownership: "explicit" as const, + list: [{ id: "ops" }, { id: "research" }], + }, + }; + const missing = requestModelsList({ + view: "configured", + runtimeConfig, + loadGatewayModelCatalog: vi.fn(async () => []), + }); + await missing.request; + expect(missing.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("agent"), + }), + ); + + const selected = requestModelsList({ + view: "configured", + agentId: "research", + runtimeConfig, + loadGatewayModelCatalog: vi.fn(async () => []), + }); + await selected.request; + expect(selected.respond).toHaveBeenCalledWith(true, { models: [] }, undefined); + }); + it("uses the replacement owner config for the whole catalog projection", async () => { const initialConfig = { agents: { defaults: { models: { "test/old": {} } } }, diff --git a/src/gateway/server-methods/models.ts b/src/gateway/server-methods/models.ts index 04cf089e6a24..144a5d085cb7 100644 --- a/src/gateway/server-methods/models.ts +++ b/src/gateway/server-methods/models.ts @@ -1,6 +1,8 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; // Models gateway methods expose model catalog browse results without triggering // auth probes or fresh provider discovery on each request. import { validateModelsListParams } from "../../../packages/gateway-protocol/src/index.js"; +import { resolveAgentIdOrRespondError } from "./agent-id-shared.js"; import { buildModelsListResult } from "./models-list-result.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -15,10 +17,18 @@ export const modelsHandlers: GatewayRequestHandlers = { if (!assertValidParams(params, validateModelsListParams, "models.list", respond)) { return; } - const agentId = typeof params.agentId === "string" ? params.agentId : undefined; + const resolved = resolveAgentIdOrRespondError({ + rawAgentId: params.agentId, + respond, + cfg: context.getRuntimeConfig(), + normalize: normalizeOptionalString, + }); + if (!resolved) { + return; + } respond( true, - await buildModelsListResult({ context, params, ...(agentId ? { agentId } : {}) }), + await buildModelsListResult({ context, agentId: resolved.agentId, params }), undefined, ); }, diff --git a/src/gateway/server-methods/plugin-approval.agent-runtime.test.ts b/src/gateway/server-methods/plugin-approval.agent-runtime.test.ts index 7ce413d73e82..f22efba7599a 100644 --- a/src/gateway/server-methods/plugin-approval.agent-runtime.test.ts +++ b/src/gateway/server-methods/plugin-approval.agent-runtime.test.ts @@ -62,6 +62,7 @@ function requestOptions(params: { respond: vi.fn(), context: { broadcast: vi.fn(), + getRuntimeConfig: () => ({ agents: { list: [{ id: "main" }] } }), logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, hasExecApprovalClients: () => true, validateAgentRuntimeApprovalAuthority: params.validateAuthority ?? (() => true), diff --git a/src/gateway/server-methods/plugin-approval.ts b/src/gateway/server-methods/plugin-approval.ts index c4e5eb70be18..331dfd50b32b 100644 --- a/src/gateway/server-methods/plugin-approval.ts +++ b/src/gateway/server-methods/plugin-approval.ts @@ -16,6 +16,8 @@ import type { } from "../../infra/plugin-approvals.js"; import { resolvePluginApprovalTimeoutMs } from "../../infra/plugin-approvals.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js"; import { runApprovalRequestDeliveries } from "./approval-request-delivery.js"; import { bindApprovalRequesterMetadata, @@ -112,6 +114,29 @@ export function createPluginApprovalHandlers( const normalizeTrimmedString = (value?: string | null): string | null => normalizeOptionalString(value) || null; + const rawSessionKey = normalizeOptionalString( + trustedAgentRuntime?.sessionKey ?? p.sessionKey, + ); + const sessionOwner = rawSessionKey + ? resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + rawSessionKey, + normalizeOptionalString(trustedAgentRuntime?.agentId ?? p.agentId), + ) + : undefined; + if (sessionOwner && !sessionOwner.ok) { + respond(false, undefined, sessionOwner.error); + return; + } + const sessionKey = + rawSessionKey && sessionOwner?.ok + ? resolveStoredSessionKeyForAgentStore({ + cfg: context.getRuntimeConfig(), + agentId: sessionOwner.agentId, + sessionKey: rawSessionKey, + }) + : null; + const request: PluginApprovalRequestPayload = { pluginId: trustedAgentRuntime?.approvalOwnerPluginId ?? p.pluginId ?? null, title: p.title, @@ -127,8 +152,10 @@ export function createPluginApprovalHandlers( }), } : {}), - agentId: trustedAgentRuntime?.agentId ?? p.agentId ?? null, - sessionKey: trustedAgentRuntime?.sessionKey ?? p.sessionKey ?? null, + agentId: + trustedAgentRuntime?.agentId ?? + (sessionOwner?.ok ? sessionOwner.agentId : (p.agentId ?? null)), + sessionKey, runId: trustedAgentRuntime?.operationalRunInstance.runId ?? null, turnSourceChannel: trustedAgentRuntime ? normalizeTrimmedString(trustedAgentRuntime.turnSourceChannel) diff --git a/src/gateway/server-methods/plugin-host-hooks.ts b/src/gateway/server-methods/plugin-host-hooks.ts index 291dde36157d..39f2f3a2064f 100644 --- a/src/gateway/server-methods/plugin-host-hooks.ts +++ b/src/gateway/server-methods/plugin-host-hooks.ts @@ -22,6 +22,8 @@ import { type JsonSchemaValue, } from "../../plugins/schema-validator.js"; import { ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../operator-scopes.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -96,7 +98,7 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = { } respond(true, result, undefined); }, - "plugins.sessionAction": async ({ params, client, respond }) => { + "plugins.sessionAction": async ({ params, client, respond, context }) => { if ( !assertValidParams( params, @@ -109,7 +111,26 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = { } const pluginId = normalizeOptionalString(params.pluginId); const actionId = normalizeOptionalString(params.actionId); - const sessionKey = normalizeOptionalString(params.sessionKey); + const rawSessionKey = normalizeOptionalString(params.sessionKey); + const sessionOwner = rawSessionKey + ? resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + rawSessionKey, + normalizeOptionalString(params.agentId), + ) + : undefined; + if (sessionOwner && !sessionOwner.ok) { + respond(false, undefined, sessionOwner.error); + return; + } + const sessionKey = + rawSessionKey && sessionOwner?.ok + ? resolveStoredSessionKeyForAgentStore({ + cfg: context.getRuntimeConfig(), + agentId: sessionOwner.agentId, + sessionKey: rawSessionKey, + }) + : undefined; if (!pluginId || !actionId) { respond( false, @@ -207,6 +228,7 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = { pluginId, actionId, ...(sessionKey ? { sessionKey } : {}), + ...(sessionOwner?.ok ? { agentId: sessionOwner.agentId } : {}), ...(params.payload !== undefined ? { payload: params.payload } : {}), client: { ...(client?.connId ? { connId: client.connId } : {}), diff --git a/src/gateway/server-methods/question.test.ts b/src/gateway/server-methods/question.test.ts index 98e46583ad25..b8d57ae80aa9 100644 --- a/src/gateway/server-methods/question.test.ts +++ b/src/gateway/server-methods/question.test.ts @@ -29,7 +29,10 @@ async function call(method: string, params: Record) { respond, client: null, isWebchatConnect: () => false, - context: { broadcast } as unknown as GatewayRequestHandlerOptions["context"], + context: { + broadcast, + getRuntimeConfig: () => ({}), + } as unknown as GatewayRequestHandlerOptions["context"], }); const response = calls[0]; if (!response) { diff --git a/src/gateway/server-methods/question.ts b/src/gateway/server-methods/question.ts index 6bee3e9f8b4d..c4c485fe45cd 100644 --- a/src/gateway/server-methods/question.ts +++ b/src/gateway/server-methods/question.ts @@ -21,6 +21,8 @@ import { QuestionManagerError, QuestionManagerErrorCodes, } from "../question-manager.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; const DEFAULT_QUESTION_TIMEOUT_MS = 15 * 60 * 1_000; @@ -95,11 +97,34 @@ export function createQuestionHandlers(manager: QuestionManager): GatewayRequest } const request = params as QuestionRequestParams; try { + const requestedSession = request.sessionKey + ? resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + request.sessionKey, + request.agentId, + ) + : undefined; + if (requestedSession && !requestedSession.ok) { + respond(false, undefined, requestedSession.error); + return; + } + const sessionKey = + request.sessionKey && requestedSession?.ok + ? resolveStoredSessionKeyForAgentStore({ + cfg: context.getRuntimeConfig(), + agentId: requestedSession.agentId, + sessionKey: request.sessionKey, + }) + : undefined; const record = manager.request({ ...(request.id ? { id: request.id } : {}), questions: normalizeQuestions(request), - ...(request.agentId ? { agentId: request.agentId } : {}), - ...(request.sessionKey ? { sessionKey: request.sessionKey } : {}), + ...(requestedSession?.ok + ? { agentId: requestedSession.agentId } + : request.agentId + ? { agentId: request.agentId } + : {}), + ...(sessionKey ? { sessionKey } : {}), ...(request.runId ? { runId: request.runId } : {}), timeoutMs: request.timeoutMs ?? DEFAULT_QUESTION_TIMEOUT_MS, onResolved: (event) => { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 7491b0e40d2b..77b046d6c8a5 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -135,7 +135,8 @@ function messageActionContextFromSessionKeyForTests(sessionKey: string): { }; } -vi.mock("../../agents/agent-scope.js", () => ({ +vi.mock("../../agents/agent-scope.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveSessionAgentId: ({ sessionKey, }: { @@ -2282,6 +2283,36 @@ describe("gateway send mirroring", () => { }); }); + it("uses the persisted fixed-store owner for a bare send session key", async () => { + mockDeliverySuccess("m-persisted-owner"); + const context = { + ...makeContext(), + getRuntimeConfig: () => ({ + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + }), + } as unknown as GatewayRequestContext; + + const { respond } = await runSendWithClient( + { + to: "channel:C1", + message: "hello", + channel: "slack", + sessionKey: "global", + idempotencyKey: "idem-persisted-owner", + }, + null, + context, + ); + + expect(firstRespondCall(respond)[0]).toBe(true); + expect(deliveryCall()?.session?.agentId).toBe("ops"); + }); + it("rejects a missing reserved agent-harness session before persistence or delivery", async () => { const sessionKey = "agent:main:harness:codex:supervision:missing"; @@ -2378,22 +2409,30 @@ describe("gateway send mirroring", () => { expect(deliveryCall()?.mirror?.agentId).toBe("work"); }); - it("prefers explicit agentId over sessionKey agent for delivery and mirror", async () => { + it("rejects an explicit agentId that conflicts with the session key owner", async () => { mockDeliverySuccess("m-agent-precedence"); - await runSend({ - to: "channel:C1", - message: "hello", - channel: "slack", - agentId: "work", - sessionKey: "agent:main:slack:channel:c1", - idempotencyKey: "idem-agent-precedence", - }); + const { respond } = await runSendWithClient( + { + to: "channel:C1", + message: "hello", + channel: "slack", + agentId: "work", + sessionKey: "agent:main:slack:channel:c1", + idempotencyKey: "idem-agent-precedence", + }, + null, + { + ...makeContext(), + getRuntimeConfig: () => ({ agents: { list: [{ id: "main" }, { id: "work" }] } }), + } as GatewayRequestContext, + ); - expect(deliveryCall()?.session?.agentId).toBe("work"); - expect(deliveryCall()?.session?.key).toBe("agent:main:slack:channel:c1"); - expect(deliveryCall()?.mirror?.sessionKey).toBe("agent:main:slack:channel:c1"); - expect(deliveryCall()?.mirror?.agentId).toBe("work"); + expect(firstRespondCall(respond)[0]).toBe(false); + expect(firstRespondCall(respond)[2]?.message).toBe( + 'agent "work" does not match session key agent "main"', + ); + expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); }); it("ignores blank explicit agentId and falls back to sessionKey agent", async () => { @@ -2878,6 +2917,44 @@ describe("gateway send mirroring", () => { ); }); + it("rejects a message action whose bare key conflicts with the persisted owner", async () => { + registerMessageActionPlugin({ + id: "whatsapp", + action: "send", + registrySuffix: "persisted-owner-conflict", + }); + const context = { + ...makeContext(), + getRuntimeConfig: () => ({ + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + }), + } as unknown as GatewayRequestContext; + + const { respond } = await runMessageActionRequest( + { + channel: "whatsapp", + action: "send", + params: { to: "alice", message: "hello" }, + sessionKey: "global", + agentId: "research", + idempotencyKey: "idem-message-action-owner-conflict", + }, + agentRuntimeClient("global", "research"), + context, + ); + + expect(firstRespondCall(respond)[0]).toBe(false); + expect(firstRespondCall(respond)[2]?.message).toBe( + 'agent "research" does not match session key agent "ops"', + ); + expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled(); + }); + it("rejects ingress-issued message action context for a different session", async () => { const { respond } = await runMessageActionRequest( { @@ -2917,10 +2994,8 @@ describe("gateway send mirroring", () => { expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled(); }); - it.each([ - { name: "another agent", sourceReplySessionKey: "agent:other:main" }, - { name: "a malformed key", sourceReplySessionKey: "not-a-session-key" }, - ])("rejects a signed source-reply session for $name", async ({ sourceReplySessionKey }) => { + it("rejects a signed source-reply session for another agent", async () => { + const sourceReplySessionKey = "agent:other:main"; const sessionKey = "agent:main:whatsapp:direct:alice"; const { respond } = await runMessageActionRequest( { @@ -4064,7 +4139,10 @@ describe("gateway send mirroring", () => { }, { ...makeContext(), - getRuntimeConfig: () => ({ tools: { allow: ["read"] } }), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main" }, { id: "work" }] }, + tools: { allow: ["read"] }, + }), } as GatewayRequestContext, ); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 5c3a09965770..06c1bf163f41 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -12,7 +12,6 @@ import { validatePollParams, validateSendParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { sendDurableMessageBatchCore } from "../../channels/message/runtime.js"; import type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; @@ -78,6 +77,7 @@ import { resolveGatewayConversationReadOrigin } from "../conversation-read-origi import { ADMIN_SCOPE } from "../operator-scopes.js"; import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js"; import { DEDUPE_MAX, DEDUPE_TTL_MS } from "../server-constants.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { loadSessionEntry } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import { hasActiveAgentRuntimeAuthority } from "./agent-runtime-authority.js"; @@ -216,6 +216,7 @@ function resolveTrustedMessageActionToolContext(params: { sourceReplySessionKey: string | undefined; sourceReplyFinal: boolean | undefined; sourceReplyToolCallId: string | undefined; + runtimeAgentId: string | undefined; } | { ok: false; error: ReturnType } { // Current-turn metadata can relax channel read policy. It must come from the @@ -232,6 +233,7 @@ function resolveTrustedMessageActionToolContext(params: { sourceReplySessionKey: undefined, sourceReplyFinal: undefined, sourceReplyToolCallId: undefined, + runtimeAgentId: undefined, }; } if (Date.now() >= messageActionContext.expiresAtMs) { @@ -260,8 +262,8 @@ function resolveTrustedMessageActionToolContext(params: { (sessionAgentId && normalizeAgentId(sessionAgentId) !== identityAgentId) || (messageActionContext.sessionId && requestSessionId !== messageActionContext.sessionId) || (sourceReplySessionKey && - (!sourceReplySessionAgentId || - normalizeAgentId(sourceReplySessionAgentId) !== identityAgentId)) + sourceReplySessionAgentId && + normalizeAgentId(sourceReplySessionAgentId) !== identityAgentId) ) { return { ok: false, @@ -280,6 +282,7 @@ function resolveTrustedMessageActionToolContext(params: { sourceReplySessionKey, sourceReplyFinal: messageActionContext.sourceReplyFinal, sourceReplyToolCallId: messageActionContext.sourceReplyToolCallId, + runtimeAgentId: identityAgentId, }; } @@ -980,9 +983,22 @@ export const sendHandlers: GatewayRequestHandlers = { work: async ({ cfg, channel, accountId, dedupeKey, authorize }) => { try { const sessionKey = normalizeOptionalString(request.sessionKey) ?? undefined; - const agentId = - normalizeOptionalString(request.agentId) ?? - (sessionKey ? resolveSessionAgentId({ sessionKey, config: cfg }) : undefined); + const requestedAgentId = + normalizeOptionalString(request.agentId) ?? trustedContext.runtimeAgentId; + const sessionOwner = sessionKey + ? resolveRequestedSessionAgentId(cfg, sessionKey, requestedAgentId) + : undefined; + if (sessionOwner && !sessionOwner.ok) { + return { ok: false, error: sessionOwner.error, meta: { channel } }; + } + const agentId = sessionOwner?.agentId ?? requestedAgentId; + const sourceReplySessionKey = trustedContext.sourceReplySessionKey; + const sourceReplyOwner = sourceReplySessionKey + ? resolveRequestedSessionAgentId(cfg, sourceReplySessionKey, agentId) + : undefined; + if (sourceReplyOwner && !sourceReplyOwner.ok) { + return { ok: false, error: sourceReplyOwner.error, meta: { channel } }; + } if (accountId) { request.params.accountId = accountId; } @@ -1028,7 +1044,7 @@ export const sendHandlers: GatewayRequestHandlers = { cfg, accountId, currentAccountId: trustedContext.requesterAccountId, - sessionKey: trustedContext.sourceReplySessionKey ?? sessionKey, + sessionKey: sourceReplySessionKey ?? sessionKey, sessionId: trustedContext.sessionId, agentId, toolContext: trustedContext.toolContext, @@ -1227,11 +1243,29 @@ export const sendHandlers: GatewayRequestHandlers = { const providedSessionKey = normalizeSessionKeyPreservingOpaquePeerIds(request.sessionKey) || undefined; const explicitAgentId = normalizeOptionalString(request.agentId); - const sessionAgentId = providedSessionKey - ? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }) + const sessionOwner = providedSessionKey + ? resolveRequestedSessionAgentId(cfg, providedSessionKey, explicitAgentId) : undefined; - const defaultAgentId = resolveSessionAgentId({ config: cfg }); - const effectiveAgentId = explicitAgentId ?? sessionAgentId ?? defaultAgentId; + if (sessionOwner && !sessionOwner.ok) { + return { ok: false, error: sessionOwner.error, meta: { channel } }; + } + const sessionAgentId = sessionOwner?.agentId; + const implicitAgent = + !explicitAgentId && !sessionAgentId + ? resolveRequestedSessionAgentId(cfg, "main") + : undefined; + if (implicitAgent && !implicitAgent.ok) { + return { ok: false, error: implicitAgent.error, meta: { channel } }; + } + const effectiveAgentId = + explicitAgentId ?? sessionAgentId ?? (implicitAgent?.ok ? implicitAgent.agentId : null); + if (!effectiveAgentId) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "agent selection is required"), + meta: { channel }, + }; + } const sendArgs: Record = { mediaUrl, mediaUrls, diff --git a/src/gateway/server-methods/session-active-runs.test.ts b/src/gateway/server-methods/session-active-runs.test.ts index 100be5b676bb..054e9be09099 100644 --- a/src/gateway/server-methods/session-active-runs.test.ts +++ b/src/gateway/server-methods/session-active-runs.test.ts @@ -35,6 +35,7 @@ it("keeps prebuilt active-run indexes in parity with per-row scans", () => { }); registerAgentRunContext("projected-id", { projectSessionActive: true, + agentId: "main", sessionId: "session-projected", }); try { @@ -92,6 +93,7 @@ it("matches session-id-only gateway runs during archive admission", () => { requestedKey: "agent:main:child", canonicalKey: "agent:main:child", sessionId: "session-1", + defaultAgentId: "main", }), ).toBe(true); }); @@ -143,6 +145,8 @@ it("returns deterministic visible run ids for the selected session", () => { context, requestedKey: "main", canonicalKey: "main", + agentId: "main", + defaultAgentId: "main", }), ).toEqual({ active: true, runIds: ["run-a", "run-z"] }); }); @@ -332,7 +336,7 @@ it("counts settled but still registered chat runs for a session key", () => { ).toBe(false); expect( hasRegisteredChatRunForSessionKey({ context, sessionKey: "global", agentId: undefined }), - ).toBe(true); + ).toBe(false); expect( hasRegisteredChatRunForSessionKey({ context: {}, @@ -341,3 +345,100 @@ it("counts settled but still registered chat runs for a session key", () => { }), ).toBe(false); }); + +it("matches colliding bare active runs by stable owner", () => { + const context = { + chatAbortControllers: new Map([ + ["run-ownerless", { sessionKey: "incident-42" }], + ["run-research", { sessionKey: "incident-42", agentId: "research" }], + ]), + } as never; + + expect( + resolveVisibleActiveSessionRunState({ + context, + requestedKey: "incident-42", + canonicalKey: "incident-42", + agentId: "ops", + defaultAgentId: "ops", + }), + ).toEqual({ active: true, runIds: ["run-ownerless"] }); + expect( + resolveVisibleActiveSessionRunState({ + context, + requestedKey: "incident-42", + canonicalKey: "incident-42", + agentId: "research", + defaultAgentId: "ops", + }), + ).toEqual({ active: true, runIds: ["run-research"] }); +}); + +it("keeps projected bare runs agent-scoped", () => { + registerAgentRunContext("projected-ops", { + projectSessionActive: true, + sessionKey: "incident-42", + sessionId: "shared-id", + agentId: "ops", + }); + try { + const index = buildProjectedAgentRunIndex(); + expect( + resolveVisibleActiveSessionRunState({ + context: {}, + requestedKey: "incident-42", + canonicalKey: "incident-42", + sessionId: "shared-id", + agentId: "research", + projectedAgentRunIndex: index, + }).active, + ).toBe(false); + expect( + resolveVisibleActiveSessionRunState({ + context: {}, + requestedKey: "incident-42", + canonicalKey: "incident-42", + sessionId: "shared-id", + agentId: "ops", + projectedAgentRunIndex: index, + }).active, + ).toBe(true); + } finally { + clearAgentRunContext("projected-ops"); + } +}); + +it("resolves projected ownerless bare runs through the stable default owner", () => { + registerAgentRunContext("projected-ownerless", { + projectSessionActive: true, + sessionKey: "incident-42", + sessionId: "ownerless-id", + }); + try { + const index = buildProjectedAgentRunIndex(); + expect( + resolveVisibleActiveSessionRunState({ + context: {}, + requestedKey: "incident-42", + canonicalKey: "incident-42", + sessionId: "ownerless-id", + agentId: "ops", + defaultAgentId: "ops", + projectedAgentRunIndex: index, + }).active, + ).toBe(true); + expect( + resolveVisibleActiveSessionRunState({ + context: {}, + requestedKey: "incident-42", + canonicalKey: "incident-42", + sessionId: "ownerless-id", + agentId: "research", + defaultAgentId: "ops", + projectedAgentRunIndex: index, + }).active, + ).toBe(false); + } finally { + clearAgentRunContext("projected-ownerless"); + } +}); diff --git a/src/gateway/server-methods/session-active-runs.ts b/src/gateway/server-methods/session-active-runs.ts index 591eabb5e150..917f048002d3 100644 --- a/src/gateway/server-methods/session-active-runs.ts +++ b/src/gateway/server-methods/session-active-runs.ts @@ -3,7 +3,8 @@ import { hasProjectedAgentRunForSession, type ProjectedAgentRunIndex, } from "../../infra/agent-run-registry.js"; -import { normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveChatRunOwnerAgentId } from "../chat-run-owner.js"; import type { GatewayRequestContext } from "./types.js"; /** Active-run matcher including hidden remote lifecycle projections. */ @@ -48,38 +49,73 @@ function isTrackedActiveSessionRunForKey( if (!active.sessionKey || active.sessionKey !== key) { return false; } - if (key !== "global") { - return true; - } - const requestedAgentId = agentId ?? defaultAgentId; + const requestedAgentId = resolveChatRunOwnerAgentId({ + agentId, + sessionKey: key, + defaultAgentId, + }); if (!requestedAgentId) { - return true; + return false; } - const activeAgentId = active.agentId ?? defaultAgentId; + const activeAgentId = resolveChatRunOwnerAgentId({ + agentId: active.agentId, + sessionKey: active.sessionKey, + defaultAgentId, + }); return activeAgentId ? normalizeAgentId(activeAgentId) === normalizeAgentId(requestedAgentId) : false; } +function isTrackedActiveSessionRunForSessionId( + active: TrackedActiveSessionRun, + sessionId: string, + agentId?: string, + defaultAgentId?: string, +): boolean { + if (active.sessionId !== sessionId) { + return false; + } + const requestedAgentId = agentId ?? defaultAgentId; + if (!requestedAgentId) { + return false; + } + return ( + resolveChatRunOwnerAgentId({ + agentId: active.agentId, + sessionKey: active.sessionKey, + defaultAgentId, + }) === normalizeAgentId(requestedAgentId) + ); +} + export function hasRegisteredChatRunForSessionKey(params: { context: Partial>; sessionKey: string; agentId: string | undefined; + defaultAgentId?: string; }): boolean { if (!(params.context.chatAbortControllers instanceof Map)) { return false; } - const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; + const requestedAgentId = resolveChatRunOwnerAgentId({ + agentId: params.agentId, + sessionKey: params.sessionKey, + defaultAgentId: params.defaultAgentId, + }); + if (!requestedAgentId) { + return false; + } for (const active of params.context.chatAbortControllers.values()) { if (active.sessionKey?.trim() !== params.sessionKey) { continue; } - if (params.sessionKey !== "global") { - return true; - } - const activeAgentId = - typeof active.agentId === "string" ? normalizeAgentId(active.agentId) : undefined; - if (!requestedAgentId || !activeAgentId || requestedAgentId === activeAgentId) { + const activeAgentId = resolveChatRunOwnerAgentId({ + agentId: active.agentId, + sessionKey: active.sessionKey, + defaultAgentId: params.defaultAgentId, + }); + if (!requestedAgentId || requestedAgentId === activeAgentId) { return true; } } @@ -125,28 +161,40 @@ export function resolveVisibleActiveSessionRunState(params: { projectedAgentRunIndex?: ProjectedAgentRunIndex; }): { active: boolean; runIds: string[] } { const sessionId = params.sessionId?.trim(); + const resolvedAgentId = + params.agentId ?? + parseAgentSessionKey(params.canonicalKey)?.agentId ?? + parseAgentSessionKey(params.requestedKey)?.agentId; const runIds = (params.trackedActiveRuns ?? collectTrackedActiveSessionRuns(params.context)) .filter( (active) => isTrackedActiveSessionRunForKey( active, params.canonicalKey, - params.agentId, + resolvedAgentId, params.defaultAgentId, ) || isTrackedActiveSessionRunForKey( active, params.requestedKey, - params.agentId, + resolvedAgentId, params.defaultAgentId, ) || - (sessionId !== undefined && active.sessionId === sessionId), + (sessionId !== undefined && + isTrackedActiveSessionRunForSessionId( + active, + sessionId, + resolvedAgentId, + params.defaultAgentId, + )), ) .map((active) => active.runId) .toSorted(); const hasProjectedRun = hasProjectedAgentRunForSession({ sessionKeys: [params.requestedKey, params.canonicalKey], ...(sessionId ? { sessionId } : {}), + ...(resolvedAgentId ? { agentId: resolvedAgentId } : {}), + ...(params.defaultAgentId ? { defaultAgentId: params.defaultAgentId } : {}), ...(params.projectedAgentRunIndex ? { index: params.projectedAgentRunIndex } : {}), }); const embeddedRunInProgress = sessionId !== undefined && isEmbeddedAgentRunInProgress(sessionId); diff --git a/src/gateway/server-methods/session-catalog-entry-snapshot.ts b/src/gateway/server-methods/session-catalog-entry-snapshot.ts index b9fbf8038576..96574618f8fb 100644 --- a/src/gateway/server-methods/session-catalog-entry-snapshot.ts +++ b/src/gateway/server-methods/session-catalog-entry-snapshot.ts @@ -2,7 +2,7 @@ import type { SessionCatalogHost, SessionCatalogSession, } from "../../../packages/gateway-protocol/src/index.js"; -import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentIds } from "../../agents/agent-scope.js"; import type { SessionEntry } from "../../config/sessions.js"; import { listSessionEntriesReadOnly, @@ -11,6 +11,7 @@ import { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SessionCatalogEntrySnapshot } from "../../plugins/session-catalog.js"; import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js"; import { projectSessionActor } from "../session-utils-row.js"; @@ -45,10 +46,9 @@ export function createSessionCatalogRequestEntrySnapshot(params: { if (catalogEntries) { return catalogEntries; } - const defaultAgentId = resolveDefaultAgentId(params.cfg); const agentIds = [ - defaultAgentId, - ...listAgentIds(params.cfg).filter((agentId) => agentId !== defaultAgentId), + params.fallbackAgentId, + ...listAgentIds(params.cfg).filter((agentId) => agentId !== params.fallbackAgentId), ]; catalogEntries = agentIds.flatMap((agentId) => entriesForAgent(agentId).map((entry) => Object.assign({}, entry, { agentId })), @@ -70,10 +70,14 @@ export function createSessionCatalogRequestEntrySnapshot(params: { }; const createdActorForSession = (sessionKey: string): SessionCatalogSession["createdActor"] => { - if (actorBySessionKey.has(sessionKey)) { - return actorBySessionKey.get(sessionKey); + const agentId = resolveAgentIdFromSessionKey( + sessionKey, + tryResolveSessionCompatibilityOwnerAgentId(params.cfg, sessionKey) ?? params.fallbackAgentId, + ); + const actorCacheKey = `${agentId}\0${sessionKey}`; + if (actorBySessionKey.has(actorCacheKey)) { + return actorBySessionKey.get(actorCacheKey); } - const agentId = resolveAgentIdFromSessionKey(sessionKey, params.fallbackAgentId); const index = entryIndexForAgent(agentId); const canonicalKey = resolveStoredSessionKeyForAgentStore({ cfg: params.cfg, @@ -89,7 +93,7 @@ export function createSessionCatalogRequestEntrySnapshot(params: { } } const actor = projectSessionActor(freshest?.createdActor); - actorBySessionKey.set(sessionKey, actor); + actorBySessionKey.set(actorCacheKey, actor); return actor; }; diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index fdaaf28d9704..12af3516b164 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -206,20 +206,24 @@ describe("session catalog Gateway methods", () => { const followerBroadcast = vi.fn(); const leader = startCall( "sessions.catalog.list", - { progressId: "leader-progress" }, + { progressId: "leader-progress", agentId: "main" }, config, { connId: "leader" }, { broadcastToConnIds: leaderBroadcast }, ); const follower = startCall( "sessions.catalog.list", - { progressId: "follower-progress" }, + { progressId: "follower-progress", agentId: "main" }, config, { connId: "follower" }, { broadcastToConnIds: followerBroadcast }, ); const otherAgent = startCall("sessions.catalog.list", { agentId: "research" }, config); - const otherParams = startCall("sessions.catalog.list", { search: "other" }, config); + const otherParams = startCall( + "sessions.catalog.list", + { search: "other", agentId: "main" }, + config, + ); await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(3)); release(); diff --git a/src/gateway/server-methods/session-change-event.test.ts b/src/gateway/server-methods/session-change-event.test.ts index e01ba94d6af3..09455678380d 100644 --- a/src/gateway/server-methods/session-change-event.test.ts +++ b/src/gateway/server-methods/session-change-event.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { retainLegacyDefaultAgentId } from "../../config/legacy.default-agent-owner.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ChatAbortControllerEntry } from "../chat-abort.js"; import type { GatewayRequestContext } from "./types.js"; const mocks = vi.hoisted(() => ({ @@ -27,23 +30,27 @@ vi.mock("../session-utils.js", async (importOriginal) => { vi.mock("../session-event-payload.js", () => ({ buildGatewaySessionEventFields: ({ sessionRow, + hasActiveRun, + activeRunIds, }: { sessionRow: { key: string; label: string }; - }) => ({ key: sessionRow.key, label: sessionRow.label }), -})); - -vi.mock("./session-active-runs.js", () => ({ - resolveVisibleActiveSessionRunState: () => ({ active: false, runIds: [] }), + hasActiveRun?: boolean; + activeRunIds?: string[]; + }) => ({ key: sessionRow.key, label: sessionRow.label, hasActiveRun, activeRunIds }), })); const { emitSessionsChanged, flushPendingSessionsChangedEvents, readSessionsMutationVersion } = await import("./session-change-event.js"); -function createContext(receivers = new Set(["conn-1"])) { +function createContext( + receivers = new Set(["conn-1"]), + config: OpenClawConfig = {}, + chatAbortControllers: GatewayRequestContext["chatAbortControllers"] = new Map(), +) { return { broadcastToConnIds: vi.fn(), - chatAbortControllers: new Map(), - getRuntimeConfig: () => ({}), + chatAbortControllers, + getRuntimeConfig: () => config, getSessionEventSubscriberConnIds: () => receivers, } as unknown as GatewayRequestContext; } @@ -94,6 +101,84 @@ describe("sessions.changed coalescing", () => { expect(mocks.loadRow).toHaveBeenCalledTimes(2); }); + it("does not adopt the compatibility owner's ownerless run for another agent", () => { + const config = retainLegacyDefaultAgentId( + { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + }, + "ops", + ); + const sessionId = "agent:research:shared-session-id"; + const context = createContext( + new Set(["conn-1"]), + config, + new Map([ + [ + "compat-owner-run", + { + controller: new AbortController(), + expiresAtMs: 60_000, + sessionId, + sessionKey: "legacy-unscoped", + startedAtMs: 0, + } satisfies ChatAbortControllerEntry, + ], + ]), + ); + + emitSessionsChanged(context, { + reason: "update", + sessionKey: "agent:research:shared-session", + }); + + expect(context.broadcastToConnIds).toHaveBeenCalledWith( + "sessions.changed", + expect.objectContaining({ hasActiveRun: false, activeRunIds: [] }), + expect.anything(), + expect.anything(), + ); + }); + + it("projects active bare-global runs through the persisted fixed-store owner", () => { + const config = { + session: { scope: "global", store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + const context = createContext( + new Set(["conn-1"]), + config, + new Map([ + [ + "ops-global-run", + { + agentId: "ops", + controller: new AbortController(), + expiresAtMs: 60_000, + sessionId: "global-id", + sessionKey: "global", + startedAtMs: 0, + } satisfies ChatAbortControllerEntry, + ], + ]), + ); + + emitSessionsChanged(context, { reason: "update", sessionKey: "global" }); + + expect(context.broadcastToConnIds).toHaveBeenCalledWith( + "sessions.changed", + expect.objectContaining({ + activeRunIds: ["ops-global-run"], + hasActiveRun: true, + }), + expect.anything(), + expect.anything(), + ); + }); + it("advances the mutation fence without loading rows when nobody receives events", () => { const context = createContext(new Set()); const initialVersion = readSessionsMutationVersion(context); diff --git a/src/gateway/server-methods/session-change-event.ts b/src/gateway/server-methods/session-change-event.ts index 805cdc6879a9..6020f048d32c 100644 --- a/src/gateway/server-methods/session-change-event.ts +++ b/src/gateway/server-methods/session-change-event.ts @@ -1,7 +1,8 @@ // Shared sessions.changed broadcaster for gateway RPC and chat-command mutations. -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { hasSessionChangeReceivers } from "../session-change-receivers.js"; import { buildGatewaySessionEventFields } from "../session-event-payload.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { invalidateSessionSharingSnapshot } from "../session-sharing.js"; import { loadGatewaySessionRow } from "../session-utils.js"; import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js"; @@ -51,35 +52,48 @@ function broadcastSessionsChanged( if (!hasSessionChangeReceivers(connIds)) { return; } + const cfg = context.getRuntimeConfig(); + const unscopedOwnerAgentId = payload.sessionKey + ? tryResolveSessionCompatibilityOwnerAgentId(cfg, payload.sessionKey) + : undefined; + const effectiveAgentId = payload.agentId ?? unscopedOwnerAgentId; const sessionRow = payload.sessionKey ? loadGatewaySessionRow( payload.sessionKey, - payload.sessionKey === "global" && payload.agentId - ? { agentId: payload.agentId } - : undefined, + effectiveAgentId ? { agentId: effectiveAgentId } : undefined, ) : null; - const defaultAgentId = resolveDefaultAgentId(context.getRuntimeConfig()); - const activeRunState = sessionRow - ? resolveVisibleActiveSessionRunState({ - context, - requestedKey: payload.sessionKey ?? sessionRow.key, - canonicalKey: sessionRow.key, - sessionId: sessionRow.sessionId, - agentId: sessionRow.key === "global" ? payload.agentId : undefined, - defaultAgentId, - }) - : null; + let rowAgentId: string | undefined; + if (sessionRow) { + try { + rowAgentId = resolveAgentIdFromSessionKey(sessionRow.key, effectiveAgentId); + } catch { + rowAgentId = undefined; + } + } + const activeRunState = + sessionRow && + (sessionRow.key !== "global" || rowAgentId !== undefined || unscopedOwnerAgentId !== undefined) + ? resolveVisibleActiveSessionRunState({ + context, + requestedKey: payload.sessionKey ?? sessionRow.key, + canonicalKey: sessionRow.key, + sessionId: sessionRow.sessionId, + agentId: rowAgentId, + defaultAgentId: unscopedOwnerAgentId, + }) + : null; context.broadcastToConnIds( "sessions.changed", { ...payload, + ...(effectiveAgentId ? { agentId: effectiveAgentId } : {}), ts: Date.now(), ...(sessionRow ? { ...buildGatewaySessionEventFields({ sessionRow, - agentId: payload.agentId, + agentId: effectiveAgentId, hasActiveRun: activeRunState?.active, activeRunIds: activeRunState?.runIds, }), @@ -93,7 +107,7 @@ function broadcastSessionsChanged( }, connIds, { - ...(payload.agentId ? { agentId: payload.agentId } : {}), + ...(effectiveAgentId ? { agentId: effectiveAgentId } : {}), dropIfSlow: true, // Scope only to a concrete key; a `[undefined]` scope filters no connection // correctly and would strip draft gating, so fall back to an unscoped send. diff --git a/src/gateway/server-methods/session-discussion.test.ts b/src/gateway/server-methods/session-discussion.test.ts index cd670dc1eea5..8cd0e90d1ba9 100644 --- a/src/gateway/server-methods/session-discussion.test.ts +++ b/src/gateway/server-methods/session-discussion.test.ts @@ -50,7 +50,11 @@ const storePath = "/tmp/openclaw/sessions.sqlite"; type Method = "session.discussion.info" | "session.discussion.open"; -async function invoke(method: Method, params: Record) { +async function invoke( + method: Method, + params: Record, + runtimeConfig: OpenClawConfig = cfg, +) { const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; await sessionDiscussionHandlers[method]?.({ req: { type: "req", id: method, method, params: {} }, @@ -58,7 +62,7 @@ async function invoke(method: Method, params: Record) { client: null, isWebchatConnect: () => false, respond: (ok, payload, error) => calls.push({ ok, payload, error }), - context: { getRuntimeConfig: () => cfg } as never, + context: { getRuntimeConfig: () => runtimeConfig } as never, }); return calls[0]; } @@ -126,7 +130,10 @@ describe("session discussion gateway methods", () => { sessionKey: "agent:main:thread", }); - expect(registered.info).toHaveBeenCalledWith({ sessionKey: "agent:main:thread" }); + expect(registered.info).toHaveBeenCalledWith({ + sessionKey: "agent:main:thread", + agentId: "main", + }); expect(response).toMatchObject({ ok: true, payload: { @@ -145,10 +152,48 @@ describe("session discussion gateway methods", () => { sessionKey: "agent:main:thread", }); - expect(registered.open).toHaveBeenCalledWith({ sessionKey: "agent:main:thread" }); + expect(registered.open).toHaveBeenCalledWith({ + sessionKey: "agent:main:thread", + agentId: "main", + }); expect(response).toMatchObject({ ok: true, payload: { state: "available" } }); }); + it("admits bare fixed-store keys only through their persisted owner", async () => { + const registered = provider(); + mocks.getProvider.mockReturnValue(registered.value); + mockSession({ sessionId: "session-ops-global", updatedAt: 1 }); + const ownedConfig: OpenClawConfig = { + session: { scope: "global", store: "/tmp/shared-sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + expect( + await invoke("session.discussion.open", { sessionKey: "global" }, ownedConfig), + ).toMatchObject({ ok: true, payload: { state: "available" } }); + expect(mocks.loadSessionTarget).toHaveBeenCalledWith({ + cfg: ownedConfig, + key: "global", + agentId: "ops", + }); + + const ownerlessConfig: OpenClawConfig = { + ...ownedConfig, + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + }; + expect( + await invoke("session.discussion.info", { sessionKey: "global" }, ownerlessConfig), + ).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("has no explicit owner") }, + }); + expect(registered.info).not.toHaveBeenCalled(); + }); + it("persists a generated title before opening an untitled session discussion", async () => { const entry: SessionEntry = { sessionId: "session-1", updatedAt: 1 }; let persistedEntry: SessionEntry | undefined; diff --git a/src/gateway/server-methods/session-discussion.ts b/src/gateway/server-methods/session-discussion.ts index 15936ddc6237..b5c4a7a69618 100644 --- a/src/gateway/server-methods/session-discussion.ts +++ b/src/gateway/server-methods/session-discussion.ts @@ -9,6 +9,8 @@ import { } from "../../../packages/gateway-protocol/src/index.js"; import { getSessionDiscussionProvider } from "../../plugins/session-discussion-registry.js"; import { hasExplicitSessionName, maybeGenerateSessionTitle } from "../dashboard-session-title.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js"; import { formatForLog } from "../ws-log.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { loadAccessorSessionEntryForGatewayTarget } from "./sessions-shared.js"; @@ -20,12 +22,14 @@ const DISCUSSION_TITLE_TIMEOUT_MS = 10_000; async function maybeGenerateTitleBeforeDiscussionOpen(params: { context: GatewayRequestContext; sessionKey: string; + agentId?: string; }): Promise { try { const cfg = params.context.getRuntimeConfig(); const resolved = loadAccessorSessionEntryForGatewayTarget({ cfg, key: params.sessionKey, + agentId: params.agentId, }); const { entry } = resolved; const sessionId = entry?.sessionId; @@ -95,7 +99,7 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { } export const sessionDiscussionHandlers: GatewayRequestHandlers = { - "session.discussion.info": async ({ params, respond }) => { + "session.discussion.info": async ({ params, respond, context }) => { if ( !assertValidParams( params, @@ -106,13 +110,27 @@ export const sessionDiscussionHandlers: GatewayRequestHandlers = { ) { return; } + const requestedAgent = resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + params.sessionKey, + params.agentId, + ); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } const provider = getSessionDiscussionProvider(); if (!provider) { respond(true, { state: "none" }, undefined); return; } try { - const result = await provider.info({ sessionKey: params.sessionKey }); + const sessionKey = resolveStoredSessionKeyForAgentStore({ + cfg: context.getRuntimeConfig(), + agentId: requestedAgent.agentId, + sessionKey: params.sessionKey, + }); + const result = await provider.info({ sessionKey, agentId: requestedAgent.agentId }); if (!validateSessionDiscussionInfoResult(result)) { respond( false, @@ -150,6 +168,15 @@ export const sessionDiscussionHandlers: GatewayRequestHandlers = { ) { return; } + const requestedAgent = resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + params.sessionKey, + params.agentId, + ); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } const provider = getSessionDiscussionProvider(); if (!provider) { respond(true, { state: "none" }, undefined); @@ -159,8 +186,14 @@ export const sessionDiscussionHandlers: GatewayRequestHandlers = { await maybeGenerateTitleBeforeDiscussionOpen({ context, sessionKey: params.sessionKey, + agentId: requestedAgent.agentId, }); - const result = await provider.open({ sessionKey: params.sessionKey }); + const sessionKey = resolveStoredSessionKeyForAgentStore({ + cfg: context.getRuntimeConfig(), + agentId: requestedAgent.agentId, + sessionKey: params.sessionKey, + }); + const result = await provider.open({ sessionKey, agentId: requestedAgent.agentId }); if (!validateSessionDiscussionOpenResult(result)) { respond( false, diff --git a/src/gateway/server-methods/sessions-abort.ts b/src/gateway/server-methods/sessions-abort.ts index b9c9504e5a24..7d7e9c27922f 100644 --- a/src/gateway/server-methods/sessions-abort.ts +++ b/src/gateway/server-methods/sessions-abort.ts @@ -8,7 +8,6 @@ import { errorShape, validateSessionsAbortParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { abortEmbeddedAgentRun } from "../../agents/embedded-agent-runner/runs.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; import { @@ -18,8 +17,12 @@ import { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js"; +import { resolveChatRunOwnerAgentId } from "../chat-run-owner.js"; import { resolveSessionKeyForRun } from "../server-session-key.js"; -import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js"; +import { + resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, + tryResolveSessionCompatibilityOwnerAgentId, +} from "../session-request-agent.js"; import { resolveSessionStoreAgentId, resolveSessionStoreKey, @@ -41,6 +44,8 @@ export function resolveAbortSessionKey(params: { canonicalKey: string; activeRunSessionKey?: string; aliasKeys?: string[]; + agentId?: string; + defaultAgentId?: string; }): string { if (params.activeRunSessionKey) { return params.activeRunSessionKey; @@ -52,7 +57,14 @@ export function resolveAbortSessionKey(params: { } for (const candidate of candidates) { if (active.sessionKey === candidate) { - return candidate; + const owner = resolveChatRunOwnerAgentId({ + agentId: active.agentId, + sessionKey: active.sessionKey, + defaultAgentId: params.defaultAgentId, + }); + if (!params.agentId || owner === normalizeAgentId(params.agentId)) { + return candidate; + } } } } @@ -70,8 +82,7 @@ function resolveSessionKeyAgentId( if (!parseAgentSessionKey(key) && key.toLowerCase().startsWith("agent:")) { return undefined; } - const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key }); - return resolveSessionStoreAgentId(cfg, canonicalKey); + return parseAgentSessionKey(key)?.agentId ?? tryResolveSessionCompatibilityOwnerAgentId(cfg, key); } function sessionKeyBelongsToAgent( @@ -79,10 +90,6 @@ function sessionKeyBelongsToAgent( agentId: string, cfg: OpenClawConfig, ): boolean { - const key = normalizeOptionalString(sessionKey); - if (cfg.session?.scope === "global" && key?.toLowerCase() === "global") { - return true; - } const sessionAgentId = resolveSessionKeyAgentId(sessionKey, cfg); return Boolean(sessionAgentId && sessionAgentId === normalizeAgentId(agentId)); } @@ -154,14 +161,23 @@ export const sessionAbortHandlers: GatewayRequestHandlers = { const activeRun = requestedRunId ? context.chatAbortControllers.get(requestedRunId) : undefined; const activeRunSessionKey = activeRun?.sessionKey; const activeRunAgentId = normalizeOptionalString(activeRun?.agentId); - const inferredRunAgentId = + let inferredRunAgentId = requestedParamAgentId ?? - (requestedRunId && scopedRequestedKey?.toLowerCase() === "global" - ? activeRunAgentId - : undefined) ?? + activeRunAgentId ?? requestedKeyAgentId ?? workerRunTarget?.agentId ?? - (requestedRunId && !activeRunSessionKey ? resolveDefaultAgentId(cfg) : undefined); + resolveSessionKeyAgentId(activeRunSessionKey, cfg); + if (requestedRunId && !inferredRunAgentId) { + const runOwner = resolveRequestedGlobalAgentId( + cfg, + scopedRequestedKey ?? activeRunSessionKey ?? workerRunTarget?.sessionKey ?? "main", + ); + if (!runOwner.ok) { + respond(false, undefined, runOwner.error); + return; + } + inferredRunAgentId = runOwner.agentId; + } const requestedRunAgentId = requestedRunId ? inferredRunAgentId ? normalizeAgentId(inferredRunAgentId) @@ -178,9 +194,10 @@ export const sessionAbortHandlers: GatewayRequestHandlers = { scopedRequestedKey ?? scopedActiveRunSessionKey ?? (requestedRunId - ? resolveSessionKeyForRun(requestedRunId, { - agentId: requestedRunAgentId ?? resolveDefaultAgentId(cfg), - }) + ? resolveSessionKeyForRun( + requestedRunId, + requestedRunAgentId ? { agentId: requestedRunAgentId } : undefined, + ) : undefined) ?? workerRunTarget?.sessionKey; if (!keyCandidate && requestedRunId) { @@ -208,10 +225,23 @@ export const sessionAbortHandlers: GatewayRequestHandlers = { const existingTargets = configuredTarget ? [] : resolveExistingAgentSessionStoreTargetsSync(cfg, targetAgentId); + const stableTargetOwner = tryResolveSessionCompatibilityOwnerAgentId(cfg, key); const hasExactActiveRun = requestedRunId - ? scopedActiveRunSessionKey === key + ? scopedActiveRunSessionKey === key && + resolveChatRunOwnerAgentId({ + agentId: activeRunAgentId, + sessionKey: activeRunSessionKey, + defaultAgentId: stableTargetOwner, + }) === normalizeAgentId(targetAgentId) : [...context.chatAbortControllers.values()].some( - (entry) => entry.controlUiVisible !== false && entry.sessionKey === key, + (entry) => + entry.controlUiVisible !== false && + entry.sessionKey === key && + resolveChatRunOwnerAgentId({ + agentId: entry.agentId, + sessionKey: entry.sessionKey, + defaultAgentId: stableTargetOwner, + }) === normalizeAgentId(targetAgentId), ); if (!configuredTarget && existingTargets.length === 0 && !hasExactActiveRun) { respond( @@ -247,11 +277,12 @@ export const sessionAbortHandlers: GatewayRequestHandlers = { canonicalKey, activeRunSessionKey: scopedActiveRunSessionKey, aliasKeys: requestedKeyAliases, + agentId: requestedGlobalAgentId, + defaultAgentId: stableTargetOwner, }); const abortSessionKey = canonicalKey === "global" && requestedGlobalAgentId ? "global" : resolvedAbortSessionKey; - const abortAgentId = - abortSessionKey === "global" ? (requestedGlobalAgentId ?? activeRunAgentId) : undefined; + const abortAgentId = requestedGlobalAgentId ?? activeRunAgentId; // Capture run kinds before the abort because abortChatRunById deletes entries // from chatAbortControllers synchronously. We use this snapshot to choose the // correct dedupe namespace: agent-kind runs use "agent:" (their runId equals @@ -367,7 +398,7 @@ export const sessionAbortHandlers: GatewayRequestHandlers = { if (aborted) { emitSessionsChanged(context, { sessionKey: canonicalKey, - ...(canonicalKey === "global" && abortAgentId ? { agentId: abortAgentId } : {}), + ...(abortAgentId ? { agentId: abortAgentId } : {}), reason: "abort", }); } diff --git a/src/gateway/server-methods/sessions-archive-lifecycle.ts b/src/gateway/server-methods/sessions-archive-lifecycle.ts index 63e633206e2c..4cca79c7b3e3 100644 --- a/src/gateway/server-methods/sessions-archive-lifecycle.ts +++ b/src/gateway/server-methods/sessions-archive-lifecycle.ts @@ -59,7 +59,7 @@ type SessionArchiveLifecycleParams = { sessionId?: string; agentId: string; sessionKey: string; - defaultAgentId: string; + defaultAgentId?: string; lifecycleIdentities: string[]; }; diff --git a/src/gateway/server-methods/sessions-compact.ts b/src/gateway/server-methods/sessions-compact.ts index 72fb9dd4961e..8741936ef4fd 100644 --- a/src/gateway/server-methods/sessions-compact.ts +++ b/src/gateway/server-methods/sessions-compact.ts @@ -5,7 +5,6 @@ import { errorShape, validateSessionsCompactParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveEmbeddedSessionLane } from "../../agents/embedded-agent-runner/lanes.js"; import { hasPendingFollowupQueueWork } from "../../auto-reply/reply/queue/state.js"; import { @@ -27,7 +26,10 @@ import { runExclusiveSessionLifecycleMutation, } from "../../sessions/session-lifecycle-admission.js"; import { recordSessionCompacted } from "../../sessions/session-state-events.js"; -import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js"; +import { + resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, + tryResolveSessionCompatibilityOwnerAgentId, +} from "../session-request-agent.js"; import { resolveCanonicalGatewaySessionStoreKey, resolveGatewaySessionStoreTargetWithStore, @@ -69,6 +71,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { return; } const requestedAgentId = requestedAgent.agentId; + const compatibilityDefaultAgentId = tryResolveSessionCompatibilityOwnerAgentId(cfg, key); const target = resolveGatewaySessionStoreTargetWithStore({ cfg, key, @@ -221,7 +224,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { canonicalKey: target.canonicalKey, sessionId, agentId: requestedAgentId, - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId: compatibilityDefaultAgentId, }); // Accepted work can live only in its command lane; waiting behind it // while holding the lifecycle fence would deadlock or drop that turn. @@ -338,9 +341,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { }); emitSessionsChanged(context, { sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.agentId - ? { agentId: target.agentId } - : {}), + agentId: target.agentId, reason: "compact", compacted: true, }); @@ -372,9 +373,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { operation: "compact", phase: "start", sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.agentId - ? { agentId: target.agentId } - : {}), + agentId: target.agentId, }); const emitCompactionEnd = (completed: boolean, reason?: string) => emitSessionOperation(context, { @@ -382,9 +381,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { operation: "compact", phase: "end", sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.agentId - ? { agentId: target.agentId } - : {}), + agentId: target.agentId, completed, reason, }); @@ -490,9 +487,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { if (result.ok) { emitSessionsChanged(context, { sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.agentId - ? { agentId: target.agentId } - : {}), + agentId: target.agentId, reason: "compact", compacted: result.compacted, }); diff --git a/src/gateway/server-methods/sessions-compaction-checkpoints.ts b/src/gateway/server-methods/sessions-compaction-checkpoints.ts index 739a8a4a6016..0f81ef56063d 100644 --- a/src/gateway/server-methods/sessions-compaction-checkpoints.ts +++ b/src/gateway/server-methods/sessions-compaction-checkpoints.ts @@ -169,9 +169,7 @@ export const sessionCheckpointHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: canonicalKey, - ...(canonicalKey === "global" && requestedAgent.agentId - ? { agentId: requestedAgent.agentId } - : {}), + agentId: requestedAgent.agentId, reason: "checkpoint-branch", }); emitSessionsChanged(context, { @@ -450,9 +448,7 @@ export const sessionCheckpointHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: current.canonicalKey, - ...(current.canonicalKey === "global" && requestedAgent.agentId - ? { agentId: requestedAgent.agentId } - : {}), + agentId: requestedAgent.agentId, reason: "checkpoint-restore", }); }, diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 3faf2a445601..fa6872970176 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -11,7 +11,7 @@ import { missingScopeErrorShape, validateSessionsCreateParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; import { insideGitCheckout } from "../../agents/worktrees/git.js"; import { slugifyWorktreeTitle } from "../../agents/worktrees/name.js"; @@ -78,21 +78,30 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ); return; } + const explicitlyRequestedKey = normalizeOptionalString(p.key); + const explicitlyRequestedAgentId = normalizeOptionalString(p.agentId); + // An omitted key means the selected agent's main alias, not the compatibility owner's alias. + const agentSelectionKey = + explicitlyRequestedKey ?? + (explicitlyRequestedAgentId + ? `agent:${normalizeAgentId(explicitlyRequestedAgentId)}:main` + : "main"); + const explicitlyRequestedAgent = resolveRequestedGlobalAgentId( + cfg, + agentSelectionKey, + explicitlyRequestedAgentId, + { allowUnconfiguredExplicitAgent: true }, + ); + if (!explicitlyRequestedAgent.ok) { + respond(false, undefined, explicitlyRequestedAgent.error); + return; + } const catalogRequestedKey = normalizeOptionalString(p.key) ?? "global"; const catalogAgentId = catalogId ? normalizeAgentId( - normalizeOptionalString(p.agentId) ?? - parseAgentSessionKey(catalogRequestedKey)?.agentId ?? - resolveDefaultAgentId(cfg), + parseAgentSessionKey(catalogRequestedKey)?.agentId ?? explicitlyRequestedAgent.agentId, ) : undefined; - const catalogRequestedAgent = catalogAgentId - ? resolveRequestedGlobalAgentId(cfg, catalogRequestedKey, catalogAgentId) - : undefined; - if (catalogRequestedAgent && !catalogRequestedAgent.ok) { - respond(false, undefined, catalogRequestedAgent.error); - return; - } const catalogTarget = catalogId && catalogAgentId ? resolveRegisteredCatalogCreateTarget(catalogId, catalogAgentId, cfg) @@ -223,7 +232,11 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { } } let sessionKey = p.key; - let sessionAgentId = catalogAgentId ?? p.agentId; + let sessionAgentId = + catalogAgentId ?? + explicitlyRequestedAgent.agentId ?? + p.agentId ?? + parseAgentSessionKey(explicitlyRequestedKey)?.agentId; let sessionWorktree: Awaited> | undefined; const sessionExecCwd = requestedExecNode ? requestedCwd : undefined; let sessionCwd = requestedExecNode ? undefined : (projectRoot ?? requestedCwd); @@ -232,7 +245,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { const targetAgentId = normalizeAgentId( sessionAgentId ?? parseAgentSessionKey(sessionKey ?? "")?.agentId ?? - resolveDefaultAgentId(cfg), + explicitlyRequestedAgent.agentId, ); const targetSessionKey = sessionKey ?? `agent:${targetAgentId}:dashboard:pending`; const targetRuntime = resolveSandboxRuntimeStatus({ @@ -264,18 +277,11 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { if (p.worktree === true) { // Workspace-contained cwd and registry-authorized projects stay at operator.write; // arbitrary host paths still require operator.admin before reaching this block. - const explicitKey = normalizeOptionalString(p.key); - const requestedKey = explicitKey ?? "global"; - const requestedAgent = resolveRequestedGlobalAgentId(cfg, requestedKey, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } + const explicitKey = explicitlyRequestedKey; const agentId = normalizeAgentId( - requestedAgent.agentId ?? + explicitlyRequestedAgent.agentId ?? normalizeOptionalString(p.agentId) ?? - parseAgentSessionKey(requestedKey)?.agentId ?? - resolveDefaultAgentId(cfg), + parseAgentSessionKey(explicitKey)?.agentId, ); let targetKey = explicitKey; let preservesUnspecifiedKey = false; @@ -287,12 +293,16 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { !hasInitialTurn && cfg.session?.dmScope === "main" ) { - const parent = loadGatewaySessionEntryReadOnly( - parentSessionKey, - requestedAgent.agentId ? { agentId: requestedAgent.agentId } : undefined, - ); + const parentRequestedAgent = resolveRequestedGlobalAgentId(cfg, parentSessionKey, agentId); + if (!parentRequestedAgent.ok) { + respond(false, undefined, parentRequestedAgent.error); + return; + } + const parent = loadGatewaySessionEntryReadOnly(parentSessionKey, { + agentId: parentRequestedAgent.agentId, + }); const parentAgentId = normalizeAgentId( - requestedAgent.agentId ?? resolveSessionStoreAgentId(cfg, parent.canonicalKey), + parentRequestedAgent.agentId ?? resolveSessionStoreAgentId(cfg, parent.canonicalKey), ); if ( parent.entry?.sessionId && @@ -474,7 +484,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { const modelCatalogAgentId = normalizeAgentId( sessionAgentId ?? parseAgentSessionKey(sessionKey ?? "")?.agentId ?? - resolveDefaultAgentId(cfg), + explicitlyRequestedAgent.agentId, ); if (!authority.ensureActive()) { return; @@ -550,7 +560,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { req, params: { sessionKey: key, - ...(key === "global" ? { agentId } : {}), + agentId, message: initialMessage ?? "", idempotencyKey: randomUUID(), ...(initialAttachments ? { attachments: initialAttachments } : {}), @@ -616,7 +626,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: created.key, - ...(created.key === "global" ? { agentId: created.agentId } : {}), + agentId: created.agentId, reason: "new", }); return; @@ -647,13 +657,13 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: created.key, - ...(created.key === "global" ? { agentId: created.agentId } : {}), + agentId: created.agentId, reason: "create", }); if (runStarted) { emitSessionsChanged(context, { sessionKey: created.key, - ...(created.key === "global" ? { agentId: created.agentId } : {}), + agentId: created.agentId, reason: "send", }); } diff --git a/src/gateway/server-methods/sessions-delete.ts b/src/gateway/server-methods/sessions-delete.ts index cbac31e36959..275930a65970 100644 --- a/src/gateway/server-methods/sessions-delete.ts +++ b/src/gateway/server-methods/sessions-delete.ts @@ -5,17 +5,21 @@ import { errorShape, validateSessionsDeleteParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { managedWorktrees } from "../../agents/worktrees/service.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; import { deleteSessionEntryLifecycle, - resolveMainSessionKey, SESSION_LIFECYCLE_CHANGED_ERROR_REASON, type SessionEntry, } from "../../config/sessions.js"; import { rollbackPluginOwnedSessionEntryLifecycle } from "../../config/sessions/session-accessor.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { isIncognitoSessionKey } from "../../routing/session-key.js"; +import { + isIncognitoSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import { isAgentHarnessSessionKey } from "../../sessions/agent-harness-session-key.js"; import { isModelSelectionLocked } from "../../sessions/model-overrides.js"; import { @@ -32,6 +36,7 @@ import { emitSessionsChanged } from "./session-change-event.js"; import { loadAccessorSessionEntryForGatewayTarget, loadSessionsRuntimeModule, + isAgentMainSessionKey, rejectPluginRuntimeSessionOwnershipMismatch, requireSessionKey, resolveGatewaySessionTargetFromKey, @@ -71,16 +76,28 @@ export const sessionDeleteHandlers: GatewayRequestHandlers = { const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { agentId: requestedAgentId, }); - const mainKey = resolveMainSessionKey(cfg); + const compatibilityDefaultAgentId = tryResolveLegacyCompatibilityAgentId(cfg); + const persistedStoreOwner = resolvePersistedSessionStoreOwnerForKey(cfg, key); + const protectedGlobalAgentId = + persistedStoreOwner.kind === "configured" + ? persistedStoreOwner.agentId + : compatibilityDefaultAgentId; + const explicitlySelectedGlobalAgentId = + normalizeOptionalString(p.agentId) ?? parseAgentSessionKey(key)?.agentId; const isSelectedNonDefaultGlobal = target.canonicalKey === "global" && - requestedAgentId !== undefined && - requestedAgentId !== resolveDefaultAgentId(cfg); - if (target.canonicalKey === mainKey && !isSelectedNonDefaultGlobal) { + explicitlySelectedGlobalAgentId !== undefined && + normalizeAgentId(explicitlySelectedGlobalAgentId) !== protectedGlobalAgentId; + const isMainSession = + target.canonicalKey !== "global" && isAgentMainSessionKey(cfg, target.canonicalKey); + if ((target.canonicalKey === "global" || isMainSession) && !isSelectedNonDefaultGlobal) { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `Cannot delete the main session (${mainKey}).`), + errorShape( + ErrorCodes.INVALID_REQUEST, + `Cannot delete the main session (${target.canonicalKey}).`, + ), ); return; } diff --git a/src/gateway/server-methods/sessions-diff.test.ts b/src/gateway/server-methods/sessions-diff.test.ts index 8c3daa622d64..6698531987f0 100644 --- a/src/gateway/server-methods/sessions-diff.test.ts +++ b/src/gateway/server-methods/sessions-diff.test.ts @@ -28,7 +28,8 @@ vi.mock("../session-utils.js", () => ({ loadGatewaySessionEntryReadOnly: hoisted.loadSessionEntry, })); -vi.mock("../../agents/agent-scope.js", () => ({ +vi.mock("../../agents/agent-scope.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveAgentWorkspaceDir: hoisted.resolveAgentWorkspaceDir, resolveDefaultAgentId: hoisted.resolveDefaultAgentId, })); @@ -50,6 +51,7 @@ function initRepo(root: string): void { function mockSession(spawnedCwd: string, entry: Record = {}): void { hoisted.loadSessionEntry.mockReturnValue({ + agentId: "main", cfg: {}, entry: { sessionId: "s1", spawnedCwd, ...entry }, storePath: "/tmp/sessions.json", @@ -117,6 +119,7 @@ describe("loadSessionDiff", () => { it("reports unknown sessions without touching a workspace", async () => { hoisted.loadSessionEntry.mockReturnValue({ + agentId: "main", cfg: {}, entry: undefined, storePath: undefined, @@ -133,6 +136,80 @@ describe("loadSessionDiff", () => { expect(result.unavailableReason).toBe("not_git"); }); + it("uses the persisted fixed-store owner for a bare session checkout", async () => { + initRepo(repoRoot); + fs.writeFileSync(path.join(repoRoot, "owned.txt"), "ops\n"); + const cfg = { + session: { store: "/tmp/shared.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as const; + hoisted.loadSessionEntry.mockReturnValue({ + agentId: "ops", + cfg, + entry: { sessionId: "sess-owned-global" }, + storePath: cfg.session.store, + canonicalKey: "global", + }); + hoisted.resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, agentId: string) => + agentId === "ops" ? repoRoot : "/wrong/research", + ); + const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; + + await sessionsDiffHandlers["sessions.diff"]?.({ + req: { type: "req", id: "sessions.diff", method: "sessions.diff", params: {} }, + params: { sessionKey: "global" }, + client: null, + isWebchatConnect: () => false, + respond: (ok, payload, error) => calls.push({ ok, payload, error }), + context: { getRuntimeConfig: () => cfg } as never, + }); + + expect(calls).toEqual([ + expect.objectContaining({ + ok: true, + payload: expect.objectContaining({ root: repoRoot }), + }), + ]); + expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "ops" }); + expect(hoisted.resolveAgentWorkspaceDir).toHaveBeenCalledWith(cfg, "ops"); + }); + + it("rejects a foreign agent before loading a bare fixed-store checkout", async () => { + const cfg = { + session: { store: "/tmp/shared.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as const; + const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; + + await sessionsDiffHandlers["sessions.diff"]?.({ + req: { type: "req", id: "sessions.diff", method: "sessions.diff", params: {} }, + params: { sessionKey: "global", agentId: "research" }, + client: null, + isWebchatConnect: () => false, + respond: (ok, payload, error) => calls.push({ ok, payload, error }), + context: { getRuntimeConfig: () => cfg } as never, + }); + + expect(calls).toEqual([ + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + code: "INVALID_REQUEST", + message: 'agent "research" does not match session key agent "ops"', + }), + }), + ]); + expect(hoisted.loadSessionEntry).not.toHaveBeenCalled(); + }); + it("diffs a feature branch against the local default branch", async () => { initRepo(repoRoot); fs.writeFileSync(path.join(repoRoot, "a.txt"), "one\ntwo\nthree\n"); diff --git a/src/gateway/server-methods/sessions-diff.ts b/src/gateway/server-methods/sessions-diff.ts index 0d78b48ccc15..aa10bfd8bd33 100644 --- a/src/gateway/server-methods/sessions-diff.ts +++ b/src/gateway/server-methods/sessions-diff.ts @@ -8,9 +8,10 @@ import { type SessionsDiffParams, type SessionsDiffResult, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { applySessionDiffBaseline, loadCheckoutDiff } from "../../sessions/session-diff.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -27,22 +28,23 @@ export async function loadSessionDiff(params: SessionsDiffParams): Promise { + "sessions.diff": async ({ params, respond, context }) => { if (!assertValidParams(params, validateSessionsDiffParams, "sessions.diff", respond)) { return; } @@ -92,6 +94,21 @@ export const sessionsDiffHandlers: GatewayRequestHandlers = { ); return; } - respond(true, await loadSessionDiff(params)); + const requestedAgent = resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + params.sessionKey, + params.agentId, + ); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + respond( + true, + await loadSessionDiff({ + ...params, + ...(requestedAgent.agentId ? { agentId: requestedAgent.agentId } : {}), + }), + ); }, }; diff --git a/src/gateway/server-methods/sessions-files.preview.test.ts b/src/gateway/server-methods/sessions-files.preview.test.ts new file mode 100644 index 000000000000..7854793e2ae0 --- /dev/null +++ b/src/gateway/server-methods/sessions-files.preview.test.ts @@ -0,0 +1,124 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sessionsFilesHandlers } from "./sessions-files.js"; +import { + createSessionFilesHandlerInvoker, + createVisibleMessagesMock, + expectOkPayload, + hashContent, + IMAGE_PREVIEW_FIXTURES, + prepareSessionFilesTest, + removeWorkspaceFixture, + TEXT_PREVIEW_FIXTURES, +} from "./sessions-files.test-support.js"; + +const mocks = vi.hoisted(() => ({ + execOpenPath: vi.fn(), + loadSessionEntry: vi.fn(), + resolveAgentWorkspaceDir: vi.fn(), + resolveDefaultAgentId: vi.fn(), + readSessionTranscriptVisibleMessageDeltaCore: vi.fn(), +})); + +vi.mock("../../agents/agent-scope.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, +})); +vi.mock("../session-utils.js", async () => { + const actual = await vi.importActual("../session-utils.js"); + return { + ...actual, + loadSessionEntry: mocks.loadSessionEntry, + loadGatewaySessionEntryReadOnly: mocks.loadSessionEntry, + }; +}); +vi.mock("../session-transcript-readers.js", async () => { + const actual = await vi.importActual( + "../session-transcript-readers.js", + ); + return { + ...actual, + readSessionTranscriptVisibleMessageDeltaCore: + mocks.readSessionTranscriptVisibleMessageDeltaCore, + }; +}); + +const invokeSessionFilesHandler = createSessionFilesHandlerInvoker(sessionsFilesHandlers); +const mockVisibleMessages = createVisibleMessagesMock( + mocks.readSessionTranscriptVisibleMessageDeltaCore, +); + +describe("sessions.files preview formats", () => { + let workspaceRoot: string; + + beforeEach(() => { + workspaceRoot = prepareSessionFilesTest(mocks, mockVisibleMessages); + }); + afterEach(() => { + removeWorkspaceFixture(workspaceRoot); + }); + + it.each(IMAGE_PREVIEW_FIXTURES)( + "previews sniffed $format bytes as a base64 image without a CAS hash", + async (fixture) => { + const fileName = `preview-${fixture.format.toLowerCase()}.bin`; + fs.writeFileSync(path.join(workspaceRoot, fileName), fixture.bytes); + const payload = expectOkPayload( + await invokeSessionFilesHandler("sessions.files.get", { + sessionKey: "agent:main:main", + path: fileName, + }), + ); + expect(payload.file).toMatchObject({ + content: fixture.bytes.toString("base64"), + contentEncoding: "base64", + mimeType: fixture.mimeType, + path: fileName, + previewKind: "image", + }); + expect(payload.file.hash).toBeUndefined(); + }, + ); + + it.each(TEXT_PREVIEW_FIXTURES)("keeps detected $format text editable", async (fixture) => { + const fileName = `detected-${fixture.format.toLowerCase().replaceAll(" ", "-")}.bin`; + fs.writeFileSync(path.join(workspaceRoot, fileName), fixture.content, "utf8"); + const payload = expectOkPayload( + await invokeSessionFilesHandler("sessions.files.get", { + sessionKey: "agent:main:main", + path: fileName, + }), + ); + expect(payload.file).toMatchObject({ + content: fixture.content, + contentEncoding: "utf8", + hash: hashContent(fixture.content), + mimeType: fixture.mimeType, + path: fileName, + previewKind: "text", + }); + }); + + it("returns unsupported binary metadata without lossy inline content", async () => { + const binary = Buffer.concat([Buffer.from("SQLite format 3\0"), Buffer.alloc(64, 7)]); + fs.writeFileSync(path.join(workspaceRoot, "cache.db"), binary); + const payload = expectOkPayload( + await invokeSessionFilesHandler("sessions.files.get", { + sessionKey: "agent:main:main", + path: "cache.db", + }), + ); + expect(payload.file).toMatchObject({ + mimeType: "application/x-sqlite3", + missing: false, + path: "cache.db", + previewKind: "unsupported", + size: binary.length, + }); + expect(payload.file.content).toBeUndefined(); + expect(payload.file.contentEncoding).toBeUndefined(); + expect(payload.file.hash).toBeUndefined(); + }); +}); diff --git a/src/gateway/server-methods/sessions-files.test-support.ts b/src/gateway/server-methods/sessions-files.test-support.ts index b69c892b1b0a..a5cc90cfc79b 100644 --- a/src/gateway/server-methods/sessions-files.test-support.ts +++ b/src/gateway/server-methods/sessions-files.test-support.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { expect } from "vitest"; +import { expect, vi } from "vitest"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; type SessionFilesMethod = @@ -14,6 +14,58 @@ type SessionFilesMethod = type ResponderCall = { ok: boolean; payload?: unknown; error?: unknown }; type ReturnValueMock = { mockReturnValue: (value: unknown) => unknown }; +export const IMAGE_PREVIEW_FIXTURES = [ + { + format: "AVIF", + mimeType: "image/avif", + bytes: Buffer.from([ + 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66, 0x00, 0x00, 0x00, + 0x00, 0x61, 0x76, 0x69, 0x66, + ]), + }, + { format: "GIF", mimeType: "image/gif", bytes: Buffer.from("GIF89a", "ascii") }, + { + format: "JPEG", + mimeType: "image/jpeg", + bytes: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]), + }, + { + format: "PNG", + mimeType: "image/png", + bytes: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ), + }, + { + format: "WebP", + mimeType: "image/webp", + bytes: Buffer.concat([Buffer.from("RIFF", "ascii"), Buffer.alloc(4), Buffer.from("WEBP")]), + }, +] as const; + +export const TEXT_PREVIEW_FIXTURES = [ + { format: "RTF", mimeType: "application/rtf", content: "{\\rtf1\\ansi hello}" }, + { format: "XML", mimeType: "text/xml", content: '' }, + { format: "WebVTT", mimeType: "text/vtt", content: "WEBVTT\n\n00:00.000 --> 00:01.000\nHi" }, + { format: "vCard", mimeType: "text/vcard", content: "BEGIN:VCARD\nVERSION:4.0\nEND:VCARD\n" }, + { + format: "iCalendar", + mimeType: "text/calendar", + content: "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n", + }, + { + format: "registry", + mimeType: "application/x-ms-regedit", + content: "REGEDIT4\r\n\r\n[HKEY_CURRENT_USER\\Software]", + }, + { + format: "ASCII STL", + mimeType: "model/stl", + content: "solid test\nfacet normal 0 0 0\nendfacet\nendsolid test\n", + }, +] as const; + function createResponder() { const calls: ResponderCall[] = []; const respond: RespondFn = (ok, payload, error) => { @@ -35,7 +87,10 @@ export function createSessionFilesHandlerInvoker(handlers: GatewayRequestHandler client: null, isWebchatConnect: () => false, respond: responder.respond, - context: context as never, + context: { + getRuntimeConfig: () => ({ agents: { list: [{ id: "main", default: true }] } }), + ...context, + } as never, }); return responder.calls; }; @@ -103,16 +158,48 @@ export function createWorkspaceFixture(prefix: string): string { return workspaceRoot; } +export function removeWorkspaceFixture(workspaceRoot: string): void { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); +} + +export function prepareSessionFilesTest( + mocks: { + execOpenPath: ReturnValueMock & { mockResolvedValue: (value: unknown) => unknown }; + loadSessionEntry: ReturnValueMock; + readSessionTranscriptVisibleMessageDeltaCore: ReturnValueMock & { mockReset: () => unknown }; + resolveAgentWorkspaceDir: ReturnValueMock; + resolveDefaultAgentId: ReturnValueMock; + }, + mockVisibleMessages: (messages: unknown[]) => void, +): string { + vi.clearAllMocks(); + mocks.readSessionTranscriptVisibleMessageDeltaCore.mockReset(); + const workspaceRoot = createWorkspaceFixture("openclaw-session-files-test-"); + mocks.resolveDefaultAgentId.mockReturnValue("main"); + mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceRoot); + mocks.execOpenPath.mockResolvedValue(undefined); + mocks.loadSessionEntry.mockReturnValue(createSessionEntryFixture(workspaceRoot, "sess-main")); + mockVisibleMessages([ + assistantToolCall("edit", { path: "ui/chat.ts" }), + assistantToolCall("read", { path: "src/readme.md" }), + assistantToolCall("apply_patch", { + input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n", + }), + ]); + return workspaceRoot; +} + export function hashContent(content: string): string { return createHash("sha256").update(content, "utf8").digest("hex"); } -export function createSessionEntryFixture( +function createSessionEntryFixture( workspaceRoot: string, sessionId: string, storePath = path.join(workspaceRoot, ".sessions.json"), ) { return { + agentId: "main", canonicalKey: "agent:main:main", cfg: {}, storePath, @@ -131,6 +218,7 @@ export function useSqliteSession( storePath = path.join(workspaceRoot, `${sessionId}.sqlite`), ): string { loadSessionEntry.mockReturnValue({ + agentId: "main", canonicalKey: "agent:main:main", cfg: {}, storePath, diff --git a/src/gateway/server-methods/sessions-files.test.ts b/src/gateway/server-methods/sessions-files.test.ts index 4017a0af286a..ad62d50ad066 100644 --- a/src/gateway/server-methods/sessions-files.test.ts +++ b/src/gateway/server-methods/sessions-files.test.ts @@ -8,13 +8,13 @@ import { resolveOpenPathCommand } from "./open-path.js"; import { resolveLocalSessionWorkspaceRoot, sessionsFilesHandlers } from "./sessions-files.js"; import { assistantToolCall, - createSessionEntryFixture, createSessionFilesHandlerInvoker, createVisibleMessagesMock, - createWorkspaceFixture, expectError, expectOkPayload, hashContent, + prepareSessionFilesTest, + removeWorkspaceFixture, writeWorkspaceFile, } from "./sessions-files.test-support.js"; import { updateWorkspaceFile } from "./workspace-fs.js"; @@ -32,7 +32,8 @@ vi.mock("./open-path.js", async () => { return { ...actual, execOpenPath: hoisted.execOpenPath }; }); -vi.mock("../../agents/agent-scope.js", () => ({ +vi.mock("../../agents/agent-scope.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveAgentWorkspaceDir: hoisted.resolveAgentWorkspaceDir, resolveDefaultAgentId: hoisted.resolveDefaultAgentId, })); @@ -66,24 +67,11 @@ describe("sessions.files RPC handlers", () => { let workspaceRoot: string; beforeEach(() => { - vi.clearAllMocks(); - hoisted.readSessionTranscriptVisibleMessageDeltaCore.mockReset(); - workspaceRoot = createWorkspaceFixture("openclaw-session-files-test-"); - hoisted.resolveDefaultAgentId.mockReturnValue("main"); - hoisted.resolveAgentWorkspaceDir.mockReturnValue(workspaceRoot); - hoisted.execOpenPath.mockResolvedValue(undefined); - hoisted.loadSessionEntry.mockReturnValue(createSessionEntryFixture(workspaceRoot, "sess-main")); - mockVisibleMessages([ - assistantToolCall("edit", { path: "ui/chat.ts" }), - assistantToolCall("read", { path: "src/readme.md" }), - assistantToolCall("apply_patch", { - input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n", - }), - ]); + workspaceRoot = prepareSessionFilesTest(hoisted, mockVisibleMessages); }); afterEach(() => { - fs.rmSync(workspaceRoot, { recursive: true, force: true }); + removeWorkspaceFixture(workspaceRoot); }); it("reveals the same workspace root returned by sessions.files.list", async () => { @@ -106,6 +94,76 @@ describe("sessions.files RPC handlers", () => { ); }); + it("uses the persisted fixed-store owner for a bare session workspace", async () => { + const cfg = { + session: { store: path.join(workspaceRoot, "shared.sqlite"), scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as const; + hoisted.loadSessionEntry.mockReturnValue({ + agentId: "ops", + canonicalKey: "global", + cfg, + storePath: cfg.session.store, + entry: { sessionId: "sess-owned-global" }, + }); + hoisted.resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, agentId: string) => + agentId === "ops" ? workspaceRoot : path.join(workspaceRoot, "wrong-research"), + ); + + const payload = expectOkPayload( + await invokeSessionFilesHandler( + "sessions.files.list", + { sessionKey: "global" }, + { getRuntimeConfig: () => cfg }, + ), + ); + + expect(payload.root).toBe(workspaceRoot); + expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "ops" }); + expect(hoisted.readSessionTranscriptVisibleMessageDeltaCore).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "ops", sessionKey: "global" }), + expect.any(Object), + ); + }); + + it("rejects a foreign agent before a bare fixed-store workspace write", async () => { + const cfg = { + session: { store: path.join(workspaceRoot, "shared.sqlite"), scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as const; + + const error = expectError( + await invokeSessionFilesHandler( + "sessions.files.set", + { + sessionKey: "global", + agentId: "research", + path: "ui/chat.ts", + content: "foreign write\n", + expectedHash: hashContent("export const chat = true;\n"), + }, + { getRuntimeConfig: () => cfg }, + ), + ); + + expect(error).toMatchObject({ + code: "INVALID_REQUEST", + message: 'agent "research" does not match session key agent "ops"', + }); + expect(hoisted.loadSessionEntry).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(workspaceRoot, "ui/chat.ts"), "utf8")).toBe( + "export const chat = true;\n", + ); + }); + it("refuses to reveal a remote session workspace", async () => { const payload = expectOkPayload( await invokeSessionFilesHandler( @@ -889,119 +947,6 @@ describe("sessions.files RPC handlers", () => { ); }); - it.each([ - { - format: "AVIF", - mimeType: "image/avif", - bytes: Buffer.from([ - 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66, 0x00, 0x00, 0x00, - 0x00, 0x61, 0x76, 0x69, 0x66, - ]), - }, - { format: "GIF", mimeType: "image/gif", bytes: Buffer.from("GIF89a", "ascii") }, - { - format: "JPEG", - mimeType: "image/jpeg", - bytes: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]), - }, - { - format: "PNG", - mimeType: "image/png", - bytes: Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), - }, - { - format: "WebP", - mimeType: "image/webp", - bytes: Buffer.concat([Buffer.from("RIFF", "ascii"), Buffer.alloc(4), Buffer.from("WEBP")]), - }, - ])("previews sniffed $format bytes as a base64 image without a CAS hash", async (fixture) => { - const fileName = `preview-${fixture.format.toLowerCase()}.bin`; - fs.writeFileSync(path.join(workspaceRoot, fileName), fixture.bytes); - - const payload = expectOkPayload( - await invokeSessionFilesHandler("sessions.files.get", { - sessionKey: "agent:main:main", - path: fileName, - }), - ); - - expect(payload.file).toMatchObject({ - content: fixture.bytes.toString("base64"), - contentEncoding: "base64", - mimeType: fixture.mimeType, - path: fileName, - previewKind: "image", - }); - expect(payload.file.hash).toBeUndefined(); - }); - - it.each([ - { format: "RTF", mimeType: "application/rtf", content: "{\\rtf1\\ansi hello}" }, - { format: "XML", mimeType: "text/xml", content: '' }, - { format: "WebVTT", mimeType: "text/vtt", content: "WEBVTT\n\n00:00.000 --> 00:01.000\nHi" }, - { format: "vCard", mimeType: "text/vcard", content: "BEGIN:VCARD\nVERSION:4.0\nEND:VCARD\n" }, - { - format: "iCalendar", - mimeType: "text/calendar", - content: "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n", - }, - { - format: "registry", - mimeType: "application/x-ms-regedit", - content: "REGEDIT4\r\n\r\n[HKEY_CURRENT_USER\\Software]", - }, - { - format: "ASCII STL", - mimeType: "model/stl", - content: "solid test\nfacet normal 0 0 0\nendfacet\nendsolid test\n", - }, - ])("keeps detected $format text editable", async (fixture) => { - const fileName = `detected-${fixture.format.toLowerCase().replaceAll(" ", "-")}.bin`; - fs.writeFileSync(path.join(workspaceRoot, fileName), fixture.content, "utf8"); - - const payload = expectOkPayload( - await invokeSessionFilesHandler("sessions.files.get", { - sessionKey: "agent:main:main", - path: fileName, - }), - ); - - expect(payload.file).toMatchObject({ - content: fixture.content, - contentEncoding: "utf8", - hash: hashContent(fixture.content), - mimeType: fixture.mimeType, - path: fileName, - previewKind: "text", - }); - }); - - it("returns unsupported binary metadata without lossy inline content", async () => { - const binary = Buffer.concat([Buffer.from("SQLite format 3\0"), Buffer.alloc(64, 7)]); - fs.writeFileSync(path.join(workspaceRoot, "cache.db"), binary); - - const payload = expectOkPayload( - await invokeSessionFilesHandler("sessions.files.get", { - sessionKey: "agent:main:main", - path: "cache.db", - }), - ); - - expect(payload.file).toMatchObject({ - mimeType: "application/x-sqlite3", - missing: false, - path: "cache.db", - previewKind: "unsupported", - size: binary.length, - }); - expect(payload.file.content).toBeUndefined(); - expect(payload.file.contentEncoding).toBeUndefined(); - expect(payload.file.hash).toBeUndefined(); - }); - it.each([ { format: "BMP", diff --git a/src/gateway/server-methods/sessions-files.touched-files.test.ts b/src/gateway/server-methods/sessions-files.touched-files.test.ts index 7337c7e6e79d..6810fe288a96 100644 --- a/src/gateway/server-methods/sessions-files.touched-files.test.ts +++ b/src/gateway/server-methods/sessions-files.touched-files.test.ts @@ -22,7 +22,8 @@ const hoisted = vi.hoisted(() => ({ readSessionTranscriptVisibleMessageDeltaCore: vi.fn(), })); -vi.mock("../../agents/agent-scope.js", () => ({ +vi.mock("../../agents/agent-scope.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveAgentWorkspaceDir: hoisted.resolveAgentWorkspaceDir, resolveDefaultAgentId: hoisted.resolveDefaultAgentId, })); diff --git a/src/gateway/server-methods/sessions-files.ts b/src/gateway/server-methods/sessions-files.ts index ebc36a2ecd80..c512f7951aea 100644 --- a/src/gateway/server-methods/sessions-files.ts +++ b/src/gateway/server-methods/sessions-files.ts @@ -18,12 +18,14 @@ import { validateSessionsFilesListParams, validateSessionsFilesSetParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { resolveToCwd as resolveSessionToolPathToCwd } from "../../agents/sessions/tools/path-utils.js"; import { runGit } from "../../agents/worktrees/git.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { FsSafeError } from "../../infra/fs-safe.js"; import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { readSessionTranscriptVisibleMessageDeltaCore, resolveTranscriptReadTarget, @@ -523,10 +525,10 @@ function loadSessionFileRoot(params: { sessionKey: string; agentId?: string }) { return { ...loaded, agentId: undefined, root: undefined, fileRoot: undefined }; } const agentId = normalizeAgentId( - parseAgentSessionKey(loaded.canonicalKey)?.agentId ?? + loaded.agentId ?? + parseAgentSessionKey(loaded.canonicalKey)?.agentId ?? params.agentId ?? - parseAgentSessionKey(params.sessionKey)?.agentId ?? - resolveDefaultAgentId(loaded.cfg), + parseAgentSessionKey(params.sessionKey)?.agentId, ); const spawnedCwd = normalizeOptionalString(loaded.entry.spawnedCwd); const spawnedWorkspaceDir = normalizeOptionalString(loaded.entry.spawnedWorkspaceDir); @@ -863,25 +865,61 @@ function respondSessionFileUnsafe(respond: RespondFn, filePath: string) { ); } +function requireSessionFilesAgentId(params: { + cfg: OpenClawConfig; + sessionKey: string; + agentId?: string; + respond: RespondFn; +}): string | undefined { + const requestedAgent = resolveRequestedSessionAgentId( + params.cfg, + params.sessionKey, + params.agentId, + ); + if (!requestedAgent.ok) { + params.respond(false, undefined, requestedAgent.error); + return undefined; + } + return requestedAgent.agentId; +} + /** Gateway handlers for session files and workspace browsing. */ export const sessionsFilesHandlers: GatewayRequestHandlers = { - "sessions.files.list": async ({ params, respond }) => { + "sessions.files.list": async ({ params, respond, context }) => { if ( !assertValidParams(params, validateSessionsFilesListParams, "sessions.files.list", respond) ) { return; } - const result = await buildListResult(params); + const agentId = requireSessionFilesAgentId({ + cfg: context.getRuntimeConfig(), + sessionKey: params.sessionKey, + agentId: params.agentId, + respond, + }); + if (!agentId) { + return; + } + const result = await buildListResult({ ...params, agentId }); respond(true, { sessionKey: params.sessionKey, ...result, }); }, - "sessions.files.get": async ({ params, respond }) => { + "sessions.files.get": async ({ params, respond, context }) => { if (!assertValidParams(params, validateSessionsFilesGetParams, "sessions.files.get", respond)) { return; } - const result = await findSessionFile(params); + const agentId = requireSessionFilesAgentId({ + cfg: context.getRuntimeConfig(), + sessionKey: params.sessionKey, + agentId: params.agentId, + respond, + }); + if (!agentId) { + return; + } + const result = await findSessionFile({ ...params, agentId }); if (!result.file || result.file.missing) { respondSessionFileNotFound(respond, params.path); return; @@ -895,10 +933,19 @@ export const sessionsFilesHandlers: GatewayRequestHandlers = { ...result, }); }, - "sessions.files.set": async ({ params, respond, sessionMutationAuthorization }) => { + "sessions.files.set": async ({ params, respond, context, sessionMutationAuthorization }) => { if (!assertValidParams(params, validateSessionsFilesSetParams, "sessions.files.set", respond)) { return; } + const agentId = requireSessionFilesAgentId({ + cfg: context.getRuntimeConfig(), + sessionKey: params.sessionKey, + agentId: params.agentId, + respond, + }); + if (!agentId) { + return; + } // NUL bytes would make the written file fail decodeUtf8Strict on the next // read, stranding it without a CAS hash; reject them up front so the API // never writes content its own editability checks classify as binary. @@ -926,7 +973,7 @@ export const sessionsFilesHandlers: GatewayRequestHandlers = { respondSessionFileUnsafe(respond, params.path); return; } - const loaded = loadSessionFileRoot(params); + const loaded = loadSessionFileRoot({ ...params, agentId }); if (!loaded.root) { respondSessionFileNotFound(respond, params.path); return; @@ -1008,7 +1055,16 @@ export const sessionsFilesHandlers: GatewayRequestHandlers = { ) { return; } - const loaded = loadSessionFileRoot({ sessionKey: params.key, agentId: params.agentId }); + const agentId = requireSessionFilesAgentId({ + cfg: context.getRuntimeConfig(), + sessionKey: params.key, + agentId: params.agentId, + respond, + }); + if (!agentId) { + return; + } + const loaded = loadSessionFileRoot({ sessionKey: params.key, agentId }); const workspaceRoot = loaded.root; if (!workspaceRoot) { respond(true, { diff --git a/src/gateway/server-methods/sessions-messaging.ts b/src/gateway/server-methods/sessions-messaging.ts index 1763314b5172..b95374c7b4b5 100644 --- a/src/gateway/server-methods/sessions-messaging.ts +++ b/src/gateway/server-methods/sessions-messaging.ts @@ -7,7 +7,6 @@ import { errorShape, validateSessionsSendParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { abortEmbeddedAgentRun, isEmbeddedAgentRunActive, @@ -17,7 +16,10 @@ import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; import { resolveSessionWorkStartError, type SessionEntry } from "../../config/sessions.js"; import { isSessionTranscriptProjectionUnavailableError } from "../../config/sessions/session-accessor.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; -import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js"; +import { + resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, + tryResolveSessionCompatibilityOwnerAgentId, +} from "../session-request-agent.js"; import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js"; import { readSessionMessageCountAsync } from "../session-transcript-readers.js"; import { @@ -193,7 +195,7 @@ async function createAgentMainSessionForSend(params: { } const createdKey = normalizeOptionalString(createResult.payload?.key) ?? params.canonicalKey; - const loaded = loadGatewaySessionEntryReadOnly(createdKey); + const loaded = loadGatewaySessionEntryReadOnly(createdKey, { agentId }); if (!loaded.entry?.sessionId) { return { ok: false, @@ -225,7 +227,7 @@ export async function interruptSessionRunIfActive(params: { requestedKey: params.requestedKey, canonicalKey: params.canonicalKey, agentId: params.agentId, - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, params.canonicalKey), excludeRunIds: params.excludeRunIds, }); const hasEmbeddedRun = @@ -250,6 +252,8 @@ export async function interruptSessionRunIfActive(params: { context: params.context, requestedKey: params.requestedKey, canonicalKey: params.canonicalKey, + agentId: params.agentId, + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, params.canonicalKey), }); await handleChatAbortRequestWithLifecycle( @@ -257,9 +261,7 @@ export async function interruptSessionRunIfActive(params: { req: params.req, params: { sessionKey: abortSessionKey, - ...(params.canonicalKey === "global" && params.agentId - ? { agentId: params.agentId } - : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), }, respond: (ok, _payload, error) => { abortOk = ok; @@ -383,7 +385,7 @@ async function handleSessionSend(params: { req: params.req, params: { sessionKey: canonicalKey, - ...(canonicalKey === "global" && requestedAgentId ? { agentId: requestedAgentId } : {}), + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), message: (p as { message: string }).message, thinking: (p as { thinking?: string }).thinking, attachments: (p as { attachments?: unknown[] }).attachments, @@ -557,7 +559,7 @@ async function handleSessionSend(params: { } emitSessionsChanged(params.context, { sessionKey: canonicalKey, - ...(canonicalKey === "global" && requestedAgentId ? { agentId: requestedAgentId } : {}), + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), reason: interruptedActiveRun ? "steer" : "send", }); } diff --git a/src/gateway/server-methods/sessions-mutations.ts b/src/gateway/server-methods/sessions-mutations.ts index f31439cd5576..51a78241120d 100644 --- a/src/gateway/server-methods/sessions-mutations.ts +++ b/src/gateway/server-methods/sessions-mutations.ts @@ -11,6 +11,8 @@ import { import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js"; import { isPluginJsonValue } from "../../plugins/host-hooks.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { resolveOperatorSessionCreation } from "./session-creation-provenance.js"; import { executeSessionPatch, executeSessionPatchMany } from "./sessions-patch-engine.js"; @@ -128,9 +130,24 @@ export const sessionMutationHandlers: GatewayRequestHandlers = { ); return; } + const requestedAgent = resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + key, + params.agentId, + ); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const canonicalKey = resolveStoredSessionKeyForAgentStore({ + cfg: context.getRuntimeConfig(), + agentId: requestedAgent.agentId, + sessionKey: key, + }); const patched = await patchPluginSessionExtension({ cfg: context.getRuntimeConfig(), - sessionKey: key, + sessionKey: canonicalKey, + agentId: requestedAgent.agentId, pluginId, namespace, value: params.value, @@ -144,6 +161,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = { respond(true, { ok: true, key: patched.key, value: patched.value }, undefined); emitSessionsChanged(context, { sessionKey: patched.key, + agentId: requestedAgent.agentId, reason: "plugin-patch", }); }, @@ -188,7 +206,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: result.key, - ...(result.key === "global" ? { agentId: result.agentId } : {}), + agentId: result.agentId, reason, }); }, diff --git a/src/gateway/server-methods/sessions-patch-archive.ts b/src/gateway/server-methods/sessions-patch-archive.ts index a472e0ea16ee..2b05e8e6e38d 100644 --- a/src/gateway/server-methods/sessions-patch-archive.ts +++ b/src/gateway/server-methods/sessions-patch-archive.ts @@ -6,7 +6,6 @@ import { type SessionCreatedActor, type SessionsPatchParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import type { SessionEntry } from "../../config/sessions.js"; import { SESSION_LIFECYCLE_CHANGED_ERROR_REASON } from "../../config/sessions/lifecycle.js"; @@ -14,6 +13,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { resolveMissingAgentHarnessSessionError } from "../../sessions/agent-harness-session-key.js"; import { resolvePluginSessionOwnershipError } from "../session-plugin-ownership.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { resolveCanonicalGatewaySessionStoreKey, resolveGatewaySessionStoreTargetWithStore, @@ -217,7 +217,7 @@ export async function prepareSessionPatchArchive(params: { sessionId: fresh.entry?.sessionId, sessionKey: freshCanonicalKey, agentId: freshResolved.agentId, - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, freshCanonicalKey), lifecycleIdentities: target.lifecycleIdentities.filter((identity): identity is string => Boolean(identity), ), diff --git a/src/gateway/server-methods/sessions-patch-engine.ts b/src/gateway/server-methods/sessions-patch-engine.ts index 0602f12efe0b..3c8a6adf1134 100644 --- a/src/gateway/server-methods/sessions-patch-engine.ts +++ b/src/gateway/server-methods/sessions-patch-engine.ts @@ -147,20 +147,27 @@ async function executeSessionPatchMutations(params: { const targetDiscoveryCache = new Map(); const preflightTargets = params.targets.map((input) => { const key = input.key.trim(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, input.agentId); return { input, key, - resolved: resolveGatewaySessionStoreTargetWithStore({ - cfg, - key, - ...(input.agentId ? { agentId: input.agentId } : {}), - exactRead: true, - targetDiscoveryCache, - }), + requestedAgent, + resolved: requestedAgent.ok + ? resolveGatewaySessionStoreTargetWithStore({ + cfg, + key, + agentId: requestedAgent.agentId, + exactRead: true, + targetDiscoveryCache, + }) + : undefined, }; }); const logicalTargets = new Set(); for (const { key, resolved } of preflightTargets) { + if (!resolved) { + continue; + } const logicalId = `${resolved.storePath}\0${resolved.canonicalKey ?? key}`; if (logicalTargets.has(logicalId)) { return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, "Duplicate target.") }; @@ -175,12 +182,18 @@ async function executeSessionPatchMutations(params: { const preparedByIndex: Array = Array.from({ length: params.targets.length, }); - for (const [index, { input, key, resolved }] of preflightTargets.entries()) { - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, input.agentId); + for (const [index, { input, key, requestedAgent, resolved }] of preflightTargets.entries()) { if (!requestedAgent.ok) { outcomes[index] = requestedAgent; continue; } + if (!resolved) { + outcomes[index] = { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "Session target could not be resolved."), + }; + continue; + } const requestedAgentId = requestedAgent.agentId; const canonicalKey = resolved.canonicalKey ?? key; const candidateKeys = resolved.storeKeys; @@ -560,9 +573,7 @@ async function executeSessionPatchMutations(params: { }); emitSessionsChanged(params.context, { sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.requestedAgentId - ? { agentId: target.requestedAgentId } - : {}), + ...(target.requestedAgentId ? { agentId: target.requestedAgentId } : {}), reason: "patch", }); patched = true; diff --git a/src/gateway/server-methods/sessions-patch-model-selection.ts b/src/gateway/server-methods/sessions-patch-model-selection.ts index 1ad5e34747e0..6dcf358d76bd 100644 --- a/src/gateway/server-methods/sessions-patch-model-selection.ts +++ b/src/gateway/server-methods/sessions-patch-model-selection.ts @@ -26,7 +26,7 @@ export function persistSessionPatchModelSelection(params: { const agentId = resolveSessionAgentId({ config: params.cfg, sessionKey: params.sessionKey, - ...(params.sessionKey === "global" ? { agentId: params.targetAgentId } : {}), + agentId: params.targetAgentId, }); const resolved = resolveSessionModelRef(params.cfg, params.entry, agentId); persistStickyModelSelectionBestEffort({ diff --git a/src/gateway/server-methods/sessions-read.test.ts b/src/gateway/server-methods/sessions-read.test.ts index 0f83ee17522d..692f50a71220 100644 --- a/src/gateway/server-methods/sessions-read.test.ts +++ b/src/gateway/server-methods/sessions-read.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js"; +import { resolveSessionStorePathCore as resolveStorePath } from "../../config/sessions.js"; import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import { resolveSqliteTargetFromSessionStorePath } from "../../config/sessions/session-sqlite-target.js"; import { @@ -100,12 +101,14 @@ test("agents.list reads published model facts without starting provider discover }); beforeEach(async () => { + testState.agentConfig = undefined; testState.sessionStorePath = undefined; testState.sessionConfig = undefined; await setAgentsConfig(undefined); }); afterEach(() => { + testState.agentConfig = undefined; testState.sessionStorePath = undefined; testState.sessionConfig = undefined; closeOpenClawAgentDatabasesForTest(); @@ -151,6 +154,45 @@ test("unknown-agent session reads return missing results without provisioning an expect(await listAgentIdsViaRpc()).toEqual(["main"]); }); +test("bare ownerless reads fail closed without blocking scoped preview siblings", async () => { + await setAgentsConfig({ ownership: "explicit", entries: { ops: {}, research: {} } }); + const { getRuntimeConfig } = await getGatewayConfigModule(); + expect(getRuntimeConfig().agents).toMatchObject({ + ownership: "explicit", + entries: { ops: {}, research: {} }, + }); + const sessionKey = "agent:ops:preview-valid"; + const sessionId = "session-ops-preview-valid"; + const storePath = resolveStorePath(undefined, { agentId: "ops" }); + await replaceSessionEntry( + { agentId: "ops", sessionKey, storePath }, + { sessionId, updatedAt: 42 }, + ); + await seedLinearSessionTranscript({ + agentId: "ops", + contents: ["scoped preview remains readable"], + sessionId, + sessionKey, + storePath, + }); + + const described = await directSessionReq<{ session: unknown }>("sessions.describe", { + key: "global", + }); + expect(described).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("has no explicit owner") }, + }); + + const preview = await directSessionReq<{ + previews: Array<{ key: string; status: string; items: unknown[] }>; + }>("sessions.preview", { keys: ["global", sessionKey] }); + expect(preview).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("has no explicit owner") }, + }); +}); + test("sessions.describe reads a pre-existing store after its agent is removed from config", async () => { const storePath = path.join( requireStateDir(), diff --git a/src/gateway/server-methods/sessions-read.ts b/src/gateway/server-methods/sessions-read.ts index f78d3d76dbd9..f89cd5b4dd5d 100644 --- a/src/gateway/server-methods/sessions-read.ts +++ b/src/gateway/server-methods/sessions-read.ts @@ -11,9 +11,7 @@ import { validateSessionsResolveParams, validateSessionsSearchParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { - isConfiguredSessionStoreAgentId, isPerAgentSessionStoreConfig, listSessionMembershipKeys, resolveExistingAgentSessionStoreTargetsSync, @@ -35,7 +33,10 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; -import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js"; +import { + resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, + tryResolveSessionCompatibilityOwnerAgentId, +} from "../session-request-agent.js"; import { canAccessIncognitoSession, createSessionListEntryFilter, @@ -44,11 +45,7 @@ import { resolveSessionSharingTarget, resolveSessionVisibility, } from "../session-sharing.js"; -import { - resolveSessionStoreAgentId, - resolveSessionStoreKey, - resolveStoredSessionKeyForAgentStore, -} from "../session-store-key.js"; +import { resolveSessionStoreAgentId } from "../session-store-key.js"; import { readRecentSessionMessagesWithStatsAsync, readSessionPreviewItemsFromTranscript, @@ -77,6 +74,7 @@ import { } from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { respondWithCachedSessionList } from "./sessions-list-cache.js"; +import { resolveSessionSearchScope } from "./sessions-search-scope.js"; import { filterSessionStoreToConfiguredAgents, loadSessionEntriesForTarget, @@ -101,33 +99,12 @@ export const sessionReadHandlers: GatewayRequestHandlers = { const canSearchSessionKey = (sessionKey: string) => !isIncognitoSessionKey(sessionKey) || canAccessIncognitoSession({ cfg, client: client ?? null, sessionKey }); - const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; - const sessionKeys = params.sessionKeys?.map((sessionKey) => - requestedAgentId - ? resolveStoredSessionKeyForAgentStore({ cfg, agentId: requestedAgentId, sessionKey }) - : resolveSessionStoreKey({ cfg, sessionKey }), - ); - const agentIds = new Set( - sessionKeys?.map((sessionKey) => - requestedAgentId && (sessionKey === "global" || sessionKey === "unknown") - ? requestedAgentId - : resolveSessionStoreAgentId(cfg, sessionKey), - ), - ); - if ( - agentIds.size > 1 || - (requestedAgentId && [...agentIds].some((agentId) => agentId !== requestedAgentId)) - ) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "sessions.search supports one agent per call"), - ); + const scope = resolveSessionSearchScope(cfg, params); + if (!scope.ok) { + respond(false, undefined, scope.error); return; } - const agentId = - requestedAgentId ?? agentIds.values().next().value ?? resolveDefaultAgentId(cfg); - const configured = isConfiguredSessionStoreAgentId(cfg, agentId); + const { agentId, configured, requestedAgentId, sessionKeys } = scope; if (requestedAgentId && !params.sessionKeys && configured) { respond( false, @@ -395,7 +372,6 @@ export const sessionReadHandlers: GatewayRequestHandlers = { ); const trackedActiveRuns = collectTrackedActiveSessionRuns(context); const projectedAgentRunIndex = buildProjectedAgentRunIndex(); - const defaultAgentId = resolveDefaultAgentId(cfg); const sessions = measureDiagnosticsTimelineSpanSync( "gateway.sessions.list.active_run_flags", () => { @@ -412,8 +388,8 @@ export const sessionReadHandlers: GatewayRequestHandlers = { requestedKey: session.key, canonicalKey: session.key, sessionId: session.sessionId, - ...(session.key === "global" && p.agentId ? { agentId: p.agentId } : {}), - defaultAgentId, + agentId: session.agentId, + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, session.key), trackedActiveRuns, projectedAgentRunIndex, }); @@ -568,10 +544,16 @@ export const sessionReadHandlers: GatewayRequestHandlers = { const previews: SessionsPreviewEntry[] = []; for (const key of keys) { + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } try { const cachedStoreTarget = resolveGatewaySessionStoreTargetWithStore({ cfg, key, + agentId: requestedAgent.agentId, }); // Fixed stores share a legacy path but resolve to owner-specific SQLite databases. Keep // synthetic misses from poisoning another agent's real store entry in this batch. @@ -581,6 +563,7 @@ export const sessionReadHandlers: GatewayRequestHandlers = { const target = resolveGatewaySessionStoreTarget({ cfg, key, + agentId: requestedAgent.agentId, store, }); const entry = resolveCanonicalSessionEntryFromStoreKeys(store, target.storeKeys); @@ -620,7 +603,16 @@ export const sessionReadHandlers: GatewayRequestHandlers = { return; } const cfg = context.getRuntimeConfig(); - const { target, storePath, store, entry } = loadSessionEntriesForTarget({ key, cfg }); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { target, storePath, store, entry } = loadSessionEntriesForTarget({ + key, + cfg, + ...(requestedAgent.agentId ? { agentId: requestedAgent.agentId } : {}), + }); if (!entry) { respond(true, { session: null }, undefined); return; @@ -666,7 +658,7 @@ export const sessionReadHandlers: GatewayRequestHandlers = { respond(true, { ok: false, candidates: resolved.candidates }, undefined); return; } - respond(true, { ok: true, key: resolved.key }, undefined); + respond(true, { ok: true, key: resolved.key, agentId: resolved.agentId }, undefined); }, "sessions.get": async ({ params, respond, context }) => { const p = params as { diff --git a/src/gateway/server-methods/sessions-rewind.ts b/src/gateway/server-methods/sessions-rewind.ts index ad67faf4cb64..0941871e78ed 100644 --- a/src/gateway/server-methods/sessions-rewind.ts +++ b/src/gateway/server-methods/sessions-rewind.ts @@ -7,7 +7,6 @@ import { validateSessionsForkParams, validateSessionsRewindParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { listRegisteredAgentHarnesses } from "../../agents/harness/registry.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; import { @@ -30,7 +29,10 @@ import { type SessionUpstreamLink, } from "../../sessions/session-upstream-links.js"; import { buildDashboardSessionKey } from "../session-create-service.js"; -import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js"; +import { + resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, + tryResolveSessionCompatibilityOwnerAgentId, +} from "../session-request-agent.js"; import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; @@ -286,7 +288,7 @@ async function mutateSessionAtMessage( canonicalKey: current.canonicalKey, sessionId: initialSessionId, agentId: requestedAgent.agentId, - defaultAgentId: resolveDefaultAgentId(cfg), + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, sessionKey), }); }, run: async () => { @@ -412,9 +414,7 @@ async function mutateSessionAtMessage( ); emitSessionsChanged(context, { sessionKey: upstreamFork.key, - ...(upstreamFork.key === "global" && requestedAgent.agentId - ? { agentId: requestedAgent.agentId } - : {}), + agentId: requestedAgent.agentId, reason: "fork", }); return; @@ -507,10 +507,7 @@ async function mutateSessionAtMessage( ); emitSessionsChanged(context, { sessionKey: action === "fork" ? result.key : current.canonicalKey, - ...((action === "fork" ? result.key : current.canonicalKey) === "global" && - requestedAgent.agentId - ? { agentId: requestedAgent.agentId } - : {}), + agentId: requestedAgent.agentId, reason: action === "switch" ? "branch-switch" : action, }); }, diff --git a/src/gateway/server-methods/sessions-search-scope.ts b/src/gateway/server-methods/sessions-search-scope.ts new file mode 100644 index 000000000000..18023661d0a2 --- /dev/null +++ b/src/gateway/server-methods/sessions-search-scope.ts @@ -0,0 +1,73 @@ +import { + ErrorCodes, + errorShape, + type SessionsSearchParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { isConfiguredSessionStoreAgentId } from "../../config/sessions.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { + resolveSessionStoreAgentId, + resolveSessionStoreKey, + resolveStoredSessionKeyForAgentStore, +} from "../session-store-key.js"; + +export function resolveSessionSearchScope(cfg: OpenClawConfig, params: SessionsSearchParams) { + const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; + const resolvedSessionKeys: + | Array<{ sessionKey: string; agentId: string | undefined }> + | undefined = params.sessionKeys ? [] : undefined; + for (const sessionKey of params.sessionKeys ?? []) { + const requestedAgent = + requestedAgentId && + !isConfiguredSessionStoreAgentId(cfg, requestedAgentId) && + resolvePersistedSessionStoreOwnerForKey(cfg, sessionKey).kind === "none" + ? ({ ok: true, agentId: requestedAgentId } as const) + : resolveRequestedSessionAgentId(cfg, sessionKey, requestedAgentId); + if (!requestedAgent.ok) { + return { ok: false as const, error: requestedAgent.error }; + } + resolvedSessionKeys?.push({ + sessionKey: requestedAgent.agentId + ? resolveStoredSessionKeyForAgentStore({ + cfg, + agentId: requestedAgent.agentId, + sessionKey, + }) + : resolveSessionStoreKey({ cfg, sessionKey }), + agentId: requestedAgent.agentId, + }); + } + const sessionKeys = resolvedSessionKeys?.map((resolved) => resolved.sessionKey); + const agentIds = new Set( + resolvedSessionKeys?.map((resolved) => + resolved.agentId ? resolved.agentId : resolveSessionStoreAgentId(cfg, resolved.sessionKey), + ), + ); + if ( + agentIds.size > 1 || + (requestedAgentId && [...agentIds].some((agentId) => agentId !== requestedAgentId)) + ) { + return { + ok: false as const, + error: errorShape(ErrorCodes.INVALID_REQUEST, "sessions.search supports one agent per call"), + }; + } + let agentId = requestedAgentId ?? agentIds.values().next().value; + if (!agentId) { + const fallbackAgent = resolveRequestedSessionAgentId(cfg, "main"); + if (!fallbackAgent.ok) { + return { ok: false as const, error: fallbackAgent.error }; + } + agentId = fallbackAgent.agentId; + } + return { + ok: true as const, + agentId, + configured: isConfiguredSessionStoreAgentId(cfg, agentId), + requestedAgentId, + sessionKeys, + }; +} diff --git a/src/gateway/server-methods/sessions-search.test.ts b/src/gateway/server-methods/sessions-search.test.ts index 5271acc066bd..339feee5e2d7 100644 --- a/src/gateway/server-methods/sessions-search.test.ts +++ b/src/gateway/server-methods/sessions-search.test.ts @@ -129,6 +129,53 @@ describe("sessions.search gateway method", () => { ); }); + it("rejects a bare fixed-store key scoped to a non-owner before transcript lookup", async () => { + cfg = { + session: { store: "/stores/shared/sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + const respond = await callSearch({ + agentId: "research", + query: "needle", + sessionKeys: ["global"], + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: 'agent "research" does not match session key agent "ops"', + }), + ); + expect(searchSessionTranscriptsMock).not.toHaveBeenCalled(); + }); + + it("retains the inferred fixed-store owner for a bare key search", async () => { + cfg = { + session: { store: "/stores/shared/sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + await callSearch({ query: "needle", sessionKeys: ["global"] }); + + expect(searchSessionTranscriptsMock).toHaveBeenCalledWith({ + agentId: "ops", + query: "needle", + limit: undefined, + sessionKeys: ["global"], + }); + }); + it("filters incognito candidates before applying a non-admin result limit", async () => { const incognitoKey = "agent:main:dashboard:incognito-newer"; const durableKey = "agent:main:dashboard:durable"; diff --git a/src/gateway/server-methods/sessions-sharing.test.ts b/src/gateway/server-methods/sessions-sharing.test.ts index edcc1e3a7b9f..883c692ea4eb 100644 --- a/src/gateway/server-methods/sessions-sharing.test.ts +++ b/src/gateway/server-methods/sessions-sharing.test.ts @@ -121,6 +121,42 @@ async function call( } describe("session sharing handlers", () => { + it("admits bare fixed-store keys only through their persisted owner", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async (state) => { + const storePath = state.path("shared-sessions.sqlite"); + await upsertSessionEntryCore( + { agentId: "ops", sessionKey: "global", storePath }, + { sessionId: "session-ops-global", updatedAt: 1, visibility: "shared" }, + ); + const ownedConfig = { + session: { scope: "global", store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as ReturnType; + + expect( + await call("session.members.list", { sessionKey: "global" }, context(vi.fn(), ownedConfig)), + ).toMatchObject([[true, { sessionKey: "global", role: "owner" }, undefined]]); + + const ownerlessConfig = { + ...ownedConfig, + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + } as ReturnType; + const rejected = await call( + "session.members.list", + { sessionKey: "global" }, + context(vi.fn(), ownerlessConfig), + ); + expect(rejected[0]?.[2]).toMatchObject({ + code: "INVALID_REQUEST", + message: expect.stringContaining("has no explicit owner"), + }); + }); + }); + it("keeps hidden incognito rows from changing non-owner list path metadata", async () => { await withOpenClawTestState({ scenario: "minimal" }, async (state) => { const incognitoKey = "agent:main:dashboard:incognito-private"; diff --git a/src/gateway/server-methods/sessions-sharing.ts b/src/gateway/server-methods/sessions-sharing.ts index 08272d4bec66..9fd7ecd4296a 100644 --- a/src/gateway/server-methods/sessions-sharing.ts +++ b/src/gateway/server-methods/sessions-sharing.ts @@ -19,6 +19,7 @@ import { import { patchSessionEntryCore } from "../../config/sessions/session-accessor.js"; import { runExclusiveSessionLifecycleMutation } from "../../sessions/session-lifecycle-admission.js"; import { listProfiles } from "../../state/user-profiles.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { allowedSessionVisibilities, canManageSessionSharing, @@ -63,10 +64,19 @@ function requireManageableTarget(params: { agentId?: string; respond: Parameters[0]["respond"]; }) { + const requestedAgent = resolveRequestedSessionAgentId( + params.cfg, + params.sessionKey, + params.agentId, + ); + if (!requestedAgent.ok) { + params.respond(false, undefined, requestedAgent.error); + return null; + } const target = resolveSessionSharingTarget({ cfg: params.cfg, sessionKey: params.sessionKey, - agentId: params.agentId, + agentId: requestedAgent.agentId, }); if (!target) { params.respond( diff --git a/src/gateway/server-methods/sessions-subscriptions.ts b/src/gateway/server-methods/sessions-subscriptions.ts index de80e0055527..0e83dcead184 100644 --- a/src/gateway/server-methods/sessions-subscriptions.ts +++ b/src/gateway/server-methods/sessions-subscriptions.ts @@ -6,10 +6,12 @@ import { validateSessionsMessagesUnsubscribeParams, validateSessionsViewerPresenceSetParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { canReviewOperatorApproval } from "../operator-approval-authorization.js"; import { APPROVALS_SCOPE } from "../operator-scopes.js"; -import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js"; +import { sessionObserverScopeKey } from "../session-observer-model.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveSessionStoreAgentId } from "../session-store-key.js"; import { resolveSessionSubscriptionKey } from "../session-subscription-keys.js"; import { resolveSessionStoreKey } from "../session-utils.js"; import { requireSessionKey } from "./sessions-shared.js"; @@ -57,7 +59,21 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { ); return; } - canonicalKeys.push(resolveSessionStoreKey({ cfg, sessionKey: trimmed })); + const requested = resolveRequestedSessionAgentId( + cfg, + trimmed, + parseAgentSessionKey(trimmed) ? undefined : params.agentId, + ); + if (!requested.ok) { + respond(false, undefined, requested.error); + return; + } + const canonicalKey = resolveSessionStoreKey({ + cfg, + sessionKey: trimmed, + storeAgentId: requested.agentId, + }); + canonicalKeys.push(sessionObserverScopeKey(canonicalKey, requested.agentId)); } const sessionKeys = declarations.replace(connId, canonicalKeys); respond(true, { sessionKeys }, undefined); @@ -91,7 +107,7 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { return; } const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + const requestedAgent = resolveRequestedSessionAgentId(cfg, key, p.agentId); if (!requestedAgent.ok) { respond(false, undefined, requestedAgent.error); return; @@ -104,7 +120,7 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { }); const subscriptionKey = resolveSessionSubscriptionKey( canonicalKey, - requestedAgentId ?? resolveDefaultAgentId(cfg), + requestedAgentId ?? resolveSessionStoreAgentId(cfg, canonicalKey), ); if (connId) { let approvalReplay; @@ -176,7 +192,7 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { return; } const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + const requestedAgent = resolveRequestedSessionAgentId(cfg, key, p.agentId); if (!requestedAgent.ok) { respond(false, undefined, requestedAgent.error); return; @@ -189,7 +205,7 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { }); const subscriptionKey = resolveSessionSubscriptionKey( canonicalKey, - requestedAgentId ?? resolveDefaultAgentId(cfg), + requestedAgentId ?? resolveSessionStoreAgentId(cfg, canonicalKey), ); if (connId) { context.unsubscribeSessionMessageEvents(connId, subscriptionKey); diff --git a/src/gateway/server-methods/sessions-suggestions-access.ts b/src/gateway/server-methods/sessions-suggestions-access.ts new file mode 100644 index 000000000000..90849bc4edef --- /dev/null +++ b/src/gateway/server-methods/sessions-suggestions-access.ts @@ -0,0 +1,83 @@ +import { + ErrorCodes, + errorShape, + type SessionSuggestionEvent, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { + authorizeIncognitoSessionTarget, + authorizeSessionSharingTarget, + resolveSessionSharingRole, + resolveSessionSharingTarget, + resolveSessionVisibility, +} from "../session-sharing.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; + +export function requireSuggestionTarget(params: { + context: GatewayRequestContext; + sessionKey: string; + agentId?: string; + respond: RespondFn; +}) { + const cfg = params.context.getRuntimeConfig(); + const requestedAgent = resolveRequestedSessionAgentId(cfg, params.sessionKey, params.agentId); + if (!requestedAgent.ok) { + params.respond(false, undefined, requestedAgent.error); + return null; + } + const target = resolveSessionSharingTarget({ + cfg, + sessionKey: params.sessionKey, + agentId: requestedAgent.agentId, + }); + if (!target) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown session: ${params.sessionKey}`), + ); + return null; + } + return target; +} + +export function requireVisibleSuggestionRole(params: { + client: GatewayClient | null; + sessionKey: string; + target: NonNullable>; + respond: RespondFn; +}) { + const role = resolveSessionSharingRole({ client: params.client, target: params.target }); + const incognitoError = authorizeIncognitoSessionTarget({ + client: params.client, + sessionKey: params.sessionKey, + target: params.target, + }); + if (incognitoError) { + params.respond(false, undefined, incognitoError); + return null; + } + if (resolveSessionVisibility(params.target.entry) !== "draft") { + return role; + } + const error = authorizeSessionSharingTarget({ client: params.client, target: params.target }); + if (!error) { + return role; + } + params.respond(false, undefined, error); + return null; +} + +export function publishSuggestion( + context: GatewayRequestContext, + target: NonNullable>, + requestedSessionKey: string, + event: SessionSuggestionEvent, +): void { + context.broadcast("session.suggestion", event, { + sessionKeys: [ + ...new Set([requestedSessionKey, target.canonicalKey, target.storeKey]), + ].toSorted(), + agentId: event.suggestion.agentId, + }); +} diff --git a/src/gateway/server-methods/sessions-suggestions.test-mocks.ts b/src/gateway/server-methods/sessions-suggestions.test-mocks.ts new file mode 100644 index 000000000000..0219921897bb --- /dev/null +++ b/src/gateway/server-methods/sessions-suggestions.test-mocks.ts @@ -0,0 +1,55 @@ +import { vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + appendSessionAudit: vi.fn(async () => undefined), + handleChatSend: vi.fn(), + suggestionMutationFailure: undefined as + | "claim" + | "release" + | "release-unexpected" + | "finalize" + | undefined, + presence: [] as Array<{ user?: { id: string; name?: string }; watchedSessions?: string[] }>, +})); + +vi.mock("./chat-send-handler.js", () => ({ handleChatSend: mocks.handleChatSend })); +vi.mock("./session-audit.js", () => ({ appendSessionAudit: mocks.appendSessionAudit })); +vi.mock("../../infra/system-presence.js", () => ({ + listSystemPresence: () => mocks.presence, +})); +vi.mock("../../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + const failIfRequested = (phase: "claim" | "release" | "finalize") => { + if (mocks.suggestionMutationFailure === phase) { + throw new actual.SessionWorkStartInvalidatedError("session changed in test"); + } + }; + return { + ...actual, + claimSessionSuggestionDispatch: ( + ...args: Parameters + ) => { + failIfRequested("claim"); + return actual.claimSessionSuggestionDispatch(...args); + }, + finalizeSessionSuggestionClaim: ( + ...args: Parameters + ) => { + failIfRequested("finalize"); + return actual.finalizeSessionSuggestionClaim(...args); + }, + releaseSessionSuggestionDispatch: ( + ...args: Parameters + ) => { + failIfRequested("release"); + if (mocks.suggestionMutationFailure === "release-unexpected") { + throw new Error("release storage failed"); + } + return actual.releaseSessionSuggestionDispatch(...args); + }, + }; +}); + +export function getSessionSuggestionTestMocks() { + return mocks; +} diff --git a/src/gateway/server-methods/sessions-suggestions.test-support.ts b/src/gateway/server-methods/sessions-suggestions.test-support.ts new file mode 100644 index 000000000000..699c02acc4df --- /dev/null +++ b/src/gateway/server-methods/sessions-suggestions.test-support.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, vi } from "vitest"; +import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; +import { sessionSuggestionHandlers } from "./sessions-suggestions.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; + +export const sessionKey = "agent:main:main"; + +const defaultSuggestionSession = { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", +} as const; + +export function upsertDefaultSuggestionSession() { + return upsertSessionEntryCore({ agentId: "main", sessionKey }, defaultSuggestionSession); +} + +export function client(profileId: string, displayName: string, admin = false): GatewayClient { + return { + connId: `conn-${profileId}`, + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "test", + mode: "webchat", + instanceId: `instance-${profileId}`, + }, + role: "operator", + scopes: admin ? ["operator.admin"] : ["operator.read", "operator.write"], + }, + authenticatedUserId: `${profileId}@example.com`, + authenticatedUserProfile: { profileId, displayName, hasAvatar: false, updatedAt: 1 }, + }; +} + +export function context( + broadcast = vi.fn(), + runtimeConfig: ReturnType = {}, +): GatewayRequestContext { + return { + getRuntimeConfig: () => runtimeConfig, + broadcast, + broadcastToConnIds: vi.fn(), + chatAbortControllers: new Map(), + logGateway: { warn: vi.fn() }, + } as unknown as GatewayRequestContext; +} + +export async function call( + method: + | "session.suggestions.add" + | "session.suggestions.list" + | "session.suggestions.resolve" + | "session.typing", + params: Record, + requestClient: GatewayClient | null, + requestContext = context(), +) { + const responses: Parameters[] = []; + await sessionSuggestionHandlers[method]?.({ + req: { type: "req", id: "request-1", method, params }, + params, + client: requestClient, + context: requestContext, + isWebchatConnect: () => true, + respond: (...response: Parameters) => responses.push(response), + }); + return { responses, context: requestContext }; +} + +export function responseSuggestionId(result: Awaited>): string { + const payload = result.responses[0]?.[1] as { suggestion?: { id?: string } } | undefined; + if (!payload?.suggestion?.id) { + throw new Error("suggestion response id missing"); + } + return payload.suggestion.id; +} + +export function registerSessionSuggestionTestLifecycle(mocks: { + appendSessionAudit: ReturnType; + handleChatSend: ReturnType; + suggestionMutationFailure?: string; + presence: unknown[]; +}): void { + beforeEach(() => { + mocks.appendSessionAudit.mockClear(); + mocks.handleChatSend.mockReset(); + mocks.handleChatSend.mockImplementation(({ respond }: { respond: RespondFn }) => { + respond(true, { runId: "suggestion-run", status: "started" }); + }); + mocks.suggestionMutationFailure = undefined; + mocks.presence = []; + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + closeOpenClawAgentDatabasesForTest(); + }); +} diff --git a/src/gateway/server-methods/sessions-suggestions.test.ts b/src/gateway/server-methods/sessions-suggestions.test.ts index e51a050b0853..10e4f2dd453e 100644 --- a/src/gateway/server-methods/sessions-suggestions.test.ts +++ b/src/gateway/server-methods/sessions-suggestions.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; import { clearActiveEmbeddedRun, @@ -11,160 +11,118 @@ import { listSessionSuggestions, SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, } from "../../config/sessions/session-suggestion-store.js"; -import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; -import { sessionSuggestionHandlers } from "./sessions-suggestions.js"; -import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; +import { getSessionSuggestionTestMocks } from "./sessions-suggestions.test-mocks.js"; +import { + call, + client, + context, + registerSessionSuggestionTestLifecycle, + responseSuggestionId, + sessionKey, + upsertDefaultSuggestionSession, +} from "./sessions-suggestions.test-support.js"; +import type { GatewayRequestContext, RespondFn } from "./types.js"; -const mocks = vi.hoisted(() => ({ - appendSessionAudit: vi.fn(async () => undefined), - handleChatSend: vi.fn(), - suggestionMutationFailure: undefined as - | "claim" - | "release" - | "release-unexpected" - | "finalize" - | undefined, - presence: [] as Array<{ - user?: { id: string; name?: string }; - watchedSessions?: string[]; - }>, -})); - -vi.mock("./chat-send-handler.js", () => ({ handleChatSend: mocks.handleChatSend })); -vi.mock("./session-audit.js", () => ({ appendSessionAudit: mocks.appendSessionAudit })); -vi.mock("../../infra/system-presence.js", () => ({ - listSystemPresence: () => mocks.presence, -})); -vi.mock("../../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); - const failIfRequested = (phase: "claim" | "release" | "finalize") => { - if (mocks.suggestionMutationFailure === phase) { - throw new actual.SessionWorkStartInvalidatedError("session changed in test"); - } - }; - return { - ...actual, - claimSessionSuggestionDispatch: ( - ...args: Parameters - ) => { - failIfRequested("claim"); - return actual.claimSessionSuggestionDispatch(...args); - }, - finalizeSessionSuggestionClaim: ( - ...args: Parameters - ) => { - failIfRequested("finalize"); - return actual.finalizeSessionSuggestionClaim(...args); - }, - releaseSessionSuggestionDispatch: ( - ...args: Parameters - ) => { - failIfRequested("release"); - if (mocks.suggestionMutationFailure === "release-unexpected") { - throw new Error("release storage failed"); - } - return actual.releaseSessionSuggestionDispatch(...args); - }, - }; -}); - -const sessionKey = "agent:main:main"; - -const defaultSuggestionSession = { - sessionId: "session-main", - updatedAt: 1, - createdActor: { type: "human", id: "owner" }, - visibility: "suggest", -} as const; - -function upsertDefaultSuggestionSession() { - return upsertSessionEntryCore({ agentId: "main", sessionKey }, defaultSuggestionSession); -} - -function client(profileId: string, displayName: string, admin = false): GatewayClient { - return { - connId: `conn-${profileId}`, - connect: { - minProtocol: 1, - maxProtocol: 1, - client: { - id: "openclaw-control-ui", - version: "test", - platform: "test", - mode: "webchat", - instanceId: `instance-${profileId}`, - }, - role: "operator", - scopes: admin ? ["operator.admin"] : ["operator.read", "operator.write"], - }, - authenticatedUserId: `${profileId}@example.com`, - authenticatedUserProfile: { - profileId, - displayName, - hasAvatar: false, - updatedAt: 1, - }, - }; -} - -function context(broadcast = vi.fn()): GatewayRequestContext { - return { - getRuntimeConfig: () => ({}), - broadcast, - broadcastToConnIds: vi.fn(), - chatAbortControllers: new Map(), - logGateway: { warn: vi.fn() }, - } as unknown as GatewayRequestContext; -} - -async function call( - method: - | "session.suggestions.add" - | "session.suggestions.list" - | "session.suggestions.resolve" - | "session.typing", - params: Record, - requestClient: GatewayClient | null, - requestContext = context(), -) { - const responses: Parameters[] = []; - await sessionSuggestionHandlers[method]?.({ - req: { type: "req", id: "request-1", method, params }, - params, - client: requestClient, - context: requestContext, - isWebchatConnect: () => true, - respond: (...response: Parameters) => responses.push(response), - }); - return { responses, context: requestContext }; -} - -function responseSuggestionId(result: Awaited>): string { - const payload = result.responses[0]?.[1] as { suggestion?: { id?: string } } | undefined; - if (!payload?.suggestion?.id) { - throw new Error("suggestion response id missing"); - } - return payload.suggestion.id; -} - -beforeEach(() => { - mocks.appendSessionAudit.mockClear(); - mocks.handleChatSend.mockReset(); - mocks.handleChatSend.mockImplementation(async ({ respond }: { respond: RespondFn }) => { - respond(true, { runId: "suggestion-run", status: "started" }); - }); - mocks.suggestionMutationFailure = undefined; - mocks.presence = []; -}); - -afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - closeOpenClawAgentDatabasesForTest(); -}); +const mocks = getSessionSuggestionTestMocks(); +registerSessionSuggestionTestLifecycle(mocks); describe("session suggestion handlers", () => { + it("admits bare fixed-store keys only through their persisted owner", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async (state) => { + const storePath = state.path("shared-sessions.sqlite"); + await upsertSessionEntryCore( + { agentId: "ops", sessionKey: "global", storePath }, + { + sessionId: "session-ops-global", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const ownedConfig = { + session: { scope: "global", store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as ReturnType; + + const admitted = await call( + "session.suggestions.list", + { sessionKey: "global" }, + client("owner", "Owner"), + context(vi.fn(), ownedConfig), + ); + expect(admitted.responses[0]).toMatchObject([true, { role: "owner", suggestions: [] }]); + + const ownerlessConfig = { + ...ownedConfig, + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + } as ReturnType; + const rejected = await call( + "session.suggestions.list", + { sessionKey: "global" }, + client("owner", "Owner"), + context(vi.fn(), ownerlessConfig), + ); + expect(rejected.responses[0]?.[2]).toMatchObject({ + code: "INVALID_REQUEST", + message: expect.stringContaining("has no explicit owner"), + }); + }); + }); + + it("attributes an ownerless active run to the persisted bare-key owner", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async (state) => { + const storePath = state.path("shared-sessions.sqlite"); + await upsertSessionEntryCore( + { agentId: "ops", sessionKey: "global", storePath }, + { + sessionId: "session-ops-global", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const ownedConfig = { + session: { scope: "global", store: storePath }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as ReturnType; + const requestContext = context(vi.fn(), ownedConfig); + requestContext.chatAbortControllers.set("run-ops", { + sessionKey: "global", + sessionId: "session-ops-global", + } as never); + const added = await call( + "session.suggestions.add", + { sessionKey: "global", text: "steer the owner" }, + client("alice", "Alice"), + requestContext, + ); + const id = responseSuggestionId(added); + + const resolved = await call( + "session.suggestions.resolve", + { sessionKey: "global", id, resolution: "send" }, + client("owner", "Owner"), + requestContext, + ); + + expect(resolved.responses[0]?.[0]).toBe(true); + expect(mocks.handleChatSend.mock.calls[0]?.[0]?.params).toMatchObject({ + agentId: "ops", + queueMode: "steer", + expectedRunId: "run-ops", + }); + }); + }); + it("lets a suggest viewer add and list only their own suggestion", async () => { await withOpenClawTestState({ scenario: "minimal" }, async () => { await upsertDefaultSuggestionSession(); diff --git a/src/gateway/server-methods/sessions-suggestions.ts b/src/gateway/server-methods/sessions-suggestions.ts index e150056d21fb..2903d1484a79 100644 --- a/src/gateway/server-methods/sessions-suggestions.ts +++ b/src/gateway/server-methods/sessions-suggestions.ts @@ -6,7 +6,6 @@ import { validateSessionSuggestionsResolveParams, validateSessionTypingParams, type SessionSuggestion, - type SessionSuggestionEvent, type SessionSuggestionResolution, type SessionSharingIdentity, type SessionTypingEvent, @@ -23,9 +22,10 @@ import { SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, type StoredSessionSuggestion, } from "../../config/sessions.js"; +import { sessionObserverScopeKey } from "../session-observer-model.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "../session-request-agent.js"; import { authorizeIncognitoSessionTarget, - authorizeSessionSharingTarget, canManageSessionSharing, resolveSessionSharingRole, resolveSessionSharingTarget, @@ -41,6 +41,11 @@ import { liveViewerIdentities, updateTypingConnections, } from "./session-typing-state.js"; +import { + publishSuggestion, + requireSuggestionTarget, + requireVisibleSuggestionRole, +} from "./sessions-suggestions-access.js"; import type { GatewayClient, GatewayRequestContext, @@ -72,69 +77,6 @@ function protocolSuggestion( }; } -function requireSuggestionTarget(params: { - context: GatewayRequestContext; - sessionKey: string; - agentId?: string; - respond: RespondFn; -}) { - const target = resolveSessionSharingTarget({ - cfg: params.context.getRuntimeConfig(), - sessionKey: params.sessionKey, - agentId: params.agentId, - }); - if (!target) { - params.respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown session: ${params.sessionKey}`), - ); - return null; - } - return target; -} - -function requireVisibleSuggestionRole(params: { - client: GatewayClient | null; - sessionKey: string; - target: NonNullable>; - respond: RespondFn; -}) { - const role = resolveSessionSharingRole({ client: params.client, target: params.target }); - const incognitoError = authorizeIncognitoSessionTarget({ - client: params.client, - sessionKey: params.sessionKey, - target: params.target, - }); - if (incognitoError) { - params.respond(false, undefined, incognitoError); - return null; - } - if (resolveSessionVisibility(params.target.entry) !== "draft") { - return role; - } - const error = authorizeSessionSharingTarget({ client: params.client, target: params.target }); - if (!error) { - return role; - } - params.respond(false, undefined, error); - return null; -} - -function publishSuggestion( - context: GatewayRequestContext, - target: NonNullable>, - requestedSessionKey: string, - event: SessionSuggestionEvent, -): void { - context.broadcast("session.suggestion", event, { - sessionKeys: [ - ...new Set([requestedSessionKey, target.canonicalKey, target.storeKey]), - ].toSorted(), - agentId: event.suggestion.agentId, - }); -} - function resolutionState(resolution: SessionSuggestionResolution): "accepted" | "dismissed" { return resolution === "dismiss" ? "dismissed" : "accepted"; } @@ -224,6 +166,10 @@ async function dispatchSuggestion(params: { suggestion: StoredSessionSuggestion; resolution: "send" | "queue"; }): Promise<{ ok: true } | { ok: false; error: Parameters[2] }> { + const compatibilityOwnerAgentId = tryResolveSessionCompatibilityOwnerAgentId( + params.context.getRuntimeConfig(), + params.target.storeKey, + ); const activeRunState = params.resolution === "send" ? resolveVisibleActiveSessionRunState({ @@ -232,6 +178,7 @@ async function dispatchSuggestion(params: { canonicalKey: params.target.storeKey, sessionId: params.target.entry.sessionId, agentId: params.target.agentId, + defaultAgentId: compatibilityOwnerAgentId, }) : undefined; if (activeRunState?.active && activeRunState.runIds.length !== 1) { @@ -566,7 +513,7 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = { const currentTarget = resolveSessionSharingTarget({ cfg: context.getRuntimeConfig(), sessionKey: params.sessionKey, - agentId: params.agentId, + agentId: target.agentId, }); if (!currentTarget || currentTarget.entry.sessionId !== target.entry.sessionId) { // Session replacement clears session_suggestions in the same entry-store @@ -655,7 +602,12 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = { respond(true, { ok: true, broadcast: false }); return; } - const sessionKeys = new Set([params.sessionKey, target.canonicalKey, target.storeKey]); + const sessionKeys = new Set([ + params.sessionKey, + target.canonicalKey, + target.storeKey, + sessionObserverScopeKey(target.canonicalKey, target.agentId), + ]); const now = Date.now(); const typingKey = `${actor.id}\0${target.agentId}\0${target.canonicalKey}\0${target.entry.sessionId}`; const effectiveTyping = updateTypingConnections({ @@ -676,7 +628,7 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = { const current = resolveSessionSharingTarget({ cfg: context.getRuntimeConfig(), sessionKey: params.sessionKey, - agentId: params.agentId, + agentId: target.agentId, }); if (!current || current.entry.sessionId !== target.entry.sessionId) { return false; diff --git a/src/gateway/server-methods/sessions-viewers.test.ts b/src/gateway/server-methods/sessions-viewers.test.ts index 10711f668856..96de6d1595bb 100644 --- a/src/gateway/server-methods/sessions-viewers.test.ts +++ b/src/gateway/server-methods/sessions-viewers.test.ts @@ -103,4 +103,35 @@ describe("sessions.viewers.set", () => { expect.objectContaining({ code: "UNAVAILABLE" }), ); }); + + it("scopes bare viewer identities by explicit owner and rejects ambiguity", async () => { + const replace = vi.fn((_connId: string, sessionKeys: readonly string[]) => sessionKeys); + const context = { + getRuntimeConfig: () => ({ + agents: { ownership: "explicit", list: [{ id: "main" }, { id: "work" }] }, + }), + sessionViewerPresence: { replace }, + } as unknown as GatewayRequestContext; + + const selected = await declare({ + body: { sessionKeys: ["global"], agentId: "work" }, + connId: "conn-viewer", + context, + }); + expect(replace).toHaveBeenCalledWith("conn-viewer", ["agent:work:global"]); + expect(selected).toHaveBeenCalledWith(true, { sessionKeys: ["agent:work:global"] }, undefined); + + replace.mockClear(); + const ambiguous = await declare({ + body: { sessionKeys: ["global"] }, + connId: "conn-viewer", + context, + }); + expect(replace).not.toHaveBeenCalled(); + expect(ambiguous).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + }); }); diff --git a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts index 2ead800f799c..dc76065cdff5 100644 --- a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts +++ b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts @@ -215,7 +215,7 @@ async function expectListedGlobalSessionActiveRun(params: { extra: { loadGatewayModelCatalog: vi.fn().mockResolvedValue([]) }, }); listSessionsFromStoreAsyncMock.mockResolvedValue({ - sessions: [{ key: "global", hasActiveRun: false }], + sessions: [{ key: "global", agentId: params.agentId, hasActiveRun: false }], }); const respond = await callSessions( "sessions.list", @@ -308,7 +308,11 @@ describe("sessions.abort agent scope", () => { await callSessions("sessions.abort", { runId: "run-beta" }, { context, reqId: "req-2" }); expect(resolveSessionKeyForRunMock).not.toHaveBeenCalled(); - expectChatAbortParams({ sessionKey: "agent:beta:dashboard:target", runId: "run-beta" }); + expectChatAbortParams({ + sessionKey: "agent:beta:dashboard:target", + runId: "run-beta", + agentId: "beta", + }); }); it("resolves runId-only worker aborts to the owning session", async () => { @@ -327,6 +331,7 @@ describe("sessions.abort agent scope", () => { expectChatAbortParams({ sessionKey: "agent:work:dashboard:worker", runId: "run-worker", + agentId: "work", }); expect(context.dedupe?.size).toBe(0); }); @@ -334,7 +339,7 @@ describe("sessions.abort agent scope", () => { it("aborts global-scope active runs for non-default agents", async () => { const activeRun = createActiveRun("global", { agentId: "work" }); const context = createGlobalWorkRunContext(activeRun); - resolveSessionKeyForRunMock.mockReturnValue(undefined); + resolveSessionKeyForRunMock.mockReturnValue("global"); await callSessions( "sessions.abort", @@ -342,7 +347,7 @@ describe("sessions.abort agent scope", () => { { context, reqId: "req-global" }, ); - expect(resolveSessionKeyForRunMock).not.toHaveBeenCalled(); + expect(resolveSessionKeyForRunMock).toHaveBeenCalledWith("run-global", { agentId: "work" }); expectChatAbortParams({ sessionKey: "global", runId: "run-global", agentId: "work" }); }); @@ -455,6 +460,7 @@ describe("sessions.abort agent scope", () => { }), new Set(["conn-1"]), { + agentId: "main", dropIfSlow: true, sessionKeys: ["agent:main:openclaw-weixin:direct:wechat-user"], }, @@ -801,7 +807,7 @@ describe("sessions.abort agent scope", () => { await callSessions("sessions.abort", { runId: "run-work" }, { context, reqId: "req-3" }); expect(resolveSessionKeyForRunMock).not.toHaveBeenCalled(); - expectChatAbortParams({ sessionKey: "main", runId: "run-work" }); + expectChatAbortParams({ sessionKey: "main", runId: "run-work", agentId: "work" }); }); it("rejects key-based aborts when key agent does not match agentId", async () => { @@ -828,10 +834,34 @@ describe("sessions.abort agent scope", () => { ] as const) { const respond = await callSessions(method, params, { context, reqId: `req-${method}` }); - expectRespondErrorMessage(respond, "session key agent does not match agentId"); + expectRespondErrorMessage(respond, 'agent "work" does not match session key agent "main"'); } }); + it("protects bare global when its fixed-store owner is inferred", async () => { + const context = createContext({ + extra: { + getRuntimeConfig: () => ({ + session: { scope: "global", store: "/stores/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }), + }, + }); + + const respond = await callSessions( + "sessions.delete", + { key: "global" }, + { context, reqId: "req-persisted-global-delete" }, + ); + + expectRespondErrorMessage(respond, "Cannot delete the main session (global)."); + expect(loadSessionEntryMock).not.toHaveBeenCalled(); + }); + it("rejects unknown explicit agentId before session mutations", async () => { const context = createContext({ agents: [{ id: "main", default: true }] }); const respond = await callSessions( @@ -869,7 +899,11 @@ describe("sessions.abort agent scope", () => { { context, reqId: "req-5" }, ); - expectChatAbortParams({ sessionKey: "agent:work:main", runId: undefined }); + expectChatAbortParams({ + sessionKey: "agent:work:main", + runId: undefined, + agentId: "work", + }); }); it("does not use a raw legacy key alias that belongs to another agent", async () => { @@ -882,7 +916,11 @@ describe("sessions.abort agent scope", () => { { context, reqId: "req-6" }, ); - expectChatAbortParams({ sessionKey: "agent:work:main", runId: undefined }); + expectChatAbortParams({ + sessionKey: "agent:work:main", + runId: undefined, + agentId: "work", + }); }); it("keeps the raw legacy key alias when it belongs to the requested agent", async () => { @@ -898,6 +936,6 @@ describe("sessions.abort agent scope", () => { { context, reqId: "req-7" }, ); - expectChatAbortParams({ sessionKey: "main", runId: undefined }); + expectChatAbortParams({ sessionKey: "main", runId: undefined, agentId: "work" }); }); }); diff --git a/src/gateway/server-methods/skills-workspace-handler.test.ts b/src/gateway/server-methods/skills-workspace-handler.test.ts new file mode 100644 index 000000000000..0ddffd6dbe43 --- /dev/null +++ b/src/gateway/server-methods/skills-workspace-handler.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveSkillsAgentWorkspace } from "./skills-workspace-handler.js"; +import type { GatewayRequestContext } from "./types.js"; + +function context(config: OpenClawConfig): GatewayRequestContext { + return { getRuntimeConfig: () => config } as GatewayRequestContext; +} + +describe("resolveSkillsAgentWorkspace", () => { + const config: OpenClawConfig = { + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }; + + it("returns typed selection-required when an explicit fleet omits agentId", () => { + const result = resolveSkillsAgentWorkspace({}, context(config)); + + expect(result).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("agent") }, + }); + }); + + it("uses the explicitly selected agent workspace", () => { + const result = resolveSkillsAgentWorkspace({ agentId: "research" }, context(config)); + + expect(result).toMatchObject({ ok: true, agentId: "research" }); + }); +}); diff --git a/src/gateway/server-methods/skills-workspace-handler.ts b/src/gateway/server-methods/skills-workspace-handler.ts index 8d4ae7e9d770..573017861fb4 100644 --- a/src/gateway/server-methods/skills-workspace-handler.ts +++ b/src/gateway/server-methods/skills-workspace-handler.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js"; import { listAgentIds, resolveAgentWorkspaceDir, @@ -16,7 +17,23 @@ export function resolveSkillsAgentWorkspace(params: unknown, context: GatewayReq params && typeof params === "object" && "agentId" in params ? normalizeOptionalString((params as { agentId?: unknown }).agentId) : undefined; - const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : resolveDefaultAgentId(cfg); + let agentId: string; + try { + agentId = agentIdRaw + ? normalizeAgentId(agentIdRaw) + : resolveDefaultAgentId(cfg, { + surface: "skills workspace", + hint: "Pass agentId to select a configured agent.", + }); + } catch (error) { + if (!(error instanceof AgentSelectionRequiredError)) { + throw error; + } + return { + ok: false as const, + error: errorShape(ErrorCodes.INVALID_REQUEST, error.message), + }; + } if (agentIdRaw && !listAgentIds(cfg).includes(agentId)) { return { ok: false as const, diff --git a/src/gateway/server-methods/subagent-followup.test-helpers.ts b/src/gateway/server-methods/subagent-followup.test-helpers.ts index 77f5e9c11940..18f939d75de8 100644 --- a/src/gateway/server-methods/subagent-followup.test-helpers.ts +++ b/src/gateway/server-methods/subagent-followup.test-helpers.ts @@ -38,7 +38,7 @@ export function expectSubagentFollowupReactivation(params: { endedAt?: number; }, Set, - { dropIfSlow?: boolean }, + { agentId?: string; dropIfSlow?: boolean }, ] >; }; @@ -51,5 +51,5 @@ export function expectSubagentFollowupReactivation(params: { expect(call?.[1]?.startedAt).toBe(123); expect(call?.[1]?.endedAt).toBeUndefined(); expect(call?.[2]).toEqual(new Set(["conn-1"])); - expect(call?.[3]).toEqual({ dropIfSlow: true }); + expect(call?.[3]).toEqual({ agentId: "main", dropIfSlow: true }); } diff --git a/src/gateway/server-methods/system-event-routing.test.ts b/src/gateway/server-methods/system-event-routing.test.ts index 95da3b1025da..6778f6d21b3f 100644 --- a/src/gateway/server-methods/system-event-routing.test.ts +++ b/src/gateway/server-methods/system-event-routing.test.ts @@ -74,6 +74,39 @@ describe("system-event routing", () => { expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); }); + it("routes a bare targeted wake through the persisted fixed-store owner", async () => { + const respond = vi.fn(); + mocks.loadGatewaySessionRow.mockReturnValue({ key: "global", archived: false }); + const request = { + params: { text: "Wake the retained session.", sessionKey: "global", wake: true }, + respond, + context: { + broadcast: vi.fn(), + incrementPresenceVersion: vi.fn(() => 1), + getHealthVersion: vi.fn(() => 1), + getRuntimeConfig: vi.fn(() => ({ + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + })), + }, + } as unknown as GatewayRequestHandlerOptions; + + await expectDefined( + systemHandlers["system-event"], + 'systemHandlers["system-event"] test invariant', + )(request); + + expect(mocks.loadGatewaySessionRow).toHaveBeenCalledWith("global", { agentId: "ops" }); + expect(mocks.requestHeartbeat).toHaveBeenCalledWith( + expect.objectContaining({ sessionKey: "global" }), + ); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); + it("rejects immediate wakes for unconfigured agents", async () => { const respond = vi.fn(); const request = { diff --git a/src/gateway/server-methods/system.ts b/src/gateway/server-methods/system.ts index 2bf005f7aa11..d6db6d50c0be 100644 --- a/src/gateway/server-methods/system.ts +++ b/src/gateway/server-methods/system.ts @@ -16,11 +16,12 @@ import { SYSTEM_PRESENCE_CLEAR_LAST_INPUT_TAG, validateSystemEventParams, } from "../../../packages/gateway-protocol/src/schema.js"; -import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentIds } from "../../agents/agent-scope.js"; import { readUtilityModelSetting, resolveUtilityModelRefForAgent, } from "../../agents/utility-model.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; import { resolveGatewayPort, resolveStateDir } from "../../config/paths.js"; import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js"; import { resolveAdvertisedLanHostCore } from "../../infra/advertised-lan-host.js"; @@ -34,11 +35,13 @@ import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js"; import { requestHeartbeat } from "../../infra/heartbeat-wake.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; import { resolveRuntimeOsLabel } from "../../infra/os-summary.js"; +import { withSystemEventOwner } from "../../infra/system-event-ownership.js"; import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.js"; import { listSystemPresence, updateSystemPresence } from "../../infra/system-presence.js"; import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { getGatewayProcessInstanceId } from "../process-instance.js"; import { broadcastPresenceSnapshot } from "../server/presence-events.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { loadGatewaySessionRow } from "../session-utils.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -62,17 +65,20 @@ async function collectSystemInfo(context: GatewayRequestContext): Promise { + const utilitySetting = readUtilityModelSetting(config, soleAgentId); + const utilityModel = resolveUtilityModelRefForAgent({ cfg: config, agentId: soleAgentId }); + return utilitySetting.kind === "disabled" + ? ({ status: "disabled" } as const) + : utilitySetting.kind === "explicit" + ? ({ status: "configured", model: utilitySetting.modelRef } as const) + : utilityModel + ? ({ status: "auto", model: utilityModel } as const) + : ({ status: "unavailable" } as const); + })() + : ({ status: "unavailable" } as const); return { machineName: await getMachineDisplayName(), @@ -157,6 +163,14 @@ export const systemHandlers: GatewayRequestHandlers = { return; } const requestedSessionKey = normalizeOptionalString(params.sessionKey); + const cfg = context.getRuntimeConfig(); + const requestedOwner = requestedSessionKey + ? resolveRequestedSessionAgentId(cfg, requestedSessionKey) + : undefined; + if (requestedOwner && !requestedOwner.ok) { + respond(false, undefined, requestedOwner.error); + return; + } const sessionKey = requestedSessionKey ?? resolveMainSessionKeyFromConfig(); const wake = params.wake === true; const isNodePresenceLine = text.startsWith("Node:"); @@ -169,8 +183,10 @@ export const systemHandlers: GatewayRequestHandlers = { return; } if (wake && requestedSessionKey) { - const targetAgentId = normalizeAgentId(resolveAgentIdFromSessionKey(requestedSessionKey)); - const configuredAgentIds = listAgentIds(context.getRuntimeConfig()).map(normalizeAgentId); + const targetAgentId = normalizeAgentId( + requestedOwner?.agentId ?? resolveAgentIdFromSessionKey(requestedSessionKey), + ); + const configuredAgentIds = listAgentIds(cfg).map(normalizeAgentId); if (!configuredAgentIds.includes(targetAgentId)) { respond( false, @@ -278,14 +294,24 @@ export const systemHandlers: GatewayRequestHandlers = { } const deltaText = parts.join(" · "); if (deltaText) { - enqueueSystemEvent(deltaText, { + const eventOptions = { sessionKey, contextKey: presenceUpdate.key, - }); + }; + enqueueSystemEvent( + deltaText, + requestedOwner + ? withSystemEventOwner(eventOptions, requestedOwner.agentId) + : eventOptions, + ); } } } else { - enqueueSystemEvent(text, { sessionKey }); + const eventOptions = { sessionKey }; + enqueueSystemEvent( + text, + requestedOwner ? withSystemEventOwner(eventOptions, requestedOwner.agentId) : eventOptions, + ); if (wake) { // Targeted admin events may need a proactive response. Carry the exact // session through the wake so its delivery context, not main, wins. diff --git a/src/gateway/server-methods/talk-client-run-ownership.ts b/src/gateway/server-methods/talk-client-run-ownership.ts new file mode 100644 index 000000000000..ceeb35536e9a --- /dev/null +++ b/src/gateway/server-methods/talk-client-run-ownership.ts @@ -0,0 +1,20 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { GatewayRequestHandlers } from "./types.js"; + +export function hasOwnedActiveTalkClientRun(params: { + context: Parameters[0]["context"]; + clientConnId?: string; + sessionKey: string; +}): boolean { + const connId = normalizeOptionalString(params.clientConnId); + const sessionKey = params.sessionKey.trim(); + if (!connId || !sessionKey) { + return false; + } + for (const entry of params.context.chatAbortControllers.values()) { + if (entry.sessionKey === sessionKey && entry.ownerConnId === connId && entry.kind !== "agent") { + return true; + } + } + return false; +} diff --git a/src/gateway/server-methods/talk-client.ts b/src/gateway/server-methods/talk-client.ts index b84d45eeafea..afc2d55e08ca 100644 --- a/src/gateway/server-methods/talk-client.ts +++ b/src/gateway/server-methods/talk-client.ts @@ -14,7 +14,7 @@ import { validateTalkClientToolCallParams, validateTalkClientTranscriptParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { AgentSelectionRequiredError, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { buildAgentMainSessionKey } from "../../routing/session-key.js"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL, @@ -64,6 +64,7 @@ import { flushTalkRealtimeRelayVoiceWrites, } from "../talk-realtime-relay.js"; import { formatForLog } from "../ws-log.js"; +import { hasOwnedActiveTalkClientRun } from "./talk-client-run-ownership.js"; import { buildRealtimeInstructions, buildRealtimeVoiceLaunchOptions, @@ -92,13 +93,6 @@ function pruneLegacyVoiceBindings(now = Date.now()): void { } } -function resolveTalkClientAgentId( - config: Parameters[0], - key: string, -) { - return resolveTalkSessionAgentId(config, key); -} - /** * Gateway methods for browser-owned realtime Talk sessions. * @@ -480,7 +474,16 @@ export const talkClientHandlers: GatewayRequestHandlers = { ), ); } catch (err) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); + respond( + false, + undefined, + errorShape( + err instanceof AgentSelectionRequiredError + ? ErrorCodes.INVALID_REQUEST + : ErrorCodes.UNAVAILABLE, + formatForLog(err), + ), + ); } }, "talk.client.toolCall": async (request) => { @@ -500,7 +503,7 @@ export const talkClientHandlers: GatewayRequestHandlers = { } const config = request.context.getRuntimeConfig(); - const agentId = resolveTalkClientAgentId(config, params.sessionKey); + const agentId = resolveTalkSessionAgentId(config, params.sessionKey); const relaySessionId = normalizeOptionalString(params.relaySessionId); const connId = normalizeOptionalString(request.client?.connId); pruneLegacyVoiceBindings(); @@ -626,7 +629,7 @@ export const talkClientHandlers: GatewayRequestHandlers = { try { const config = context.getRuntimeConfig(); await appendClientVoiceTranscript({ - agentId: resolveTalkClientAgentId(config, params.sessionKey), + agentId: resolveTalkSessionAgentId(config, params.sessionKey), sessionKey: params.sessionKey, voiceSessionId: params.voiceSessionId, entryId: params.entryId, @@ -656,7 +659,7 @@ export const talkClientHandlers: GatewayRequestHandlers = { return; } const config = context.getRuntimeConfig(); - const agentId = resolveTalkClientAgentId(config, params.sessionKey); + const agentId = resolveTalkSessionAgentId(config, params.sessionKey); const origin = resolveClientVoiceSessionOrigin({ agentId, sessionKey: params.sessionKey, @@ -712,27 +715,16 @@ export const talkClientHandlers: GatewayRequestHandlers = { }); respond(true, result, undefined); } catch (err) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); + respond( + false, + undefined, + errorShape( + err instanceof AgentSelectionRequiredError + ? ErrorCodes.INVALID_REQUEST + : ErrorCodes.UNAVAILABLE, + formatForLog(err), + ), + ); } }, }; - -function hasOwnedActiveTalkClientRun(params: { - context: Parameters[0]["context"]; - clientConnId?: string; - sessionKey: string; -}): boolean { - // Browser steering is only allowed for the connection that owns the live - // browser session; agent-owned consult runs use the relay steering path. - const connId = normalizeOptionalString(params.clientConnId); - const sessionKey = params.sessionKey.trim(); - if (!connId || !sessionKey) { - return false; - } - for (const entry of params.context.chatAbortControllers.values()) { - if (entry.sessionKey === sessionKey && entry.ownerConnId === connId && entry.kind !== "agent") { - return true; - } - } - return false; -} diff --git a/src/gateway/server-methods/talk-session.ts b/src/gateway/server-methods/talk-session.ts index cf2150f67a32..5f81da3e61cb 100644 --- a/src/gateway/server-methods/talk-session.ts +++ b/src/gateway/server-methods/talk-session.ts @@ -12,7 +12,8 @@ import { validateTalkSessionSteerParams, validateTalkSessionSubmitToolResultParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { buildAgentMainSessionKey } from "../../routing/session-key.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope.js"; +import { buildAgentMainSessionKey, parseAgentSessionKey } from "../../routing/session-key.js"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL } from "../../talk/agent-consult-tool.js"; import { REALTIME_VOICE_AGENT_CONTROL_TOOL } from "../../talk/agent-run-control-shared.js"; import { controlRealtimeVoiceAgentRun } from "../../talk/agent-run-control.js"; @@ -20,6 +21,7 @@ import { resolveTalkSessionAgentId } from "../../talk/agent-target.js"; import { ensureClientVoiceAgentSessionEntry } from "../../talk/client-voice-session.js"; import { resolveConfiguredRealtimeVoiceProvider } from "../../talk/provider-resolver.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; import { createTalkHandoff, getTalkHandoff, revokeTalkHandoff } from "../talk-handoff.js"; import { @@ -98,6 +100,10 @@ function respondInvalidRequest(respond: RespondFn, message: string) { function respondUnavailable(respond: RespondFn, err: unknown) { const message = formatForLog(err); + if (err instanceof AgentSelectionRequiredError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, message)); + return; + } respond( false, undefined, @@ -147,22 +153,32 @@ export const talkSessionHandlers: GatewayRequestHandlers = { return; } const spawnedBy = normalizeOptionalString(params.spawnedBy); - if ( - normalizeOptionalString(params.sessionKey) && - !spawnedBy && - !canCreateUnscopedManagedRoomSession(client) - ) { + const requestedSessionKey = normalizeOptionalString(params.sessionKey); + if (requestedSessionKey && !spawnedBy && !canCreateUnscopedManagedRoomSession(client)) { respondInvalidRequest( respond, `talk.session.create managed-room sessionKey requires spawnedBy or gateway scope: ${ADMIN_SCOPE}`, ); return; } + const runtimeConfig = context.getRuntimeConfig(); + const bareTalkAgentId = + requestedSessionKey && !parseAgentSessionKey(requestedSessionKey) + ? resolveTalkSessionAgentId(runtimeConfig, requestedSessionKey) + : undefined; + const requestedOwner = requestedSessionKey + ? resolveRequestedSessionAgentId(runtimeConfig, requestedSessionKey, bareTalkAgentId) + : undefined; + if (requestedOwner && !requestedOwner.ok) { + respond(false, undefined, requestedOwner.error); + return; + } const resolvedSession = await resolveSessionKeyFromResolveParams({ - cfg: context.getRuntimeConfig(), + cfg: runtimeConfig, client, p: { - key: params.sessionKey, + key: requestedSessionKey, + ...(requestedOwner?.agentId ? { agentId: requestedOwner.agentId } : {}), ...(spawnedBy ? { spawnedBy } : {}), includeGlobal: true, includeUnknown: true, @@ -227,7 +243,22 @@ export const talkSessionHandlers: GatewayRequestHandlers = { requested: params, defaults: realtimeConfig, }); - const agentId = resolveTalkSessionAgentId(runtimeConfig, params.sessionKey); + const requestedSessionKey = normalizeOptionalString(params.sessionKey); + const bareTalkAgentId = + requestedSessionKey && !parseAgentSessionKey(requestedSessionKey) + ? resolveTalkSessionAgentId(runtimeConfig, requestedSessionKey) + : undefined; + const requestedOwner = requestedSessionKey + ? resolveRequestedSessionAgentId(runtimeConfig, requestedSessionKey, bareTalkAgentId) + : undefined; + if (requestedOwner && !requestedOwner.ok) { + respond(false, undefined, requestedOwner.error); + return; + } + const agentId = + requestedOwner?.agentId ?? + bareTalkAgentId ?? + resolveTalkSessionAgentId(runtimeConfig, requestedSessionKey); const resolution = resolveConfiguredRealtimeVoiceProvider({ configuredProviderId: realtimeConfig.provider, providerConfigs: realtimeConfig.providers, diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 0f689fa2f99e..576745a6b38c 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -1841,6 +1841,68 @@ describe("talk.session unified handlers", () => { expect(closeRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); }); + it("uses talk.agentId for a bare realtime session in an explicit fleet", async () => { + const provider = { + id: "openai", + label: "OpenAI Realtime", + isConfigured: () => true, + createBridge: vi.fn(), + }; + mocks.resolveConfiguredRealtimeVoiceProvider.mockReturnValue({ + provider, + providerConfig: {}, + }); + mocks.createTalkRealtimeRelaySession.mockReturnValue({ + provider: "openai", + transport: "gateway-relay", + relaySessionId: "relay-talk-owner", + audio: { + inputEncoding: "pcm16", + inputSampleRateHz: 24000, + outputEncoding: "pcm16", + outputSampleRateHz: 24000, + }, + model: "gpt-realtime", + voice: "alloy", + expiresAt: 1_797_986_400, + }); + const config: OpenClawConfig = { + agents: { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }, + talk: { + agentId: "research", + realtime: { + provider: "openai", + providers: { openai: {} }, + }, + }, + }; + const respond = vi.fn(); + + await callTalkHandler("talk.session.create", { + params: { + mode: "realtime", + transport: "gateway-relay", + brain: "agent-consult", + provider: "openai", + sessionKey: "incident-42", + }, + respond, + context: { getRuntimeConfig: () => config, logGateway: { warn: vi.fn() } }, + }); + + expect(mocks.resolveConfiguredRealtimeVoiceProvider).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "research" }), + ); + expect(mocks.ensureClientVoiceAgentSessionEntry).toHaveBeenCalledWith({ + agentId: "research", + sessionKey: "incident-42", + }); + expectRespondOk(respond, { relaySessionId: "relay-talk-owner" }); + }); + it.each([ { label: "request override from a configured GA model", @@ -2206,6 +2268,7 @@ describe("talk.session unified handlers", () => { client: { connId: "conn-1", connect: { scopes: ["operator.write"] } }, p: { key: "agent:worker:subagent:child", + agentId: "worker", spawnedBy: "agent:main:parent", includeGlobal: true, includeUnknown: true, @@ -2213,6 +2276,33 @@ describe("talk.session unified handlers", () => { }); }); + it("resolves a bare managed-room session through the persisted fixed-store owner", async () => { + const createRespond = vi.fn(); + const config: OpenClawConfig = { + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + }; + await callTalkHandler("talk.session.create", { + params: { + mode: "stt-tts", + transport: "managed-room", + sessionKey: "global", + }, + client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + respond: createRespond, + context: { getRuntimeConfig: () => config }, + }); + + expectRespondOk(createRespond, { transport: "managed-room" }); + expect(mocks.resolveSessionKeyFromResolveParams).toHaveBeenCalledWith( + expect.objectContaining({ p: expect.objectContaining({ key: "global", agentId: "ops" }) }), + ); + }); + it("rejects unscoped managed-room session keys without admin scope", async () => { const createRespond = vi.fn(); await callTalkHandler("talk.session.create", { diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 7bd8ac06fb83..0652f13f0ec6 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -15,6 +15,7 @@ import { validateTalkModeParams, validateTalkSpeakParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { AgentSelectionRequiredError } from "../../agents/agent-scope.js"; import { readConfigFileSnapshot } from "../../config/config.js"; import { redactConfigObject } from "../../config/redact-snapshot.js"; import { @@ -737,7 +738,16 @@ export const talkHandlers: GatewayRequestHandlers = { try { respond(true, buildTalkCatalog(context.getRuntimeConfig()), undefined); } catch (err) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); + respond( + false, + undefined, + errorShape( + err instanceof AgentSelectionRequiredError + ? ErrorCodes.INVALID_REQUEST + : ErrorCodes.UNAVAILABLE, + formatForLog(err), + ), + ); } }, "talk.config": async ({ params, respond, client, context }) => { diff --git a/src/gateway/server-methods/task-suggestions.test-support.ts b/src/gateway/server-methods/task-suggestions.test-support.ts new file mode 100644 index 000000000000..0fe0108d176a --- /dev/null +++ b/src/gateway/server-methods/task-suggestions.test-support.ts @@ -0,0 +1,106 @@ +import { expect, vi } from "vitest"; +import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js"; +import { taskSuggestionsHandlers } from "./task-suggestions.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; + +type Method = + | "taskSuggestions.list" + | "taskSuggestions.create" + | "taskSuggestions.accept" + | "taskSuggestions.dismiss"; + +export const GIT_CWD = process.cwd(); +export const SOURCE_SESSION_KEY = "agent:main:source"; + +export async function call( + method: Method, + params: Record, + broadcast = vi.fn(), + overrides: Record & { + client?: GatewayClient | null; + context?: Partial; + config?: Record; + } = {}, +) { + const calls: Parameters[] = []; + const config = + overrides.config ?? + (overrides.client !== undefined || overrides.context !== undefined ? {} : overrides); + await taskSuggestionsHandlers[method]?.({ + req: { type: "req", id: "request-1", method, params }, + params, + respond: (...args: Parameters) => calls.push(args), + client: overrides.client ?? null, + isWebchatConnect: () => true, + context: { broadcast, getRuntimeConfig: () => config, ...overrides.context }, + } as never); + return { response: calls[0], broadcast }; +} + +export function requirePayload(result: Awaited>): unknown { + expect(result.response?.[0]).toBe(true); + if (!result.response?.[0]) { + throw new Error("expected a successful gateway response"); + } + return result.response[1]; +} + +export async function dismissPendingTaskSuggestions(): Promise { + const listed = await call("taskSuggestions.list", {}); + const payload = requirePayload(listed) as { suggestions: Array<{ id: string }> }; + for (const suggestion of payload.suggestions) { + await call("taskSuggestions.dismiss", { taskId: suggestion.id }); + } +} + +export function operatorClient(): GatewayClient { + return { + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "test", + mode: "webchat", + }, + role: "operator", + scopes: ["operator.admin"], + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + }, + }; +} + +export function configuredCloudContext( + profiles: Record = { primary: { provider: "test" } }, +): Partial { + return { + workerEnvironmentService: {} as never, + workerPlacementDispatchService: {} as never, + getRuntimeConfig: () => ({ cloudWorkers: { profiles } }), + }; +} + +export async function createSourceSuggestion() { + const created = await call("taskSuggestions.create", { + title: "Fix the source session", + prompt: "Apply the focused fix in this session.", + tldr: "The current session already owns the relevant context.", + cwd: GIT_CWD, + sessionKey: SOURCE_SESSION_KEY, + agentId: "main", + }); + return (requirePayload(created) as { taskId: string }).taskId; +} + +export async function createLocalTaskSuggestion() { + const created = await call("taskSuggestions.create", { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is untested.", + cwd: GIT_CWD, + sessionKey: "agent:main:main", + agentId: "main", + }); + return (requirePayload(created) as { taskId: string }).taskId; +} diff --git a/src/gateway/server-methods/task-suggestions.test.ts b/src/gateway/server-methods/task-suggestions.test.ts index 00e7c8737ec5..7ebb7af6a249 100644 --- a/src/gateway/server-methods/task-suggestions.test.ts +++ b/src/gateway/server-methods/task-suggestions.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js"; import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-run-registry.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; @@ -12,8 +11,18 @@ import { import { sessionCreateHandlers } from "./sessions-create.js"; import { sessionDeleteHandlers } from "./sessions-delete.js"; import { sessionDispatchHandlers } from "./sessions-dispatch.js"; -import { taskSuggestionsHandlers } from "./task-suggestions.js"; -import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; +import { + call, + configuredCloudContext, + createLocalTaskSuggestion, + createSourceSuggestion, + dismissPendingTaskSuggestions, + GIT_CWD, + operatorClient, + requirePayload, + SOURCE_SESSION_KEY, +} from "./task-suggestions.test-support.js"; +import type { RespondFn } from "./types.js"; const mocks = vi.hoisted(() => ({ handleChatSend: vi.fn() })); const sessionReadState = vi.hoisted(() => ({ mode: "normal" as "normal" | "present" | "throw" })); @@ -37,55 +46,6 @@ vi.mock("../session-utils.js", async (importOriginal) => { }; }); -type Method = - | "taskSuggestions.list" - | "taskSuggestions.create" - | "taskSuggestions.accept" - | "taskSuggestions.dismiss"; - -const GIT_CWD = process.cwd(); -const SOURCE_SESSION_KEY = "agent:main:source"; - -async function call( - method: Method, - params: Record, - broadcast = vi.fn(), - overrides: { - client?: GatewayClient | null; - context?: Partial; - } = {}, -) { - const calls: Parameters[] = []; - const respond: RespondFn = (...args) => { - calls.push(args); - }; - await taskSuggestionsHandlers[method]?.({ - req: { type: "req", id: "request-1", method, params }, - params, - respond, - client: overrides.client ?? null, - isWebchatConnect: () => true, - context: { broadcast, getRuntimeConfig: () => ({}), ...overrides.context }, - } as never); - return { response: calls[0], broadcast }; -} - -function requirePayload(result: Awaited>): unknown { - expect(result.response?.[0]).toBe(true); - if (!result.response?.[0]) { - throw new Error("expected a successful gateway response"); - } - return result.response[1]; -} - -async function dismissPendingTaskSuggestions(): Promise { - const listed = await call("taskSuggestions.list", {}); - const payload = requirePayload(listed) as { suggestions: Array<{ id: string }> }; - for (const suggestion of payload.suggestions) { - await call("taskSuggestions.dismiss", { taskId: suggestion.id }); - } -} - beforeEach(async () => { sessionReadState.mode = "normal"; await dismissPendingTaskSuggestions(); @@ -100,58 +60,6 @@ afterEach(async () => { closeOpenClawAgentDatabasesForTest(); }); -function operatorClient(): GatewayClient { - return { - connect: { - minProtocol: 1, - maxProtocol: 1, - client: { - id: "openclaw-control-ui", - version: "test", - platform: "test", - mode: "webchat", - }, - role: "operator", - scopes: ["operator.admin"], - caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], - }, - }; -} - -function configuredCloudContext( - profiles: Record = { primary: { provider: "test" } }, -): Partial { - return { - workerEnvironmentService: {} as never, - workerPlacementDispatchService: {} as never, - getRuntimeConfig: () => ({ cloudWorkers: { profiles } }), - }; -} - -async function createSourceSuggestion() { - const created = await call("taskSuggestions.create", { - title: "Fix the source session", - prompt: "Apply the focused fix in this session.", - tldr: "The current session already owns the relevant context.", - cwd: GIT_CWD, - sessionKey: SOURCE_SESSION_KEY, - agentId: "main", - }); - return (requirePayload(created) as { taskId: string }).taskId; -} - -async function createLocalTaskSuggestion() { - const created = await call("taskSuggestions.create", { - title: "Add coverage", - prompt: "Add the missing regression test.", - tldr: "The edge case is untested.", - cwd: GIT_CWD, - sessionKey: "agent:main:main", - agentId: "main", - }); - return (requirePayload(created) as { taskId: string }).taskId; -} - describe("task suggestion gateway methods", () => { it("creates, lists, and resolves an ephemeral suggestion", async () => { const created = await call("taskSuggestions.create", { @@ -207,6 +115,36 @@ describe("task suggestion gateway methods", () => { expect(empty.response?.[1]).toEqual({ suggestions: [] }); }); + it("attributes a bare source session to the persisted fixed-store owner", async () => { + const config = { + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + }; + const created = await call( + "taskSuggestions.create", + { + title: "Inspect the deployment", + prompt: "Check the deployment logs.", + tldr: "Deployment needs inspection.", + cwd: GIT_CWD, + sessionKey: "global", + }, + vi.fn(), + config, + ); + + expect(created.response?.[0]).toBe(true); + expect(created.response?.[1]).toMatchObject({ suggestion: { agentId: "ops" } }); + const listed = await call("taskSuggestions.list", { sessionKey: "global" }, vi.fn(), config); + expect(listed.response?.[1]).toMatchObject({ + suggestions: [expect.objectContaining({ agentId: "ops", sessionKey: "global" })], + }); + }); + it("evicts accepted-session replay before an unseen pending suggestion", async () => { const created = await call("taskSuggestions.create", { title: "Remove stale adapter", @@ -895,19 +833,24 @@ describe("task suggestion gateway methods", () => { ); it("rejects an agent that conflicts with the source session", async () => { - const result = await call("taskSuggestions.create", { - title: "Add coverage", - prompt: "Add the missing regression test.", - tldr: "The edge case is untested.", - cwd: GIT_CWD, - sessionKey: "agent:main:main", - agentId: "work", - }); + const result = await call( + "taskSuggestions.create", + { + title: "Add coverage", + prompt: "Add the missing regression test.", + tldr: "The edge case is untested.", + cwd: GIT_CWD, + sessionKey: "agent:main:main", + agentId: "work", + }, + vi.fn(), + { agents: { list: [{ id: "main" }, { id: "work" }] } }, + ); expect(result.response?.[0]).toBe(false); expect(result.response?.[2]).toMatchObject({ code: "INVALID_REQUEST", - message: "task suggestion agentId must match its source session", + message: 'agent "work" does not match session key agent "main"', }); expect(result.broadcast).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-methods/task-suggestions.ts b/src/gateway/server-methods/task-suggestions.ts index 4b86282fa490..b2682e4ba01c 100644 --- a/src/gateway/server-methods/task-suggestions.ts +++ b/src/gateway/server-methods/task-suggestions.ts @@ -12,12 +12,12 @@ import { validateTaskSuggestionsDismissParams, validateTaskSuggestionsListParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { insideGitCheckout } from "../../agents/worktrees/git.js"; import { resolveSessionWorkStartError } from "../../config/sessions.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { buildDashboardSessionKey } from "../session-create-service.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import { abandonTaskSuggestionAcceptance, @@ -178,14 +178,14 @@ function failSuggestedTaskDelivery(params: { return { ok: false, error: params.error }; } -function resolveSuggestionAgentId( +function resolveSuggestionOwner( suggestion: TaskSuggestion, options: GatewayRequestHandlerOptions, -): string { - return normalizeAgentId( - suggestion.agentId ?? - parseAgentSessionKey(suggestion.sessionKey)?.agentId ?? - resolveDefaultAgentId(options.context.getRuntimeConfig()), +): ReturnType { + return resolveRequestedSessionAgentId( + options.context.getRuntimeConfig(), + suggestion.sessionKey, + suggestion.agentId, ); } @@ -228,7 +228,11 @@ async function createSuggestedTaskSession(params: { cloudProfileId?: string; }): Promise { let sessionResponse: Parameters | undefined; - const agentId = resolveSuggestionAgentId(params.suggestion, params.options); + const sourceOwner = resolveSuggestionOwner(params.suggestion, params.options); + if (!sourceOwner.ok) { + return { ok: false, error: sourceOwner.error }; + } + const agentId = normalizeAgentId(sourceOwner.agentId); const sessionKey = buildDashboardSessionKey(agentId); const fail = (key: string, error: NonNullable[2]>) => failSuggestedTaskSession({ @@ -355,7 +359,11 @@ async function deliverSuggestedTaskToSourceSession(params: { suggestion: TaskSuggestion; options: GatewayRequestHandlerOptions; }): Promise { - const agentId = resolveSuggestionAgentId(params.suggestion, params.options); + const sourceOwner = resolveSuggestionOwner(params.suggestion, params.options); + if (!sourceOwner.ok) { + return { ok: false, error: sourceOwner.error }; + } + const agentId = normalizeAgentId(sourceOwner.agentId); const fail = (error: NonNullable[2]>) => failSuggestedTaskDelivery({ taskId: params.taskId, options: params.options, error }); let source: ReturnType; @@ -434,7 +442,7 @@ async function deliverSuggestedTaskToSourceSession(params: { } export const taskSuggestionsHandlers: GatewayRequestHandlers = { - "taskSuggestions.list": ({ params, respond }) => { + "taskSuggestions.list": ({ params, respond, context }) => { if (!validateTaskSuggestionsListParams(params)) { respond( false, @@ -443,7 +451,28 @@ export const taskSuggestionsHandlers: GatewayRequestHandlers = { ); return; } - respond(true, { suggestions: listTaskSuggestions(params) }, undefined); + const requestedSessionKey = params.sessionKey; + const sessionOwner = requestedSessionKey + ? resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + requestedSessionKey, + params.agentId, + ) + : undefined; + if (sessionOwner && !sessionOwner.ok) { + respond(false, undefined, sessionOwner.error); + return; + } + respond( + true, + { + suggestions: listTaskSuggestions({ + ...params, + ...(sessionOwner ? { agentId: sessionOwner.agentId } : {}), + }), + }, + undefined, + ); }, "taskSuggestions.create": ({ params, respond, context }) => { if (!validateTaskSuggestionsCreateParams(params)) { @@ -470,26 +499,17 @@ export const taskSuggestionsHandlers: GatewayRequestHandlers = { ); return; } - const sessionAgentId = parseAgentSessionKey(params.sessionKey)?.agentId; const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; - if ( - requestedAgentId && - sessionAgentId && - requestedAgentId !== normalizeAgentId(sessionAgentId) - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "task suggestion agentId must match its source session", - ), - ); + const sourceOwner = resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + params.sessionKey, + requestedAgentId, + ); + if (!sourceOwner.ok) { + respond(false, undefined, sourceOwner.error); return; } - const agentId = normalizeAgentId( - requestedAgentId ?? sessionAgentId ?? resolveDefaultAgentId(context.getRuntimeConfig()), - ); + const agentId = normalizeAgentId(sourceOwner.agentId); const created = createTaskSuggestion({ ...params, agentId }); if (created.status === "full") { respond( diff --git a/src/gateway/server-methods/tasks.test.ts b/src/gateway/server-methods/tasks.test.ts index 31b6d35b4105..81c919201818 100644 --- a/src/gateway/server-methods/tasks.test.ts +++ b/src/gateway/server-methods/tasks.test.ts @@ -10,6 +10,7 @@ import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END, } from "../../agents/internal-runtime-context.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { emitAgentEvent } from "../../infra/agent-events.js"; import { createTaskRecord as createTaskRecordOrNull, @@ -82,9 +83,9 @@ function captureRespond() { return { calls, respond }; } -function createContext() { +function createContext(config: Record = {}) { return { - getRuntimeConfig: () => ({}), + getRuntimeConfig: () => config, } as never; } @@ -110,6 +111,7 @@ function createSnapshotTask(overrides: Partial): TaskRecord { async function runTaskHandler( method: "tasks.list" | "tasks.get" | "tasks.cancel" | "tasks.retry" | "tasks.dismiss", params: Record, + config: Record = {}, ) { const { calls, respond } = captureRespond(); await expectDefined( @@ -119,7 +121,7 @@ async function runTaskHandler( req: { type: "req", id: `req-${method}`, method }, params, respond, - context: createContext(), + context: createContext(config), client: null, isWebchatConnect: () => false, }); @@ -190,6 +192,77 @@ describe("tasks gateway handlers", () => { expect(canonical.payload?.tasks?.map((task) => task.taskId)).toEqual([running.taskId]); }); + it("uses the persisted fixed-store owner for a bare task session filter", async () => { + const task = createTaskRecord({ + runtime: "cli", + requesterSessionKey: "global", + ownerKey: "global", + scopeKind: "session", + runId: "run-global", + task: "Owned task", + status: "running", + deliveryStatus: "pending", + }); + const { calls, payload } = await runTaskHandler( + "tasks.list", + { sessionKey: "global" }, + { + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + }, + ); + + expect(calls[0]?.[0]).toBe(true); + expect(payload?.tasks?.map((entry) => entry.taskId)).toEqual([task.taskId]); + }); + + it("does not use the executor as the requester owner for a legacy bare task", () => { + const task = createTaskRecord({ + runtime: "subagent", + requesterSessionKey: "global", + ownerKey: "global", + scopeKind: "session", + childSessionKey: "agent:research:subagent:child", + agentId: "research", + runId: "run-legacy-owner", + task: "Owned by ops, executed by research", + status: "running", + deliveryStatus: "pending", + }); + expect(task.requesterAgentId).toBeUndefined(); + const cfg = { + session: { scope: "global", store: "/tmp/shared-sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + + expect( + listTaskRecordPage({ + offset: 0, + limit: 10, + sessionKey: "global", + sessionAgentId: "ops", + cfg, + }).tasks.map((entry) => entry.taskId), + ).toEqual([task.taskId]); + expect( + listTaskRecordPage({ + offset: 0, + limit: 10, + sessionKey: "global", + sessionAgentId: "research", + cfg, + }).tasks, + ).toEqual([]); + }); + it("orders the ledger by last activity, not creation time", async () => { // The registry lists newest-created first; the wire must page by last // activity so an old task that just finished is not hidden behind diff --git a/src/gateway/server-methods/tasks.ts b/src/gateway/server-methods/tasks.ts index d1385fa90758..f436f70b5b3f 100644 --- a/src/gateway/server-methods/tasks.ts +++ b/src/gateway/server-methods/tasks.ts @@ -11,15 +11,14 @@ import { validateTasksListParams, validateTasksRecoveryParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { dismissSubagentCompletionDelivery, retrySubagentCompletionDelivery, } from "../../agents/subagents/completion/subagent-completion-delivery.js"; import { canonicalizeMainSessionAlias } from "../../config/sessions.js"; -import { parseAgentSessionKey } from "../../routing/session-key.js"; import { getTaskById, listTaskRecordPage } from "../../tasks/runtime-internal.js"; import type { TaskStatus } from "../../tasks/task-registry.types.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { mapTaskSummary } from "./task-summary.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -78,15 +77,23 @@ export const tasksHandlers: GatewayRequestHandlers = { const statusFilter = normalizeTaskStatusFilter(params.status); const limit = Math.min(params.limit ?? DEFAULT_TASKS_LIST_LIMIT, MAX_TASKS_LIST_LIMIT); const requestedSessionKey = normalizeOptionalString(params.sessionKey); + const cfg = context.getRuntimeConfig(); let sessionKey: string | undefined; + let sessionAgentId: string | undefined; if (requestedSessionKey) { - const cfg = context.getRuntimeConfig(); + const sessionOwner = resolveRequestedSessionAgentId( + cfg, + requestedSessionKey, + normalizeOptionalString(params.agentId), + ); + if (!sessionOwner.ok) { + respond(false, undefined, sessionOwner.error); + return; + } + sessionAgentId = sessionOwner.agentId; sessionKey = canonicalizeMainSessionAlias({ cfg, - agentId: - parseAgentSessionKey(requestedSessionKey)?.agentId ?? - normalizeOptionalString(params.agentId) ?? - resolveDefaultAgentId(cfg), + agentId: sessionOwner.agentId, sessionKey: requestedSessionKey, }); } @@ -97,8 +104,10 @@ export const tasksHandlers: GatewayRequestHandlers = { offset: cursor, limit, statuses: statusFilter ? [...statusFilter] : undefined, - agentId: params.agentId, + agentId: sessionKey ? undefined : params.agentId, sessionKey, + sessionAgentId, + cfg, }); const nextOffset = cursor + page.tasks.length; respond(true, { diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 64ef4d6fa024..ca0d1ed4531e 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -12,7 +12,8 @@ import { } from "../../plugins/tools.js"; import { toolsCatalogHandlers } from "./tools-catalog.js"; -vi.mock("../../agents/agent-scope.js", () => ({ +vi.mock("../../agents/agent-scope.js", async (importOriginal) => ({ + ...(await importOriginal()), listAgentIds: vi.fn(() => ["main"]), resolveDefaultAgentId: vi.fn(() => "main"), resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"), diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index ce241ee96eaa..d79111498023 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -4,11 +4,7 @@ import { type ToolsCatalogResult, validateToolsCatalogParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { - resolveAgentDir, - resolveAgentWorkspaceDir, - resolveDefaultAgentId, -} from "../../agents/agent-scope.js"; +import { resolveAgentDir, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { resolveSwarmConfig } from "../../agents/subagents/swarm/swarm-config.js"; import { listCoreToolSections, @@ -196,10 +192,10 @@ function buildPluginGroups(params: { /** Build the merged core/plugin tool catalog for one agent. */ function buildToolsCatalogResult(params: { cfg: OpenClawConfig; - agentId?: string; + agentId: string; includePlugins?: boolean; }): ToolsCatalogResult { - const agentId = normalizeOptionalString(params.agentId) || resolveDefaultAgentId(params.cfg); + const agentId = params.agentId; const includePlugins = params.includePlugins !== false; const groups = buildCoreGroups({ cfg: params.cfg, agentId }); if (includePlugins) { diff --git a/src/gateway/server-methods/tools-effective.global-agent.integration.test.ts b/src/gateway/server-methods/tools-effective.global-agent.integration.test.ts index 1a641eb3925e..55ffaee5c02b 100644 --- a/src/gateway/server-methods/tools-effective.global-agent.integration.test.ts +++ b/src/gateway/server-methods/tools-effective.global-agent.integration.test.ts @@ -183,7 +183,7 @@ describe("tools.effective global agent integration", () => { | [boolean, unknown?, { code: number; message: string }?] | undefined; expect(call?.[0]).toBe(false); - expect(call?.[2]?.message).toBe('agent id "work" does not match session agent "main"'); + expect(call?.[2]?.message).toBe('agent "work" does not match session key agent "main"'); expect(inventoryMocks.resolveEffectiveToolInventory).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-methods/tools-effective.test.ts b/src/gateway/server-methods/tools-effective.test.ts index 532824ef8795..a5c967b66ccd 100644 --- a/src/gateway/server-methods/tools-effective.test.ts +++ b/src/gateway/server-methods/tools-effective.test.ts @@ -108,7 +108,7 @@ type ToolsEffectivePayload = { }>; }; -function createInvokeParams(params: Record) { +function createInvokeParams(params: Record, cfg: Record = {}) { const respond = vi.fn(); return { respond, @@ -119,7 +119,7 @@ function createInvokeParams(params: Record) { )({ params, respond: respond as never, - context: { getRuntimeConfig: () => ({}) } as never, + context: { getRuntimeConfig: () => cfg } as never, client: null, req: { type: "req", id: "req-1", method: "tools.effective" }, isWebchatConnect: () => false, @@ -845,10 +845,13 @@ describe("tools.effective handler", () => { runtimeMocks.resolveAgentWorkspaceDir.mockReturnValueOnce("/tmp/workspace-work"); runtimeMocks.resolveEffectiveToolInventory.mockReturnValueOnce(makeCoreInventory()); - const { respond, invoke } = createInvokeParams({ - sessionKey: "global", - agentId: "work", - }); + const { respond, invoke } = createInvokeParams( + { + sessionKey: "global", + agentId: "work", + }, + { agents: { list: [{ id: "main" }, { id: "work" }] } }, + ); await invoke(); expect(runtimeMocks.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "work" }); @@ -863,6 +866,33 @@ describe("tools.effective handler", () => { expect(runtimeMocks.resolveAgentDir).toHaveBeenCalledWith({}, "work"); }); + it("loads a bare session through the persisted fixed-store owner", async () => { + runtimeMocks.loadSessionEntry.mockReturnValueOnce({ + cfg: {}, + canonicalKey: "global", + entry: { sessionId: "session-ops-global", updatedAt: 1 }, + storePath: "/tmp/shared-sessions.sqlite", + } as never); + runtimeMocks.resolveSessionAgentId.mockReturnValueOnce("ops"); + runtimeMocks.resolveEffectiveToolInventory.mockReturnValueOnce(makeCoreInventory()); + + const { respond, invoke } = createInvokeParams( + { sessionKey: "global" }, + { + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + }, + ); + await invoke(); + + expect(runtimeMocks.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "ops" }); + expect(firstRespondCall(respond)?.[0]).toBe(true); + }); + it("does not let a requested agent override ownership of a non-global session key", async () => { runtimeMocks.listAgentIds.mockReturnValueOnce(["main", "work"]); runtimeMocks.loadSessionEntry.mockReturnValueOnce({ @@ -873,19 +903,18 @@ describe("tools.effective handler", () => { // Persisted owner of the non-global key. runtimeMocks.resolveSessionAgentId.mockReturnValueOnce("main"); - const { respond, invoke } = createInvokeParams({ - sessionKey: "agent:main:abc", - agentId: "work", - }); + const { respond, invoke } = createInvokeParams( + { + sessionKey: "agent:main:abc", + agentId: "work", + }, + { agents: { list: [{ id: "main" }, { id: "work" }] } }, + ); await invoke(); - // Wiring guard: for a non-global key the requested agent must NOT be forwarded - // as the session-agent override, otherwise the real resolver would prefer - // "work" and silently pass the mismatch check below. - expect(runtimeMocks.resolveSessionAgentId).toHaveBeenLastCalledWith( - expect.not.objectContaining({ agentId: expect.anything() }), - ); - expectInvalidResponse(respond, 'agent id "work" does not match session agent "main"'); + expectInvalidResponse(respond, 'agent "work" does not match session key agent "main"'); + expect(runtimeMocks.loadSessionEntry).not.toHaveBeenCalled(); + expect(runtimeMocks.resolveSessionAgentId).not.toHaveBeenCalled(); expect(runtimeMocks.resolveEffectiveToolInventory).not.toHaveBeenCalled(); }); }); diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index 91190a45fb46..4e4d41382a6b 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -21,6 +21,7 @@ import { logDebug, logWarn } from "../../logger.js"; import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { sessionDeliveryOrigin } from "../../utils/delivery-context.shared.js"; import { getConnectedNodePluginToolsVersion } from "../node-plugin-tool-snapshot.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { applyFinalEffectiveToolPolicy, buildBundleMcpToolsFromCatalog, @@ -544,18 +545,11 @@ function resolveTrustedToolsEffectiveContext(params: { return null; } - // Only a canonical `global` key may adopt the client-requested agent: global - // stores are shared, so the requested agent selects which agent's global store - // to read. Non-global keys encode their owning agent, so the requested agent - // must stay subject to the mismatch guard below instead of overriding session - // ownership — otherwise `{ sessionKey: "agent:main:x", agentId: "work" }` would - // resolve under `work` and silently bypass the guard. const canonicalKey = loaded.canonicalKey ?? params.sessionKey; - const allowRequestedAgentOverride = canonicalKey === "global" && Boolean(params.requestedAgentId); const sessionAgentId = resolveSessionAgentId({ sessionKey: canonicalKey, config: loaded.cfg, - ...(allowRequestedAgentOverride ? { agentId: params.requestedAgentId } : {}), + ...(params.requestedAgentId ? { agentId: params.requestedAgentId } : {}), }); if (params.requestedAgentId && params.requestedAgentId !== sessionAgentId) { params.respond( @@ -639,9 +633,18 @@ async function handleToolsEffectiveRequest(params: { if (requestedAgentId === null) { return; } + const sessionOwner = resolveRequestedSessionAgentId( + cfg, + params.rawParams.sessionKey, + requestedAgentId, + ); + if (!sessionOwner.ok) { + params.respond(false, undefined, sessionOwner.error); + return; + } const trustedContext = resolveTrustedToolsEffectiveContext({ sessionKey: params.rawParams.sessionKey, - requestedAgentId, + requestedAgentId: sessionOwner.agentId, respond: params.respond, }); if (!trustedContext) { diff --git a/src/gateway/server-methods/ui-command.test.ts b/src/gateway/server-methods/ui-command.test.ts index d2e9d10c6b57..ee5e3e5e88b9 100644 --- a/src/gateway/server-methods/ui-command.test.ts +++ b/src/gateway/server-methods/ui-command.test.ts @@ -28,6 +28,7 @@ async function call(params: unknown, clients: GatewayClient[]) { respond, context: { broadcastToConnIds, + getRuntimeConfig: () => ({}), getClientConnIds: (filter?: (client: GatewayClient) => boolean) => new Set( clients @@ -75,7 +76,11 @@ describe("ui.command gateway method", () => { expect(result.broadcastToConnIds).toHaveBeenCalledWith( "ui.command", - params, + { + ...params, + agentId: "main", + sessionKey: "agent:main:other", + }, new Set(["ui-one", "ui-two"]), ); expect(result.respond).toHaveBeenCalledWith(true, { ok: true }); diff --git a/src/gateway/server-methods/ui-command.ts b/src/gateway/server-methods/ui-command.ts index 9df32c5de428..1135ea80f1f9 100644 --- a/src/gateway/server-methods/ui-command.ts +++ b/src/gateway/server-methods/ui-command.ts @@ -10,6 +10,8 @@ import { validateUiCommandParams, } from "../../../packages/gateway-protocol/src/index.js"; import type { GatewayRequestContextWithClientLookup } from "../server-request-context.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -20,6 +22,38 @@ export const uiCommandHandlers: GatewayRequestHandlers = { } const commandParams = params as UiCommandParams; + const commandSessionKey = + "sessionKey" in commandParams.command + ? commandParams.command.sessionKey + : commandParams.sessionKey; + const requestedSession = commandSessionKey + ? resolveRequestedSessionAgentId( + context.getRuntimeConfig(), + commandSessionKey, + commandParams.agentId, + ) + : undefined; + if (requestedSession && !requestedSession.ok) { + respond(false, undefined, requestedSession.error); + return; + } + const canonicalSessionKey = + commandSessionKey && requestedSession?.ok + ? resolveStoredSessionKeyForAgentStore({ + cfg: context.getRuntimeConfig(), + agentId: requestedSession.agentId, + sessionKey: commandSessionKey, + }) + : undefined; + const normalizedParams: UiCommandParams = { + ...commandParams, + ...(canonicalSessionKey ? { sessionKey: canonicalSessionKey } : {}), + ...(requestedSession?.ok ? { agentId: requestedSession.agentId } : {}), + command: + canonicalSessionKey && "sessionKey" in commandParams.command + ? { ...commandParams.command, sessionKey: canonicalSessionKey } + : commandParams.command, + }; const clientContext = context as GatewayRequestContextWithClientLookup; // v1 intentionally fans out to every capable Control UI; session-targeted routing is out of scope. const connIds = @@ -33,7 +67,7 @@ export const uiCommandHandlers: GatewayRequestHandlers = { return; } - context.broadcastToConnIds("ui.command", commandParams, connIds); + context.broadcastToConnIds("ui.command", normalizedParams, connIds); respond(true, { ok: true }); }, }; diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index 6a3fa8b97bd8..a111f67ff44a 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -12,7 +12,7 @@ vi.mock("../../config/config.js", () => { return { getRuntimeConfig: vi.fn(() => ({ agents: { - list: [{ id: "main" }, { id: "opus" }], + list: [{ id: "main", default: true }, { id: "opus" }], }, session: {}, })), @@ -112,7 +112,7 @@ import { testApi, usageHandlers } from "./usage.js"; const TEST_RUNTIME_CONFIG = { agents: { - list: [{ id: "main" }, { id: "opus" }], + list: [{ id: "main", default: true }, { id: "opus" }], }, session: {}, }; @@ -133,7 +133,10 @@ async function runSessionsUsage( return respond; } -async function runSessionsUsageTimeseries(params: Record) { +async function runSessionsUsageTimeseries( + params: Record, + config: OpenClawConfig = TEST_RUNTIME_CONFIG, +) { const respond = vi.fn(); await expectDefined( usageHandlers["sessions.usage.timeseries"], @@ -141,12 +144,15 @@ async function runSessionsUsageTimeseries(params: Record) { )({ respond, params, - context: { getRuntimeConfig: () => TEST_RUNTIME_CONFIG }, + context: { getRuntimeConfig: () => config }, } as unknown as Parameters<(typeof usageHandlers)["sessions.usage.timeseries"]>[0]); return respond; } -async function runSessionsUsageLogs(params: Record) { +async function runSessionsUsageLogs( + params: Record, + config: OpenClawConfig = TEST_RUNTIME_CONFIG, +) { const respond = vi.fn(); await expectDefined( usageHandlers["sessions.usage.logs"], @@ -154,7 +160,7 @@ async function runSessionsUsageLogs(params: Record) { )({ respond, params, - context: { getRuntimeConfig: () => TEST_RUNTIME_CONFIG }, + context: { getRuntimeConfig: () => config }, } as unknown as Parameters<(typeof usageHandlers)["sessions.usage.logs"]>[0]); return respond; } @@ -197,6 +203,7 @@ function mockStoredSession( const storePath = "/tmp/agents/opus/agent/openclaw-agent.sqlite"; vi.mocked(loadGatewaySessionEntryReadOnly).mockReturnValueOnce({ cfg: TEST_RUNTIME_CONFIG, + agentId: "opus", canonicalKey: key, entry, legacyKey: undefined, @@ -847,6 +854,38 @@ describe("sessions.usage", () => { ); }); + it("loads bare-key usage details through the persisted fixed-store owner", async () => { + const config: OpenClawConfig = { + session: { store: "/tmp/shared-sessions.sqlite", scope: "global" }, + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + defaults: { sessionStore: { agentId: "ops" } }, + }, + }; + const entry = { sessionId: "s-ops", updatedAt: 1_000 }; + vi.mocked(loadGatewaySessionEntryReadOnly).mockReturnValueOnce({ + cfg: config, + agentId: "ops", + canonicalKey: "global", + entry, + legacyKey: undefined, + store: { global: entry }, + storeKeys: ["global"], + storePath: "/tmp/shared-sessions.sqlite", + }); + + const respond = await runSessionsUsageTimeseries({ key: "global" }, config); + + expect(mockArg(respond, 0, 0)).toBe(true); + expect(vi.mocked(loadGatewaySessionEntryReadOnly)).toHaveBeenCalledWith("global", { + agentId: "ops", + }); + expect(vi.mocked(loadSessionUsageTimeSeries)).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "ops" }), + ); + }); + it("preserves JSONL detail lookup for storeless sessions", async () => { await withUsageState(async (writeSessionFile) => { const sessionFile = writeSessionFile("storeless.jsonl"); diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts index d7de6c3351ee..68566bf84629 100644 --- a/src/gateway/server-methods/usage.test.ts +++ b/src/gateway/server-methods/usage.test.ts @@ -730,7 +730,7 @@ describe("gateway usage helpers", () => { ); const config = { - agents: { list: [{ id: "main" }, { id: "opus" }] }, + agents: { list: [{ id: "main", default: true }, { id: "opus" }] }, session: {}, } as OpenClawConfig; const context = { getRuntimeConfig: () => config }; diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 70b5d8a784c2..02447ebb167e 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -8,7 +8,7 @@ import { errorShape, validateSessionsUsageParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentIds, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { parseSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { resolveSessionFilePathCore, @@ -62,6 +62,7 @@ import { } from "../../utils/delivery-context.shared.js"; import { runTasksWithConcurrency } from "../../utils/run-with-concurrency.js"; import { listGatewayAgentsBasic } from "../agent-list.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { resolveSessionStoreAgentId, resolveStoredSessionKeyForAgentStore, @@ -140,7 +141,8 @@ function resolveSessionUsageTarget( agentIdHint ? { agentId: agentIdHint } : undefined, ); const parsed = parseAgentSessionKey(key); - const agentId = parsed?.agentId ?? agentIdHint ?? resolveDefaultAgentId(config); + const agentId = + parsed?.agentId ?? agentIdHint ?? resolveSessionAgentId({ config, sessionKey: key }); const sessionId = entry?.sessionId ?? parsed?.rest ?? key; const sessionFile = entry ? resolveExistingUsageSessionFile({ @@ -297,9 +299,14 @@ function resolveSessionUsageFileOrRespond( respond: RespondFn, config: OpenClawConfig, ): (ResolvedSessionUsageTarget & { config: OpenClawConfig }) | null { + const sessionOwner = resolveRequestedSessionAgentId(config, key); + if (!sessionOwner.ok) { + respond(false, undefined, sessionOwner.error); + return null; + } let resolved: ResolvedSessionUsageTarget | undefined; try { - resolved = resolveSessionUsageTarget(key, config); + resolved = resolveSessionUsageTarget(key, config, sessionOwner.agentId); } catch { resolved = undefined; } @@ -1029,7 +1036,7 @@ async function loadCostUsageSummaryCached(params: { const allAgents = params.agentScope === "all"; const agentId = allAgents ? undefined - : normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.config)); + : normalizeAgentId(params.agentId ?? resolveSessionAgentId({ config: params.config })); const dayBucketKey = usageDayBucketCacheKey(params.dayBucket); const cacheKey = `${allAgents ? "all" : `agent:${agentId}`}:${params.startMs}-${params.endMs}:${dayBucketKey}`; return await loadUsageResultCached({ @@ -1158,12 +1165,21 @@ export const usageHandlers: GatewayRequestHandlers = { const { startMs, endMs } = range; const agentId = normalizeOptionalString(params?.agentId); const agentScope = params?.agentScope === "all" && !agentId ? "all" : undefined; + let effectiveAgentId = agentId; + if (!agentScope && !effectiveAgentId) { + const requestedAgent = resolveRequestedSessionAgentId(config, "main"); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + effectiveAgentId = requestedAgent.agentId; + } const summary = await loadCostUsageSummaryCached({ startMs, endMs, dayBucket: resolveDayBucket(dateInterpretation), config, - agentId, + agentId: effectiveAgentId, agentScope, }); respond(true, summary, undefined); @@ -1198,22 +1214,26 @@ export const usageHandlers: GatewayRequestHandlers = { ); return; } - const specificKeyAgentId = specificKey ? parseAgentSessionKey(specificKey)?.agentId : undefined; - if ( - requestedAgentId && - specificKeyAgentId && - normalizeAgentId(requestedAgentId) !== specificKeyAgentId - ) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"), - ); + const specificSessionOwner = specificKey + ? resolveRequestedSessionAgentId(config, specificKey, requestedAgentId) + : undefined; + if (specificSessionOwner && !specificSessionOwner.ok) { + respond(false, undefined, specificSessionOwner.error); + return; + } + const implicitAgent = + !requestedAllAgents && !specificSessionOwner?.agentId && !requestedAgentId + ? resolveRequestedSessionAgentId(config, "main") + : undefined; + if (implicitAgent && !implicitAgent.ok) { + respond(false, undefined, implicitAgent.error); return; } const effectiveAgentId = requestedAllAgents ? undefined - : normalizeAgentId(requestedAgentId ?? specificKeyAgentId ?? resolveDefaultAgentId(config)); + : normalizeAgentId( + specificSessionOwner?.agentId ?? requestedAgentId ?? implicitAgent?.agentId, + ); const groupingMode: UsageGroupingMode = p.groupBy === "family" || p.includeHistorical === true ? "family" : "instance"; @@ -1249,12 +1269,16 @@ export const usageHandlers: GatewayRequestHandlers = { if (specificKey) { const scopedSpecificKey = resolveStoredSessionKeyForAgentStore({ cfg: config, - agentId: effectiveAgentId ?? resolveDefaultAgentId(config), + agentId: + effectiveAgentId ?? + expectDefined(specificSessionOwner?.agentId, "specific session owner"), sessionKey: specificKey, }); const scopedParsed = parseAgentSessionKey(scopedSpecificKey); const agentIdFromKey = - scopedParsed?.agentId ?? effectiveAgentId ?? resolveDefaultAgentId(config); + scopedParsed?.agentId ?? + effectiveAgentId ?? + expectDefined(specificSessionOwner?.agentId, "specific session owner"); const keyRest = scopedParsed?.rest ?? specificKey; // Prefer the store entry when available, even if the caller provides a discovered key diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 67c66a47f39d..66321b5f4e1d 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -40,6 +40,7 @@ const buildSessionLookup = ( } = {}, ): ReturnType => ({ cfg: { session: { mainKey: "agent:main:main" } } as OpenClawConfig, + agentId: "main", storePath: "/tmp/sessions.json", store: {} as ReturnType["store"], entry: { diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index ac23b6ba1ac5..817b5f5c7db1 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -33,7 +33,7 @@ type GatewayStartupTrace = { type GatewayPluginBootstrapParams = { cfg: OpenClawConfig; activationSourceConfig?: OpenClawConfig; - workspaceDir: string; + workspaceDir?: string; log: GatewayPluginBootstrapLog; coreGatewayHandlers?: Record; coreGatewayMethodNames?: readonly string[]; diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 1236214f25fe..202bf9b706b6 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -441,7 +441,7 @@ export function loadGatewayPlugins(params: { cfg: OpenClawConfig; activationSourceConfig?: OpenClawConfig; autoEnabledReasons?: Readonly>; - workspaceDir: string; + workspaceDir?: string; log: { info: (msg: string) => void; warn: (msg: string) => void; diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index b0961ba7131f..4f6b9b28f8b2 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -13,7 +13,9 @@ type RestartSentinel = NonNullable< Awaited> >; -type LoadedSessionEntry = ReturnType; +type LoadedSessionEntryBase = ReturnType; +type LoadedSessionEntry = Omit & + Partial>; type RecordInboundSessionAndDispatchReplyParams = Parameters< typeof import("../channels/turn/lifecycle.js").dispatchAssembledChannelTurn >[0] & { @@ -83,6 +85,7 @@ const mocks = vi.hoisted(() => { loadSessionEntry: vi.fn( (): LoadedSessionEntry => ({ cfg: {}, + agentId: "main", entry: { sessionId: "agent:main:main", updatedAt: 0, diff --git a/src/gateway/server-runtime-subscriptions.test.ts b/src/gateway/server-runtime-subscriptions.test.ts index cff940ff28bf..397eea78eda5 100644 --- a/src/gateway/server-runtime-subscriptions.test.ts +++ b/src/gateway/server-runtime-subscriptions.test.ts @@ -77,6 +77,11 @@ const transcriptBroadcastMocks = vi.hoisted(() => ({ useActualHandler: false, readMessageCount: vi.fn(), })); +const runtimeConfigState = vi.hoisted(() => ({ value: {} as Record })); + +vi.mock("../config/io.js", () => ({ + getRuntimeConfig: () => runtimeConfigState.value, +})); vi.mock("../audit/audit-config.js", () => ({ isAuditLedgerEnabled: () => auditTestState.enabled, @@ -172,6 +177,7 @@ describe("startGatewayEventSubscriptions", () => { auditTestState.stopped = 0; transcriptBroadcastMocks.useActualHandler = false; transcriptBroadcastMocks.readMessageCount.mockReset(); + runtimeConfigState.value = {}; agentEventHandlerMocks.create.mockReset().mockImplementation(() => { throw new Error("server-chat lazy load failure"); }); @@ -270,6 +276,45 @@ describe("startGatewayEventSubscriptions", () => { expect(dispose).toHaveBeenCalledOnce(); }); + it("uses the persisted bare-key owner for ownerless active-run projections", async () => { + runtimeConfigState.value = { + session: { scope: "global", store: "/tmp/openclaw-owned-sessions.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + const handler = Object.assign(vi.fn(), { dispose: vi.fn() }); + agentEventHandlerMocks.create.mockReturnValue(handler); + const params = createParams(); + params.chatAbortControllers.set("run-ops", { + sessionKey: "global", + sessionId: "session-ops", + } as never); + unsubs = startGatewayEventSubscriptions(params); + + emitAgentEvent({ runId: "load-handler", stream: "lifecycle", data: { phase: "error" } }); + await waitForFast(() => expect(agentEventHandlerMocks.create).toHaveBeenCalledOnce()); + const options = agentEventHandlerMocks.create.mock.calls[0]?.[0] as { + resolveSessionActiveRunState?: (session: { + requestedKey: string; + canonicalKey: string; + sessionId?: string; + agentId?: string; + }) => { active: boolean; runIds: string[] }; + }; + + expect( + options.resolveSessionActiveRunState?.({ + requestedKey: "global", + canonicalKey: "global", + sessionId: "session-ops", + agentId: "ops", + }), + ).toEqual({ active: true, runIds: ["run-ops"] }); + }); + it("logs transcript handler failures", async () => { unsubs = startGatewayEventSubscriptions(createParams()); diff --git a/src/gateway/server-runtime-subscriptions.ts b/src/gateway/server-runtime-subscriptions.ts index 16bb3029f525..04b24e0f42e1 100644 --- a/src/gateway/server-runtime-subscriptions.ts +++ b/src/gateway/server-runtime-subscriptions.ts @@ -1,5 +1,4 @@ // Gateway event subscription wiring for agent, heartbeat, transcript, and lifecycle broadcasts. -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isAuditLedgerEnabled, resolveAuditMessageMode } from "../audit/audit-config.js"; import { createAuditEventRecorder } from "../audit/audit-recorder.js"; import { configureExecutionIdentityAdmissionSink } from "../audit/execution-identity-admission.js"; @@ -32,6 +31,7 @@ import { mapTaskSummary, type TaskEventPayload } from "./server-methods/task-sum import { defaultSessionCompanionContextReader } from "./session-companion-context.js"; import { createSessionCompanion } from "./session-companion.js"; import { createSessionObserver } from "./session-observer.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "./session-request-agent.js"; import type { TerminalSessionManager } from "./terminal/session-manager.js"; function dispatchEventHandler(params: { @@ -237,7 +237,10 @@ export function startGatewayEventSubscriptions(params: { resolveVisibleActiveSessionRunState({ context: params, ...session, - defaultAgentId: resolveDefaultAgentId(getRuntimeConfig()), + defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId( + getRuntimeConfig(), + session.requestedKey, + ), }), }), ); diff --git a/src/gateway/server-session-events.test.ts b/src/gateway/server-session-events.test.ts index c72b548e2a86..e415ea9fb236 100644 --- a/src/gateway/server-session-events.test.ts +++ b/src/gateway/server-session-events.test.ts @@ -19,8 +19,9 @@ const projectChatDisplayMessageMock = vi.hoisted(() => vi.fn((message: unknown) const loadAccessorSessionEntryReadOnlyMock = vi.hoisted(() => vi.fn()); const loadGatewaySessionEntryReadOnlyMock = vi.hoisted(() => vi.fn()); const readSessionMessageCountAsyncMock = vi.hoisted(() => vi.fn()); +const runtimeConfigState = vi.hoisted(() => ({ value: {} as Record })); -vi.mock("../config/io.js", () => ({ getRuntimeConfig: () => ({}) })); +vi.mock("../config/io.js", () => ({ getRuntimeConfig: () => runtimeConfigState.value })); vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -105,6 +106,9 @@ describe("createTranscriptUpdateBroadcastHandler", () => { loadGatewaySessionEntryReadOnlyMock.mockReturnValue({ entry: undefined, storePath: "" }); loadGatewaySessionRowMock.mockReturnValue(sessionRow); readSessionMessageCountAsyncMock.mockResolvedValue(undefined); + loadGatewaySessionRowMock.mockReturnValue(sessionRow); + runtimeConfigState.value = {}; + sessionRow.key = "agent:main:main"; sessionRow.thinkingLevel = "ultra"; }); @@ -464,6 +468,51 @@ describe("createTranscriptUpdateBroadcastHandler", () => { expect(isEmbeddedAgentRunInProgressMock).toHaveBeenCalledWith("sess-main"); }); + it("routes an ownerless bare transcript event through the persisted fixed-store owner", async () => { + runtimeConfigState.value = { + session: { store: "/tmp/owned-shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + sessionRow.key = "global"; + const getSessionMessageSubscribers = vi.fn((sessionKey: string) => + sessionKey === "global" + ? new Set(["conn-global"]) + : sessionKey === "agent:ops:global" + ? new Set(["conn-scoped"]) + : new Set(), + ); + const broadcastToConnIds = vi.fn(); + const handler = createTranscriptUpdateBroadcastHandler({ + broadcastToConnIds, + sessionEventSubscribers: { getAll: () => new Set() }, + sessionMessageSubscribers: { get: getSessionMessageSubscribers }, + chatAbortControllers: new Map(), + }); + + await handler({ + sessionKey: "global", + message: { role: "assistant", content: [{ type: "text", text: "Owner reply" }] }, + messageId: "message-global-owner", + messageSeq: 1, + }); + + expect(getSessionMessageSubscribers).toHaveBeenCalledWith("agent:ops:global"); + expect(getSessionMessageSubscribers).toHaveBeenCalledWith("global"); + expect(loadGatewaySessionRowMock).toHaveBeenCalledWith("global", { + agentId: "ops", + transcriptUsageMaxBytes: 64 * 1024, + }); + expect(broadcastToConnIds).toHaveBeenCalledWith( + "session.message", + expect.objectContaining({ sessionKey: "global" }), + new Set(["conn-scoped", "conn-global"]), + ); + }); + it("broadcasts user idempotency keys in session.message metadata", async () => { await expect( emitAssistantTranscriptUpdate(false, { @@ -638,9 +687,12 @@ describe("createTranscriptUpdateBroadcastHandler", () => { describe("createLifecycleEventBroadcastHandler", () => { beforeEach(() => { + vi.clearAllMocks(); + isEmbeddedAgentRunInProgressMock.mockReturnValue(false); loadGatewaySessionRowMock.mockReturnValue(sessionRow); + runtimeConfigState.value = {}; + sessionRow.key = "agent:main:main"; }); - it("projects swarm phase and log payload fields", () => { const broadcastToConnIds = vi.fn(); const handler = createLifecycleEventBroadcastHandler({ @@ -688,6 +740,7 @@ describe("createLifecycleEventBroadcastHandler", () => { await Promise.resolve(); expect(received).toHaveBeenCalledWith({ sessionKey: "agent:main:main", + agentId: "main", label: "Renamed session", reason: "rename", }); @@ -695,4 +748,41 @@ describe("createLifecycleEventBroadcastHandler", () => { unsubscribe(); } }); + + it("projects active state for a bare lifecycle event through the persisted owner", () => { + runtimeConfigState.value = { + session: { store: "/tmp/owned-shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + sessionRow.key = "global"; + const activeRun = { + ...createActiveRun(true), + agentId: "ops", + sessionKey: "global", + }; + const broadcastToConnIds = vi.fn(); + const handler = createLifecycleEventBroadcastHandler({ + broadcastToConnIds, + sessionEventSubscribers: { getAll: () => new Set(["conn-1"]) }, + chatAbortControllers: new Map([["run-before-finalize", activeRun]]), + }); + + handler({ sessionKey: "global", reason: "updated" }); + + expect(loadGatewaySessionRowMock).toHaveBeenCalledWith("global", { agentId: "ops" }); + expect(broadcastToConnIds).toHaveBeenCalledWith( + "sessions.changed", + expect.objectContaining({ + sessionKey: "global", + hasActiveRun: true, + activeRunIds: ["run-before-finalize"], + }), + new Set(["conn-1"]), + { dropIfSlow: true }, + ); + }); }); diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index d437494e3c5d..d21adc299462 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -3,15 +3,16 @@ import path from "node:path"; import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/io.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { listSessionEntriesReadOnly as listAccessorSessionEntriesReadOnly, loadSessionEntryReadOnly as loadAccessorSessionEntryReadOnly, resolveTranscriptSessionKeyBySessionId, } from "../config/sessions/session-accessor.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import type { SessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import type { InternalSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import type { ChatAbortControllerEntry } from "./chat-abort.js"; @@ -42,6 +43,10 @@ import { type SessionEventSubscribers = Pick; type SessionMessageSubscribers = Pick; +function tryResolveCompatibilityDefaultAgentId(): string | undefined { + return tryResolveLegacyCompatibilityAgentId(getRuntimeConfig()); +} + function readMessageIdempotencyKey(message: unknown): string | undefined { if (!message || typeof message !== "object" || Array.isArray(message)) { return undefined; @@ -243,7 +248,6 @@ async function handleTranscriptUpdateBroadcast( return; } const compatibleLegacyMarker = completeTarget ? undefined : legacyMarker; - const storageAgentId = compatibleLegacyMarker?.agentId ?? targetAgentId ?? update.agentId; const sessionKey = compatibleLegacyMarker ? candidateKeyEntry?.sessionId === compatibleLegacyMarker.sessionId || (!candidateKeyEntry && markerMatches.length === 0) @@ -254,23 +258,25 @@ async function handleTranscriptUpdateBroadcast( return; } const effectiveAgentId = compatibleLegacyMarker?.agentId ?? targetAgentId ?? update.agentId; - const defaultGlobalAgentId = - sessionKey === "global" - ? normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())) - : undefined; + const compatibilityDefaultAgentId = tryResolveCompatibilityDefaultAgentId(); + const persistedOwner = resolvePersistedSessionStoreOwnerForKey(getRuntimeConfig(), sessionKey); + const stableCompatibilityAgentId = + persistedOwner.kind === "configured" ? persistedOwner.agentId : compatibilityDefaultAgentId; + const stableUnscopedOwner = + !parseAgentSessionKey(sessionKey) && !effectiveAgentId ? stableCompatibilityAgentId : undefined; + const storageAgentId = effectiveAgentId ?? stableUnscopedOwner; const visibleAgentId = effectiveAgentId; - const routingAgentId = effectiveAgentId ?? defaultGlobalAgentId; + const routingAgentId = effectiveAgentId ?? stableUnscopedOwner; const connIds = new Set(); for (const connId of params.sessionEventSubscribers.getAll()) { connIds.add(connId); } let broadcastKeys = [sessionKey]; - if (sessionKey === "global") { - const defaultAgentId = resolveDefaultAgentId(getRuntimeConfig()); + if (sessionKey === "global" && routingAgentId) { broadcastKeys = resolveSessionSubscriptionKeys( sessionKey, - routingAgentId ?? defaultAgentId, - defaultAgentId, + routingAgentId, + stableCompatibilityAgentId, ); } for (const broadcastKey of broadcastKeys) { @@ -338,16 +344,18 @@ async function handleTranscriptUpdateBroadcast( agentId: routingAgentId, transcriptUsageMaxBytes: 64 * 1024, }); - const activeRunState = sessionRow - ? resolveVisibleActiveSessionRunState({ - context: params, - requestedKey: sessionKey, - canonicalKey: sessionRow.key, - sessionId: sessionRow.sessionId, - ...(sessionRow.key === "global" && routingAgentId ? { agentId: routingAgentId } : {}), - defaultAgentId: normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())), - }) - : null; + const activeRunState = + sessionRow && + (sessionRow.key !== "global" || routingAgentId !== undefined || compatibilityDefaultAgentId) + ? resolveVisibleActiveSessionRunState({ + context: params, + requestedKey: sessionKey, + canonicalKey: sessionRow.key, + sessionId: sessionRow.sessionId, + ...(routingAgentId ? { agentId: routingAgentId } : {}), + defaultAgentId: stableUnscopedOwner, + }) + : null; const sessionSnapshot = buildGatewaySessionSnapshot({ sessionRow, agentId: routingAgentId, @@ -434,20 +442,36 @@ export function createLifecycleEventBroadcastHandler(params: { if (!hasSessionChangeReceivers(connIds)) { return; } - const sessionRow = loadGatewaySessionRow(event.sessionKey); - const activeRunState = sessionRow - ? resolveVisibleActiveSessionRunState({ - context: params, - requestedKey: event.sessionKey, - canonicalKey: sessionRow.key, - sessionId: sessionRow.sessionId, - defaultAgentId: normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())), - }) - : null; + const compatibilityDefaultAgentId = tryResolveCompatibilityDefaultAgentId(); + const eventAgentId = + normalizeOptionalString(event.agentId) ?? parseAgentSessionKey(event.sessionKey)?.agentId; + const persistedOwner = resolvePersistedSessionStoreOwnerForKey( + getRuntimeConfig(), + event.sessionKey, + ); + const stableOwnerAgentId = + (persistedOwner.kind === "configured" ? persistedOwner.agentId : undefined) ?? + compatibilityDefaultAgentId; + const rowAgentId = eventAgentId ?? stableOwnerAgentId; + const sessionRow = rowAgentId + ? loadGatewaySessionRow(event.sessionKey, { agentId: rowAgentId }) + : undefined; + const activeRunState = + sessionRow && (sessionRow.key !== "global" || rowAgentId) + ? resolveVisibleActiveSessionRunState({ + context: params, + requestedKey: event.sessionKey, + canonicalKey: sessionRow.key, + sessionId: sessionRow.sessionId, + ...(rowAgentId ? { agentId: rowAgentId } : {}), + defaultAgentId: stableOwnerAgentId, + }) + : null; params.broadcastToConnIds( "sessions.changed", { sessionKey: event.sessionKey, + ...(eventAgentId ? { agentId: eventAgentId } : {}), reason: event.reason, parentSessionKey: event.parentSessionKey, label: event.label, diff --git a/src/gateway/server-startup-bootstrap.ts b/src/gateway/server-startup-bootstrap.ts index 4de6165d628c..649487b6ec8a 100644 --- a/src/gateway/server-startup-bootstrap.ts +++ b/src/gateway/server-startup-bootstrap.ts @@ -502,6 +502,7 @@ export async function prepareGatewayServerBootstrap(input: { const { gatewayPluginConfigAtStart, defaultWorkspaceDir, + pluginWorkspaceDir, startupPluginIds, pluginManifestRecords, pluginMetadataSnapshot, @@ -527,7 +528,7 @@ export async function prepareGatewayServerBootstrap(input: { config: startupActivationSourceConfig, compatibleConfigs: [startupRuntimeConfig, cfgAtStart, gatewayPluginConfigAtStart], env: process.env, - workspaceDir: defaultWorkspaceDir, + workspaceDir: pluginWorkspaceDir, }); if (pluginLookUpTable) { const metrics = pluginLookUpTable.metrics; @@ -574,6 +575,7 @@ export async function prepareGatewayServerBootstrap(input: { pluginBootstrap, gatewayPluginConfigAtStart, defaultWorkspaceDir, + pluginWorkspaceDir, startupPluginIds, pluginManifestRecords, pluginMetadataSnapshot, diff --git a/src/gateway/server-startup-config-helpers.ts b/src/gateway/server-startup-config-helpers.ts index 87fcbdc4d43b..c0db97b2ca1c 100644 --- a/src/gateway/server-startup-config-helpers.ts +++ b/src/gateway/server-startup-config-helpers.ts @@ -9,6 +9,11 @@ import { readConfigFileSnapshotWithPluginMetadata, } from "../config/io.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; +import { + retainLegacyDefaultAgentId, + tryGetLegacyDefaultAgentId, +} from "../config/legacy.default-agent-owner.js"; +import { materializeLegacyDefaultAgentRoles } from "../config/legacy.default-agent-roles.js"; import { isNixMode } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js"; @@ -125,8 +130,13 @@ export async function loadGatewayStartupConfigSnapshot(params: { params.log.info( `gateway: auto-enabled plugins for this runtime without writing config:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`, ); + const legacyDefaultAgentId = tryGetLegacyDefaultAgentId(configSnapshot.sourceConfig); + const runtimeConfig = legacyDefaultAgentId + ? materializeLegacyDefaultAgentRoles(autoEnable.config, legacyDefaultAgentId).config + : autoEnable.config; + retainLegacyDefaultAgentId(runtimeConfig, legacyDefaultAgentId); return { - snapshot: withRuntimeConfig(configSnapshot, autoEnable.config), + snapshot: withRuntimeConfig(configSnapshot, runtimeConfig), wroteConfig, ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), }; diff --git a/src/gateway/server-startup-config.secrets.test.ts b/src/gateway/server-startup-config.secrets.test.ts index 738b0f180eca..ffbde608a39a 100644 --- a/src/gateway/server-startup-config.secrets.test.ts +++ b/src/gateway/server-startup-config.secrets.test.ts @@ -2937,7 +2937,7 @@ describe("gateway startup config secret preflight", () => { try { await activateStartupConfigWithEnv( - { agents: { list: [{ id: "default", default: true }] } }, + { agents: { list: [{ id: "main", agentDir: relocatedMainAgentDir }] } }, activationEnv, ); @@ -2980,7 +2980,7 @@ describe("gateway startup config secret preflight", () => { await activateStartupConfigWithEnv( { agents: { - list: [{ id: "default", default: true, agentDir: "~/configured-agent" }], + list: [{ id: "main", agentDir: "~/configured-agent" }], }, }, activationEnv, diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 8135c96f4130..6fa72da03280 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -2,6 +2,7 @@ // plus secrets snapshots before the server exposes user-facing surfaces. import { isDeepStrictEqual } from "node:util"; import { hasLegacyAuthProfileSourcesForStartup } from "../agents/auth-profiles/legacy-source-diagnostic.js"; +import { inheritLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { applyConfigOverrides } from "../config/runtime-overrides.js"; import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; @@ -711,6 +712,6 @@ export async function prepareGatewayStartupConfig(params: { ).config; return { ...authBootstrap, - cfg: activatedConfig, + cfg: inheritLegacyDefaultAgentId(params.configSnapshot.config, activatedConfig), }; } diff --git a/src/gateway/server-startup-finish.ts b/src/gateway/server-startup-finish.ts index ecbc07718377..b4033714504f 100644 --- a/src/gateway/server-startup-finish.ts +++ b/src/gateway/server-startup-finish.ts @@ -274,7 +274,7 @@ export async function finishGatewayStartup(params: { return loadGatewayStartupPluginRuntime({ cfg: gatewayPluginConfigAtStart, activationSourceConfig: startupActivationSourceConfig, - workspaceDir: defaultWorkspaceDir, + workspaceDir: runtime.pluginWorkspaceDir, log, baseMethods, coreGatewayMethodNames, diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index 6e77a26d4dff..9fa04a8dddb8 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -3,7 +3,7 @@ import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import chalk from "chalk"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; -import { resolveDefaultAgentId, resolveAgentConfig } from "../agents/agent-scope.js"; +import { resolveAgentConfig, tryResolveLegacyCompatibilityAgentId } from "../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { formatFastModeValue, resolveFastModeState } from "../agents/fast-mode.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.types.js"; @@ -159,8 +159,8 @@ export function formatAgentModelStartupDetails(params: { provider: string; model: string; }): string { - const defaultAgentId = resolveDefaultAgentId(params.cfg); - const defaultAgentConfig = resolveAgentConfig(params.cfg, defaultAgentId); + const soleAgentId = tryResolveLegacyCompatibilityAgentId(params.cfg); + const defaultAgentConfig = soleAgentId ? resolveAgentConfig(params.cfg, soleAgentId) : undefined; const explicitThinking = resolveExplicitStartupThinking({ cfg: params.cfg, provider: params.provider, @@ -194,7 +194,7 @@ export function formatAgentModelStartupDetails(params: { cfg: params.cfg, provider: params.provider, model: params.model, - agentId: defaultAgentId, + agentId: soleAgentId, }); return `thinking=${thinking}, fast=${formatFastModeValue(fast.mode)}`; diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index ca372219c568..e84de9545b36 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -117,6 +117,7 @@ const migrateLegacyNodePairingStore = vi.hoisted(() => vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: () => "/workspace", resolveDefaultAgentId: () => "default", + tryResolveConfiguredAgentWorkspaceDir: () => "/workspace", })); vi.mock("../agents/subagents/registry/subagent-registry.js", () => ({ diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 0f38c2223aae..933026167537 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -1,6 +1,7 @@ // Gateway plugin startup bootstrap and adjacent startup maintenance. -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { tryResolveConfiguredAgentWorkspaceDir } from "../agents/agent-scope.js"; import { initSubagentRegistry } from "../agents/subagents/registry/subagent-registry.js"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js"; import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -131,14 +132,14 @@ export async function prepareGatewayPluginBootstrap(params: { ambientEnvTriggers: params.ambientEnvTriggers, }); const pluginsGloballyDisabled = gatewayPluginConfig.plugins?.enabled === false; - const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfig); - const defaultWorkspaceDir = resolveAgentWorkspaceDir(gatewayPluginConfig, defaultAgentId); + const pluginWorkspaceDir = tryResolveConfiguredAgentWorkspaceDir(gatewayPluginConfig); + const defaultWorkspaceDir = pluginWorkspaceDir ?? resolveDefaultAgentWorkspaceDir(); const pluginLookUpTable = params.minimalTestGateway || pluginsGloballyDisabled ? undefined : loadPluginLookUpTable({ config: gatewayPluginConfig, - workspaceDir: defaultWorkspaceDir, + workspaceDir: pluginWorkspaceDir, env: process.env, activationSourceConfig, metadataSnapshot: params.pluginMetadataSnapshot, @@ -177,6 +178,7 @@ export async function prepareGatewayPluginBootstrap(params: { return { gatewayPluginConfigAtStart: gatewayPluginConfig, defaultWorkspaceDir, + pluginWorkspaceDir, startupPluginIds, pluginManifestRecords, pluginMetadataSnapshot: pluginLookUpTable ?? params.pluginMetadataSnapshot, @@ -214,7 +216,7 @@ export function warnUnregisteredConfiguredMemoryEmbeddingProviders(params: { export async function loadGatewayStartupPluginRuntime(params: { cfg: OpenClawConfig; activationSourceConfig?: OpenClawConfig; - workspaceDir: string; + workspaceDir?: string; log: GatewayPluginBootstrapLog; baseMethods: string[]; coreGatewayMethodNames?: readonly string[]; diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index 5ad24e2f4c47..518c811088a0 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -447,6 +447,61 @@ describe("gateway server agent", () => { expect(call.sessionId).toBe("sess-ops"); }); + test("agent resolves a bare key through configured fixed-store ownership", async () => { + testState.agentsConfig = { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }; + testState.agentConfig = { sessionStore: { agentId: "ops" } }; + const { clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/io.js"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + await setTestSessionStore({ + agentId: "ops", + entries: { + global: { + sessionId: "sess-ops-global", + updatedAt: Date.now(), + }, + }, + }); + + const res = await rpcReq(gatewaySuite.ws, "agent", { + message: "hi", + sessionKey: "global", + idempotencyKey: "idem-agent-owned-global", + }); + expect(res.ok, JSON.stringify(res)).toBe(true); + + const call = await waitForAgentCommandCall("idem-agent-owned-global"); + expect(call.agentId).toBe("ops"); + expect(call.sessionKey).toBe("global"); + expect(call.sessionId).toBe("sess-ops-global"); + }); + + test("agent rejects an ownerless bare key before session preparation", async () => { + testState.agentsConfig = { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }; + const { clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/io.js"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + const res = await rpcReq(gatewaySuite.ws, "agent", { + message: "hi", + sessionKey: "global", + idempotencyKey: "idem-agent-ownerless-global", + }); + + expect(res.ok).toBe(false); + expect(res.error).toMatchObject({ + code: "INVALID_REQUEST", + message: expect.stringContaining("has no explicit owner"), + }); + expect(vi.mocked(agentCommandMock)).not.toHaveBeenCalled(); + }); + test.each(["success", "error"] as const)( "agent executes a group-only run without a resolved session key and closes authority after %s", async (outcome) => { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index ba0f15aa43aa..427ca56b881b 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -1069,9 +1069,11 @@ describe("gateway server chat", () => { routeVariants: [], })), }); + testState.agentsConfig = config.agents; openDirectChatSession(); try { await writeSessionStore({ + agentId: "work", entries: { "agent:work:main": { sessionId: "sess-work", updatedAt: Date.now() } }, }); const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; @@ -1082,12 +1084,13 @@ describe("gateway server chat", () => { context, }); - expect(responses[0]?.ok).toBe(true); + expect(responses[0]?.ok, JSON.stringify(responses[0])).toBe(true); expect( (responses[0]?.payload as { metadata?: { models?: unknown[] } })?.metadata?.models, ).toBe(undefined); expect(context.loadGatewayModelCatalogSnapshot).not.toHaveBeenCalled(); } finally { + testState.agentsConfig = undefined; testState.sessionStorePath = undefined; } }); @@ -5647,7 +5650,9 @@ describe("gateway server chat", () => { await writeGatewayConfig({ session: { scope: "global" }, agents: { - entries: { main: { default: true }, work: {} }, + ownership: "explicit", + defaults: { sessionStore: { agentId: "work" } }, + entries: { main: {}, work: {} }, }, }); await connectOk(ws); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index c28ce5d017c4..0096201b911f 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -425,8 +425,9 @@ describe("gateway config methods", () => { const agents = requireConfigObject(rosterConfig.agents ?? {}, "agents config"); rosterConfig.agents = { ...agents, + ownership: "explicit", entries: { - main: { default: true }, + main: {}, Worker: { workspace: "/srv/worker" }, }, }; @@ -1085,8 +1086,9 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), + ownership: "explicit", entries: { - main: { default: true, skills: ["alpha", "beta"] }, + main: { skills: ["alpha", "beta"] }, worker: { skills: ["gamma"] }, }, }; @@ -1097,6 +1099,7 @@ describe("gateway config methods", () => { try { const before = await getCurrentConfigObject(); + const beforeEntries = (before.config.agents as { entries?: Record }).entries; const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { raw: JSON.stringify({ agents: { entries: { main: { skills: ["alpha"] } } } }), baseHash: before.hash, @@ -1109,7 +1112,7 @@ describe("gateway config methods", () => { const after = await getCurrentConfigObject(); expect(after.hash).toBe(before.hash); expect((after.config.agents as { entries?: Record }).entries).toEqual( - agents.entries, + beforeEntries, ); } finally { await restoreConfigFileForTest(original); @@ -1120,8 +1123,9 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), + ownership: "explicit", entries: { - main: { default: true, skills: ["alpha", "beta"] }, + main: { skills: ["alpha", "beta"] }, worker: { skills: ["gamma"] }, }, }; @@ -1132,6 +1136,7 @@ describe("gateway config methods", () => { try { const before = await getCurrentConfigObject(); + const beforeEntries = (before.config.agents as { entries?: Record }).entries; const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { raw: JSON.stringify({ agents: { entries: { main: { skills: ["alpha"] } } } }), baseHash: before.hash, @@ -1145,7 +1150,7 @@ describe("gateway config methods", () => { const after = await getCurrentConfigObject(); expect(after.hash).toBe(before.hash); expect((after.config.agents as { entries?: Record }).entries).toEqual( - agents.entries, + beforeEntries, ); } finally { await restoreConfigFileForTest(original); @@ -1156,7 +1161,8 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), - entries: { main: { default: true, skills: ["alpha"] }, worker: {} }, + ownership: "explicit", + entries: { main: { skills: ["alpha"] }, worker: {} }, }; const seed = await sendConfigApply( configRawPayload({ ...original.config, agents }, original.hash), @@ -1185,9 +1191,9 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), + ownership: "explicit", entries: { main: { - default: true, subagents: { allowAgents: ["worker"] }, }, worker: {}, @@ -1220,8 +1226,9 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), + ownership: "explicit", entries: { - main: { default: true, skills: ["alpha", "beta"] }, + main: { skills: ["alpha", "beta"] }, worker: { skills: ["gamma"] }, }, }; @@ -1232,6 +1239,7 @@ describe("gateway config methods", () => { try { const before = await getCurrentConfigObject(); + const beforeEntries = (before.config.agents as { entries?: Record }).entries; const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { raw: JSON.stringify({ agents: { entries: { main: { skills: ["alpha"] } } } }), baseHash: before.hash, @@ -1241,8 +1249,11 @@ describe("gateway config methods", () => { expect(res.ok).toBe(true); const after = await getCurrentConfigObject(); expect((after.config.agents as { entries?: Record }).entries).toEqual({ - main: { default: true, skills: ["alpha"] }, - worker: { skills: ["gamma"] }, + ...beforeEntries, + main: { + ...(beforeEntries?.main as Record | undefined), + skills: ["alpha"], + }, }); } finally { await restoreConfigFileForTest(original); diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 9f0537501931..095ce51c6b27 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -504,7 +504,7 @@ describe("gateway server hooks", () => { testState.hooksConfig = { enabled: true, token: HOOK_TOKEN, - allowedAgentIds: ["hooks"], + allowedAgentIds: ["main", "hooks"], allowedSessionKeyPrefixes: ["hook:"], mappings: [ { @@ -1005,7 +1005,7 @@ describe("gateway server hooks", () => { expect(resNoAgent.status).toBe(200); await waitForSystemEventTexts(resolveMainKey()); const noAgentCall = cronRunCall(); - expect(noAgentCall?.job?.agentId).toBeUndefined(); + expect(noAgentCall?.job?.agentId).toBe("main"); expect(noAgentCall?.sessionKey).toBe("agent:main:slack:channel:c123"); expect(peekSystemEventEntries("agent:main:main")).toStrictEqual([]); drainSystemEvents(resolveMainKey()); @@ -1018,7 +1018,7 @@ describe("gateway server hooks", () => { expect(resBlankAgent.status).toBe(200); await waitForSystemEventTexts(resolveMainKey()); const blankAgentCall = cronRunCall(); - expect(blankAgentCall?.job?.agentId).toBeUndefined(); + expect(blankAgentCall?.job?.agentId).toBe("main"); drainSystemEvents(resolveMainKey()); }); }); diff --git a/src/gateway/server.sessions.archive-owner.test.ts b/src/gateway/server.sessions.archive-owner.test.ts new file mode 100644 index 000000000000..a9e5d630356d --- /dev/null +++ b/src/gateway/server.sessions.archive-owner.test.ts @@ -0,0 +1,59 @@ +import { expect, test, vi } from "vitest"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; +import { registerChatAbortController } from "./chat-abort.js"; +import { createChatRunState } from "./server-chat-state.js"; +import { writeSessionStore } from "./test-helpers.js"; +import { + directSessionReq, + sessionStoreEntry, + setupGatewaySessionsHandlerTestHarness, +} from "./test/server-sessions.test-helpers.js"; + +const { createSessionStoreDir } = setupGatewaySessionsHandlerTestHarness(); + +test("archiving a non-default agent ignores the compatibility owner's ownerless run", async () => { + const { storePath } = await createSessionStoreDir(); + const cfg = retainLegacyDefaultAgentId( + { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + session: { store: storePath }, + }, + "ops", + ); + const sessionKey = "agent:research:archive-owner-scope"; + const sessionId = "session-archive-owner-scope"; + await writeSessionStore({ + agentId: "research", + entries: { [sessionKey]: sessionStoreEntry(sessionId) }, + storePath, + }); + + const chatAbortControllers = new Map(); + const compatibilityRun = registerChatAbortController({ + chatAbortControllers, + runId: "run-ops-ownerless", + sessionId, + sessionKey: "legacy-unscoped", + timeoutMs: 60_000, + }); + + const archived = await directSessionReq( + "sessions.patch", + { key: sessionKey, archived: true, expectedSessionId: sessionId }, + { + context: { + agentRunSeq: new Map(), + broadcast: vi.fn(), + cancelRunBoundApprovals: vi.fn(), + chatAbortControllers, + chatRunState: createChatRunState(), + getRuntimeConfig: () => cfg, + nodeSendToSession: vi.fn(), + removeChatRun: vi.fn(), + }, + }, + ); + + expect(archived.ok, JSON.stringify(archived)).toBe(true); + expect(compatibilityRun.controller.signal.aborted).toBe(false); +}); diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 9b738f3fb34d..01bf15d65552 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -54,6 +54,7 @@ import { import { setupGatewaySessionsTestHarness, createCheckpointFixture, + getGatewayConfigModule, sessionStoreEntry, directSessionReq, sessionHookMocks, @@ -414,7 +415,7 @@ test("sessions.create keeps incognito rows process-local through list, spawn, re ok: false, error: { code: "INVALID_REQUEST", - message: "sessions.create key agent (work) does not match agentId (main)", + message: 'agent "main" does not match session key agent "work"', }, }); const durableCollisionKey = "agent:main:dashboard:incognito-durable-collision"; @@ -467,7 +468,7 @@ test("incognito webchat rejects a vanished non-default-agent session before disp agentId: "work", incognito: true, }); - expect(created.ok).toBe(true); + expect(created.ok, JSON.stringify(created)).toBe(true); const sessionKey = requireNonEmptyString(created.payload?.key, "incognito webchat key"); const sessionId = requireNonEmptyString(created.payload?.sessionId, "incognito webchat id"); @@ -3204,6 +3205,58 @@ test("sessions.create preserves global and unknown sentinel keys", async () => { ).toBeUndefined(); }); +test("sessions.create applies configured fixed-store ownership to bare keys", async () => { + const { storePath } = await createSessionStoreDir(); + const broadcastToConnIds = vi.fn(); + testState.agentsConfig = { + ownership: "explicit", + entries: { ops: {}, research: {} }, + }; + testState.agentConfig = { sessionStore: { agentId: "ops" } }; + const { clearConfigCache, clearRuntimeConfigSnapshot } = await getGatewayConfigModule(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + try { + const created = await directSessionReq<{ key?: string; sessionId?: string }>( + "sessions.create", + { key: "global" }, + { + context: { + broadcastToConnIds, + getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), + }, + }, + ); + + expect(created.ok, JSON.stringify(created)).toBe(true); + expect(created.payload?.key).toBe("global"); + expect(loadSessionEntry({ agentId: "ops", sessionKey: "global", storePath })?.sessionId).toBe( + created.payload?.sessionId, + ); + expect(broadcastToConnIds).toHaveBeenCalledWith( + "sessions.changed", + expect.objectContaining({ sessionKey: "global", agentId: "ops", reason: "create" }), + new Set(["conn-1"]), + { dropIfSlow: true, agentId: "ops", sessionKeys: ["global"] }, + ); + + const conflict = await directSessionReq("sessions.create", { + key: "global", + agentId: "research", + }); + expect(conflict).toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + message: 'agent "research" does not match session key agent "ops"', + }, + }); + } finally { + testState.agentsConfig = undefined; + testState.agentConfig = {}; + } +}); + test("sessions.create stores selected global sessions in the requested agent store", async () => { const { mainStorePath, workStorePath } = await createSelectedGlobalSessionStore(); const broadcastToConnIds = vi.fn(); @@ -3322,7 +3375,7 @@ test("sessions.create loads selected global parent from the requested agent stor }); test("sessions.get reads selected global messages from the requested agent store", async () => { - const { mainStorePath, workStorePath } = await createSelectedGlobalSessionStore(); + const { mainStorePath, storeTemplate, workStorePath } = await createSelectedGlobalSessionStore(); try { await writeSessionStore({ storePath: mainStorePath, @@ -3352,12 +3405,23 @@ test("sessions.get reads selected global messages from the requested agent store storePath: workStorePath, }); - const result = await directSessionReq<{ messages?: unknown[] }>("sessions.get", { - key: "global", - agentId: "work", - }); + const result = await directSessionReq<{ messages?: unknown[] }>( + "sessions.get", + { + key: "global", + agentId: "work", + }, + { + context: { + getRuntimeConfig: () => ({ + agents: { entries: { main: {}, work: {} } }, + session: { scope: "global", store: storeTemplate }, + }), + }, + }, + ); - expect(result.ok).toBe(true); + expect(result.ok, JSON.stringify(result)).toBe(true); const renderedMessages = JSON.stringify(result.payload?.messages ?? []); expect(renderedMessages).toContain("work global"); expect(renderedMessages).not.toContain("main global"); diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index 819df3b156d9..5a2af4a1b625 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -107,7 +107,7 @@ function expectChangedBroadcast( expect(event).toBe("sessions.changed"); expect(connIds).toEqual(new Set(["conn-1"])); expect(options).toEqual({ - ...(typeof expected.agentId === "string" ? { agentId: expected.agentId } : {}), + agentId: typeof expected.agentId === "string" ? expected.agentId : "main", dropIfSlow: true, ...(typeof expected.sessionKey === "string" ? { sessionKeys: [expected.sessionKey] } : {}), }); diff --git a/src/gateway/server.sessions.preview-resolve.test.ts b/src/gateway/server.sessions.preview-resolve.test.ts index 266ab67f67d6..dcdc00a123f5 100644 --- a/src/gateway/server.sessions.preview-resolve.test.ts +++ b/src/gateway/server.sessions.preview-resolve.test.ts @@ -153,10 +153,12 @@ test("sessions.resolve returns short-id ambiguity as a protocol-success result", ok: false, candidates: [ { + agentId: "main", key: "agent:main:thread:12345678-0aaa-4000-8000-000000000001", displayName: "Newer", }, { + agentId: "main", key: "agent:main:thread:12345678-0bbb-4000-8000-000000000002", displayName: "Older", }, diff --git a/src/gateway/server.tools-effective.global-agent-gateway.test.ts b/src/gateway/server.tools-effective.global-agent-gateway.test.ts index 8e25f92ba4fc..5ebff1ec341a 100644 --- a/src/gateway/server.tools-effective.global-agent-gateway.test.ts +++ b/src/gateway/server.tools-effective.global-agent-gateway.test.ts @@ -35,7 +35,7 @@ test("tools.effective rejects a mismatched configured agent for a non-global ses expect(res.ok).toBe(false); expect(res.error).toEqual({ code: ErrorCodes.INVALID_REQUEST, - message: 'agent id "work" does not match session agent "main"', + message: 'agent "work" does not match session key agent "main"', }); } finally { ws.close(); diff --git a/src/gateway/server/health-state.test.ts b/src/gateway/server/health-state.test.ts index b32d27ccbad1..122e02b7e768 100644 --- a/src/gateway/server/health-state.test.ts +++ b/src/gateway/server/health-state.test.ts @@ -6,17 +6,27 @@ import type { HealthSummary } from "../health/types.js"; /** * Health-state cache tests covering coalescing, sensitive probes, and broadcasts. */ -const { collectGatewayHealthSnapshotMock, getUpdateAvailableMock, getUpdateScheduleMock } = - vi.hoisted(() => ({ - collectGatewayHealthSnapshotMock: vi.fn(), - getUpdateAvailableMock: vi.fn(), - getUpdateScheduleMock: vi.fn(), - })); +const { + collectGatewayHealthSnapshotMock, + getRuntimeConfigMock, + getUpdateAvailableMock, + getUpdateScheduleMock, +} = vi.hoisted(() => ({ + collectGatewayHealthSnapshotMock: vi.fn(), + getRuntimeConfigMock: vi.fn(), + getUpdateAvailableMock: vi.fn(), + getUpdateScheduleMock: vi.fn(), +})); vi.mock("../health/collector.js", () => ({ collectGatewayHealthSnapshot: collectGatewayHealthSnapshotMock, })); +vi.mock("../../config/io.js", async (importOriginal) => ({ + ...(await importOriginal()), + getRuntimeConfig: getRuntimeConfigMock, +})); + vi.mock("../../infra/update-startup.js", () => ({ getUpdateAvailable: getUpdateAvailableMock, getUpdateSchedule: getUpdateScheduleMock, @@ -61,6 +71,7 @@ async function loadHealthState() { getUpdateAvailableMock.mockReturnValue(null); getUpdateScheduleMock.mockReset(); getUpdateScheduleMock.mockReturnValue(null); + getRuntimeConfigMock.mockReset().mockReturnValue({ agents: { entries: { main: {} } } }); return await import("./health-state.js"); } @@ -103,6 +114,7 @@ describe("buildGatewaySnapshot update metadata", () => { channel: "dev", }); expect(snapshot.updateSchedule).toBeUndefined(); + expect(snapshot.sessionDefaults).toMatchObject({ ownership: "sole", selectionRequired: false }); expect(getUpdateScheduleMock).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index b4e3df0204bc..ffb501978e3c 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -1,13 +1,13 @@ // Gateway health state builds snapshots, caches health probes, and broadcasts health/presence version changes. import type { Snapshot } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { createConfigIO, getRuntimeConfig } from "../../config/io.js"; import { STATE_DIR } from "../../config/paths.js"; import { getRuntimeConfigAppliedHash } from "../../config/runtime-snapshot.js"; -import { resolveMainSessionKey } from "../../config/sessions.js"; +import { resolveAgentMainSessionKey } from "../../config/sessions.js"; import { listSystemPresence } from "../../infra/system-presence.js"; import { getUpdateAvailable, getUpdateSchedule } from "../../infra/update-startup.js"; import { normalizeMainKey } from "../../routing/session-key.js"; +import { resolveGatewayAgentSelectionState } from "../agent-list.js"; import { resolveGatewayAuth } from "../auth.js"; import type { GatewayHotReloadStatus } from "../config-reload-status.types.js"; import { projectUpdateAvailable } from "../events.js"; @@ -51,10 +51,12 @@ export function buildGatewaySnapshot(opts?: { includeUpdateDetails?: boolean; }): Snapshot { const cfg = getRuntimeConfig(); - const defaultAgentId = resolveDefaultAgentId(cfg); + const selection = resolveGatewayAgentSelectionState(cfg); + const defaultAgentId = selection.defaultId; const mainKey = normalizeMainKey(cfg.session?.mainKey); - const mainSessionKey = resolveMainSessionKey(cfg); const scope = cfg.session?.scope ?? "per-sender"; + const mainSessionKey = + scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: defaultAgentId }); const presence = listSystemPresence(); const uptimeMs = Math.round(process.uptime() * 1000); const includeUpdateDetails = opts?.includeUpdateDetails === true; @@ -71,6 +73,8 @@ export function buildGatewaySnapshot(opts?: { appliedConfigHash: getRuntimeConfigAppliedHash(), sessionDefaults: { defaultAgentId, + ownership: selection.ownership, + selectionRequired: selection.selectionRequired, mainKey, mainSessionKey, scope, diff --git a/src/gateway/server/hooks-request-handler.ts b/src/gateway/server/hooks-request-handler.ts index 7bfb35fb6b8f..c3f24a947dd1 100644 --- a/src/gateway/server/hooks-request-handler.ts +++ b/src/gateway/server/hooks-request-handler.ts @@ -14,6 +14,7 @@ import { applyHookMappings } from "../hooks-mapping.js"; import { extractHookToken, getHookAgentPolicyError, + getHookAgentSelectionError, getHookChannelError, getHookSessionKeyPrefixError, type HookAgentDispatchPayload, @@ -49,12 +50,10 @@ export type HookClientIpConfig = Readonly<{ export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; type HookDispatchers = { - dispatchWakeHook: (value: { - text: string; - mode: "now" | "next-heartbeat"; - agentId?: string; - sessionKey?: string; - }) => void; + dispatchWakeHook: ( + value: { text: string; mode: "now" | "next-heartbeat"; sessionKey?: string }, + agentId: string, + ) => void; dispatchAgentHook: ( value: HookAgentDispatchPayload, ) => HookAgentDispatchResult | Promise; @@ -325,7 +324,19 @@ export function createHooksRequestHandler( sendJson(res, 400, { ok: false, error: normalized.error }); return true; } - dispatchWakeHook(normalized.value); + if (!isHookAgentAllowed(hooksConfig, normalized.value.agentId)) { + sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() }); + return true; + } + const targetAgentId = resolveEffectiveHookTargetAgentId( + hooksConfig, + normalized.value.agentId, + ); + if (!targetAgentId) { + sendJson(res, 400, { ok: false, error: getHookAgentSelectionError() }); + return true; + } + dispatchWakeHook(normalized.value, targetAgentId); sendJson(res, 200, { ok: true, mode: normalized.value.mode }); return true; } @@ -372,6 +383,10 @@ export function createHooksRequestHandler( hooksConfig, normalized.value.agentId, ); + if (!effectiveTargetAgentId) { + sendJson(res, 400, { ok: false, error: getHookAgentSelectionError() }); + return true; + } const replayKey = buildHookReplayCacheKey({ pathKey: "agent", token, @@ -408,6 +423,7 @@ export function createHooksRequestHandler( const dispatched = await dispatchAgentHookWithReplay(replayKey, now, () => dispatchAgentHook({ ...normalized.value, + effectiveAgentId: effectiveTargetAgentId, idempotencyKey, sessionKey: dispatchSessionKey, sourcePath: `${basePath}/agent`, @@ -439,38 +455,41 @@ export function createHooksRequestHandler( } if (mapped.action.kind === "wake") { const action = mapped.action; - let targetAgentId: string | undefined; + if (!isHookAgentAllowed(hooksConfig, action.agentId)) { + sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() }); + return true; + } + const targetAgentId = resolveEffectiveHookTargetAgentId(hooksConfig, action.agentId); + if (!targetAgentId) { + sendJson(res, 400, { ok: false, error: getHookAgentSelectionError() }); + return true; + } let dispatchSessionKey: string | undefined; - if (action.agentId || action.sessionKey) { - if (!isHookAgentAllowed(hooksConfig, action.agentId)) { - sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() }); + if (action.sessionKey) { + const sessionKey = resolveHookSessionKey({ + hooksConfig, + source: + action.sessionKeySource === "static" ? "mapping-static" : "mapping-templated", + sessionKey: action.sessionKey, + }); + if (!sessionKey.ok) { + sendJson(res, 400, { ok: false, error: sessionKey.error }); return true; } - targetAgentId = resolveEffectiveHookTargetAgentId(hooksConfig, action.agentId); - if (action.sessionKey) { - const sessionKey = resolveHookSessionKey({ - hooksConfig, - source: - action.sessionKeySource === "static" ? "mapping-static" : "mapping-templated", - sessionKey: action.sessionKey, - }); - if (!sessionKey.ok) { - sendJson(res, 400, { ok: false, error: sessionKey.error }); - return true; - } - dispatchSessionKey = - resolveDispatchSessionKeyOrRespond(sessionKey.value, targetAgentId) ?? undefined; - if (!dispatchSessionKey) { - return true; - } + dispatchSessionKey = + resolveDispatchSessionKeyOrRespond(sessionKey.value, targetAgentId) ?? undefined; + if (!dispatchSessionKey) { + return true; } } - dispatchWakeHook({ - text: action.text, - mode: action.mode, - ...(targetAgentId ? { agentId: targetAgentId } : {}), - ...(dispatchSessionKey ? { sessionKey: dispatchSessionKey } : {}), - }); + dispatchWakeHook( + { + text: action.text, + mode: action.mode, + ...(dispatchSessionKey ? { sessionKey: dispatchSessionKey } : {}), + }, + targetAgentId, + ); sendJson(res, 200, { ok: true, mode: action.mode }); return true; } @@ -518,6 +537,10 @@ export function createHooksRequestHandler( hooksConfig, action.agentId, ); + if (!effectiveTargetAgentId) { + sendJson(res, 400, { ok: false, error: getHookAgentSelectionError() }); + return true; + } const dispatchSessionKey = resolveDispatchSessionKeyOrRespond( sessionKey.value, effectiveTargetAgentId, @@ -555,6 +578,7 @@ export function createHooksRequestHandler( name: action.name ?? "Hook", idempotencyKey, agentId: targetAgentId, + effectiveAgentId: effectiveTargetAgentId, wakeMode: action.wakeMode, sessionKey: dispatchSessionKey, sessionMode: action.sessionMode, diff --git a/src/gateway/server/hooks.agent-trust.test.ts b/src/gateway/server/hooks.agent-trust.test.ts index 5805b3ac56f4..b1d98523a399 100644 --- a/src/gateway/server/hooks.agent-trust.test.ts +++ b/src/gateway/server/hooks.agent-trust.test.ts @@ -17,7 +17,7 @@ const requestHeartbeatMock = vi.fn(); const runCronIsolatedAgentTurnMock = vi.fn(); const resolveMainSessionKeyMock = vi.fn(() => "main-session"); const mainRosterConfig = (): OpenClawConfig => ({ - agents: { entries: { main: { default: true } } }, + agents: { entries: { main: {} } }, }); const loadConfigMock = vi.fn(mainRosterConfig); const logHooksInfoMock = vi.fn(); @@ -108,6 +108,7 @@ function buildAgentPayload(name: string, agentId?: string) { message: "test message", name, agentId, + effectiveAgentId: agentId ?? "main", idempotencyKey: undefined, wakeMode: "now" as const, sessionKey: "session-1", @@ -128,11 +129,11 @@ function dispatchAgentHook(payload: unknown): unknown { return resolveDispatchAgentHook()(payload); } -function dispatchWakeHook(payload: unknown): unknown { +function dispatchWakeHook(payload: unknown, agentId: string): unknown { if (!capturedDispatchWakeHook) { throw new Error("dispatchWakeHook missing"); } - return capturedDispatchWakeHook(payload); + return capturedDispatchWakeHook(payload, agentId); } function resolveDispatchAgentHook(): (...args: unknown[]) => unknown { @@ -202,12 +203,14 @@ describe("dispatchAgentHook trust handling", () => { session: { scope: "global" }, }); - dispatchWakeHook({ - text: "Mapped wake", - mode: "now", - agentId: "hooks", - sessionKey: "hook:mapped", - }); + dispatchWakeHook( + { + text: "Mapped wake", + mode: "now", + sessionKey: "hook:mapped", + }, + "hooks", + ); expectOwnedSystemEvent("Mapped wake", "hooks"); expect(requestHeartbeatMock).toHaveBeenCalledWith({ @@ -990,7 +993,7 @@ describe("dispatchAgentHook trust handling", () => { expect(failureWake.sessionKey).toBeUndefined(); }); - it("carries the explicit agent on the recovered global failure wake when the initial key is absent", async () => { + it("carries the config-resolved agent on a recovered global failure wake", async () => { // Early config resolution fails before the event key resolves, so // hookEventSessionKey is absent; recovery still yields the unscoped // "global" sentinel. The failure wake must reuse the recovered key and @@ -1000,7 +1003,10 @@ describe("dispatchAgentHook trust handling", () => { }); resolveMainSessionKeyMock.mockReturnValueOnce("global").mockReturnValueOnce("global"); - const result = await dispatchAgentHook(buildAgentPayload("Config", "hooks")); + const result = await dispatchAgentHook({ + ...buildAgentPayload("Config"), + effectiveAgentId: "hooks", + }); expect(result).toMatchObject({ ok: false, diff --git a/src/gateway/server/hooks.terminal-target.test.ts b/src/gateway/server/hooks.terminal-target.test.ts index 18df71ca075b..030387f6acf4 100644 --- a/src/gateway/server/hooks.terminal-target.test.ts +++ b/src/gateway/server/hooks.terminal-target.test.ts @@ -37,6 +37,7 @@ type HookPayload = { message: string; name: string; agentId?: string; + effectiveAgentId: string; wakeMode: "now" | "next-heartbeat"; sessionKey: string; sourcePath: string; @@ -49,6 +50,7 @@ function payload(overrides: Partial = {}): HookPayload { return { message: "test message", name: "Email", + effectiveAgentId: "main", wakeMode: "now", sessionKey: "session-1", sourcePath: "/hooks/agent", diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 428dc4f0cd5c..a9b877c9cd38 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -6,7 +6,7 @@ import { } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listAgentIds } from "../../agents/agent-scope.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import type { CliDeps } from "../../cli/deps.types.js"; import { getRuntimeConfig } from "../../config/io.js"; @@ -256,33 +256,23 @@ export function createGatewayHooksRequestHandler(params: { const loadIsolatedAgentModule = () => (isolatedAgentModulePromise ??= import("../../cron/isolated-agent.js")); - const dispatchWakeHook = (value: { - text: string; - mode: "now" | "next-heartbeat"; - agentId?: string; - sessionKey?: string; - }) => { - const targeted = Boolean(value.agentId || value.sessionKey); + const dispatchWakeHook = ( + value: { text: string; mode: "now" | "next-heartbeat"; sessionKey?: string }, + agentId: string, + ) => { // A targeted wake must enqueue and wake the same canonical store key; // otherwise the heartbeat runs for one agent while its event waits elsewhere. - const target = targeted - ? (() => { - const cfg = getRuntimeConfig(); - const agentId = value.agentId ?? resolveDefaultAgentId(cfg); - return resolveHookEventTarget({ - cfg, - resolvedAgentId: agentId, - explicitAgentId: value.agentId, - sessionKey: value.sessionKey, - }); - })() - : undefined; - const sessionKey = target?.eventSessionKey ?? resolveMainSessionKeyFromConfig(); + const target = resolveHookEventTarget({ + cfg: getRuntimeConfig(), + resolvedAgentId: agentId, + sessionKey: value.sessionKey, + }); + const sessionKey = target.eventSessionKey; const eventOptions = { sessionKey }; enqueueSystemEvent( value.text, - isUnscopedSessionKeySentinel(sessionKey) && target?.heartbeatTarget.agentId - ? withSystemEventOwner(eventOptions, target.heartbeatTarget.agentId) + isUnscopedSessionKeySentinel(sessionKey) + ? withSystemEventOwner(eventOptions, agentId) : eventOptions, ); if (value.mode === "now") { @@ -290,7 +280,7 @@ export function createGatewayHooksRequestHandler(params: { source: "hook", intent: "immediate", reason: "hook:wake", - ...target?.heartbeatTarget, + ...target.heartbeatTarget, }); } }; @@ -308,7 +298,7 @@ export function createGatewayHooksRequestHandler(params: { const nowMs = resolveDateTimestampMs(Date.now()); const job: CronJob = { id: jobId, - agentId: value.agentId, + agentId: value.effectiveAgentId, name: safeName, enabled: true, createdAtMs: nowMs, @@ -364,9 +354,7 @@ export function createGatewayHooksRequestHandler(params: { hookEventTarget?.heartbeatTarget ?? (isGlobalEvent ? { - agentId: - normalizeOptionalString(value.agentId) ?? - resolveDefaultAgentId(getRuntimeConfig()), + agentId: value.effectiveAgentId, } : { sessionKey: eventSessionKey }); } @@ -405,7 +393,7 @@ export function createGatewayHooksRequestHandler(params: { runId, }; } - const agentId = acceptedValue.agentId ?? resolveDefaultAgentId(dispatchCfg); + const agentId = acceptedValue.effectiveAgentId; const queueKey = resolveCronAgentSessionKey({ sessionKey, agentId, diff --git a/src/gateway/session-automation-index.ts b/src/gateway/session-automation-index.ts index 9d6548dcc3d6..6a0cac93f587 100644 --- a/src/gateway/session-automation-index.ts +++ b/src/gateway/session-automation-index.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveCronJobBoundSessionKeys } from "../cron/job-session-bindings.js"; import type { CronJob } from "../cron/types.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; type SessionAutomationSource = { /** Current in-memory cron jobs; undefined until the cron store is loaded. */ @@ -77,14 +78,23 @@ function buildAutomationKeys( continue; } for (const key of resolveCronJobBoundSessionKeys(job, { cfg, defaultAgentId })) { - keys.add(key); + const agentId = job.owner?.agentId ?? defaultAgentId; + if (parseAgentSessionKey(key)) { + keys.add(key); + } else if (agentId) { + keys.add(`${normalizeAgentId(agentId)}\0${key}`); + } } } return keys; } /** True when an enabled cron job is bound to the canonical session key. */ -export function sessionHasAutomation(sessionKey: string, cfg: OpenClawConfig): boolean { +export function sessionHasAutomation( + sessionKey: string, + cfg: OpenClawConfig, + agentId?: string, +): boolean { const jobs = source?.getJobs(); if (!source || !jobs || jobs.length === 0) { return false; @@ -97,5 +107,10 @@ export function sessionHasAutomation(sessionKey: string, cfg: OpenClawConfig): b keys: buildAutomationKeys(jobs, cfg, source.getDefaultAgentId()), }; } - return memo.keys.has(sessionKey); + const identity = parseAgentSessionKey(sessionKey) + ? sessionKey + : agentId + ? `${normalizeAgentId(agentId)}\0${sessionKey}` + : undefined; + return identity ? memo.keys.has(identity) : false; } diff --git a/src/gateway/session-compaction-checkpoints.ts b/src/gateway/session-compaction-checkpoints.ts index a7322f148a3a..41b91802059d 100644 --- a/src/gateway/session-compaction-checkpoints.ts +++ b/src/gateway/session-compaction-checkpoints.ts @@ -92,6 +92,7 @@ type RestoreCheckpointSessionParams = { type PersistSessionCompactionCheckpointParams = { cfg: OpenClawConfig; + agentId?: string; sessionKey: string; sessionId: string; reason: SessionCompactionCheckpointReason; @@ -722,6 +723,7 @@ async function persistSessionCompactionCheckpoint( const target = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), }); const createdAt = params.createdAt ?? Date.now(); const checkpoint: SessionCompactionCheckpoint = { diff --git a/src/gateway/session-companion-ask.ts b/src/gateway/session-companion-ask.ts index 11d32594ff56..dd57923434f3 100644 --- a/src/gateway/session-companion-ask.ts +++ b/src/gateway/session-companion-ask.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { SessionCompanionExchange } from "../../packages/gateway-protocol/src/schema/sessions.js"; import { prepareSystemAgentRunAdmission } from "../agents/admitted-run-context.js"; -import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolveSimpleCompletionSelectionForAgent } from "../agents/simple-completion-runtime.js"; import { resolveUtilityModelRefForAgent } from "../agents/utility-model.js"; import { resolveSessionStorePathCore } from "../config/sessions.js"; @@ -20,6 +20,7 @@ import { type SessionCompanionThread, } from "./session-companion-state.js"; import type { SessionObserverCompanionSnapshot } from "./session-observer-contract.js"; +import { sessionObserverScopeKey } from "./session-observer-model.js"; const companionLog = createSubsystemLogger("gateway/session-companion"); @@ -51,7 +52,10 @@ type SessionCompanionRunParams = { export type SessionCompanionAskDeps = { getConfig: () => OpenClawConfig; sessionObserver: { - getCompanionSnapshot: (sessionKey: string) => SessionObserverCompanionSnapshot; + getCompanionSnapshot: ( + sessionKey: string, + agentId?: string, + ) => SessionObserverCompanionSnapshot; }; resolveUtilityModelRef?: typeof resolveUtilityModelRefForAgent; contextReader: SessionCompanionContextReader; @@ -359,10 +363,9 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt const activeAsks = new Map(); const admissions: Array<{ connId: string; admittedAt: number }> = []; - const resolveTarget = (sessionKey: string) => { + const resolveTarget = (sessionKey: string, agentId: string) => { const cfg = params.getConfig(); - const observerSnapshot = params.sessionObserver.getCompanionSnapshot(sessionKey); - const agentId = observerSnapshot.agentId || resolveSessionAgentId({ sessionKey, config: cfg }); + const observerSnapshot = params.sessionObserver.getCompanionSnapshot(sessionKey, agentId); return { agentId, cfg, observerSnapshot }; }; @@ -371,10 +374,12 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt const prepareThread = async ( sessionKey: string, + agentId: string, signal: AbortSignal, ): Promise => { - const existing = params.threads.get(sessionKey); - const { agentId, observerSnapshot } = resolveTarget(sessionKey); + const threadKey = sessionObserverScopeKey(sessionKey, agentId); + const existing = params.threads.get(threadKey); + const { observerSnapshot } = resolveTarget(sessionKey, agentId); if (signal.aborted) { throw new Error("session companion preparation was cancelled"); } @@ -382,7 +387,7 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt return existing; } if (existing) { - params.threads.delete(sessionKey); + params.threads.delete(threadKey); } const result = await contextReader.read({ agentId, sessionKey, signal }); if (signal.aborted || params.isDisposed()) { @@ -411,23 +416,26 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt busy: false, lastUsedAt: params.now(), }; - params.threads.set(sessionKey, thread); + params.threads.set(threadKey, thread); return thread; }; const ask = async (request: { + agentId: string; sessionKey: string; question: string; connId: string; signal?: AbortSignal; }): Promise<{ answer: string; ts: number }> => { const sessionKey = request.sessionKey.trim(); + const agentId = request.agentId.trim(); const question = request.question.trim(); - if (!sessionKey || !question || params.isDisposed() || request.signal?.aborted) { + if (!sessionKey || !agentId || !question || params.isDisposed() || request.signal?.aborted) { throw new SessionCompanionAskError("unavailable", "Session companion is unavailable."); } - const existing = params.threads.get(sessionKey); - if (existing?.busy || activeAsks.has(sessionKey)) { + const threadKey = sessionObserverScopeKey(sessionKey, agentId); + const existing = params.threads.get(threadKey); + if (existing?.busy || activeAsks.has(threadKey)) { throw new SessionCompanionAskError( "busy", "The session companion is answering another question.", @@ -471,9 +479,9 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt admissions.push({ connId: request.connId, admittedAt }); const controller = new AbortController(); const activeAsk: SessionCompanionActiveAsk = { controller }; - activeAsks.set(sessionKey, activeAsk); + activeAsks.set(threadKey, activeAsk); const abort = (cancellation: SessionCompanionCancellationKind) => { - if (activeAsks.get(sessionKey) !== activeAsk || activeAsk.cancellation) { + if (activeAsks.get(threadKey) !== activeAsk || activeAsk.cancellation) { return; } activeAsk.cancellation = cancellation; @@ -495,12 +503,12 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt }); let ownedThread: SessionCompanionThread | undefined; const discardOwnedThread = () => { - if (ownedThread && params.threads.get(sessionKey) === ownedThread) { - params.threads.delete(sessionKey); + if (ownedThread && params.threads.get(threadKey) === ownedThread) { + params.threads.delete(threadKey); } }; try { - const thread = await prepareThread(sessionKey, controller.signal); + const thread = await prepareThread(sessionKey, agentId, controller.signal); ownedThread = thread; if (thread.busy) { throw new SessionCompanionAskError( @@ -510,9 +518,9 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt } thread.busy = true; thread.lastUsedAt = admittedAt; - const { agentId, cfg } = resolveTarget(sessionKey); + const { cfg } = resolveTarget(sessionKey, agentId); if (currentSessionId(sessionKey, agentId) !== thread.context.sessionId) { - params.threads.delete(sessionKey); + params.threads.delete(threadKey); throw contextError( "context-unavailable", "The selected session changed before the companion could answer.", @@ -526,7 +534,7 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt ); } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const currentSnapshot = params.sessionObserver.getCompanionSnapshot(sessionKey); + const currentSnapshot = params.sessionObserver.getCompanionSnapshot(sessionKey, agentId); thread.digestText = formatObserverDigest(currentSnapshot); const delta = selectDeltaNotes(currentSnapshot, thread.lastNoteSequence); const referenceContext = buildReferenceContext({ @@ -563,7 +571,7 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt throw new Error("session companion ask was cancelled"); } if ( - params.threads.get(sessionKey) !== thread || + params.threads.get(threadKey) !== thread || currentSessionId(sessionKey, agentId) !== thread.context.sessionId ) { discardOwnedThread(); @@ -606,10 +614,10 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt } finally { clearTimeoutFn(timeout); request.signal?.removeEventListener("abort", abortRequest); - if (activeAsks.get(sessionKey) === activeAsk) { - activeAsks.delete(sessionKey); + if (activeAsks.get(threadKey) === activeAsk) { + activeAsks.delete(threadKey); } - if (ownedThread && params.threads.get(sessionKey) === ownedThread) { + if (ownedThread && params.threads.get(threadKey) === ownedThread) { ownedThread.busy = false; } } @@ -619,12 +627,13 @@ export function createSessionCompanionAskRuntime(params: SessionCompanionAskRunt ask, cancel( sessionKey: string, + agentId: string, cancellation: Extract< SessionCompanionCancellationKind, "backing-session-revoked" | "explicit-reset" >, ) { - const activeAsk = activeAsks.get(sessionKey); + const activeAsk = activeAsks.get(sessionObserverScopeKey(sessionKey, agentId)); if (!activeAsk || activeAsk.cancellation) { return; } diff --git a/src/gateway/session-companion-rpc.test.ts b/src/gateway/session-companion-rpc.test.ts index a1c2e84649f5..7cfd587c365d 100644 --- a/src/gateway/session-companion-rpc.test.ts +++ b/src/gateway/session-companion-rpc.test.ts @@ -13,12 +13,13 @@ async function invoke( }, client: { connId?: string } = { connId: "conn-1" }, signal?: AbortSignal, + config: Record = { agents: { list: [{ id: "main" }] } }, ) { const respond = vi.fn(); await sessionCompanionHandlers[method]?.({ params, client, - context: { sessionCompanion: companion }, + context: { sessionCompanion: companion, getRuntimeConfig: () => config }, respond, signal, } as never); @@ -35,6 +36,7 @@ describe("session companion RPC", () => { ); expect(ask).toHaveBeenCalledWith({ + agentId: "main", sessionKey: "agent:main:main", question: "What is happening?", connId: "conn-1", @@ -57,6 +59,7 @@ describe("session companion RPC", () => { ); expect(ask).toHaveBeenCalledWith({ + agentId: "main", sessionKey: "agent:main:main", question: "Who owns this ask?", connId: "conn-1", @@ -149,7 +152,7 @@ describe("session companion RPC", () => { { sessionKey: "agent:main:main" }, { state }, ); - expect(state).toHaveBeenCalledWith("agent:main:main"); + expect(state).toHaveBeenCalledWith({ agentId: "main", sessionKey: "agent:main:main" }); expect(respond).toHaveBeenCalledWith(true, { exchanges: [{ question: "Why?", answer: "Because.", ts: 10 }], }); @@ -169,7 +172,7 @@ describe("session companion RPC", () => { { sessionKey: "agent:main:main" }, { reset }, ); - expect(reset).toHaveBeenCalledWith("agent:main:main"); + expect(reset).toHaveBeenCalledWith({ agentId: "main", sessionKey: "agent:main:main" }); expect(respond).toHaveBeenCalledWith(true, { ok: true }); const invalid = await invoke( @@ -183,4 +186,35 @@ describe("session companion RPC", () => { expect.objectContaining({ code: "INVALID_REQUEST" }), ); }); + + it("threads an explicit owner for a bare key and returns typed selection errors", async () => { + const config = { agents: { ownership: "explicit", list: [{ id: "main" }, { id: "work" }] } }; + const state = vi.fn(() => ({ exchanges: [] })); + const selected = await invoke( + "sessions.companion.state", + { sessionKey: "global", agentId: "work" }, + { state }, + undefined, + undefined, + config, + ); + expect(state).toHaveBeenCalledWith({ agentId: "work", sessionKey: "global" }); + expect(selected).toHaveBeenCalledWith(true, { exchanges: [] }); + + state.mockClear(); + const ambiguous = await invoke( + "sessions.companion.state", + { sessionKey: "global" }, + { state }, + undefined, + undefined, + config, + ); + expect(state).not.toHaveBeenCalled(); + expect(ambiguous).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + }); }); diff --git a/src/gateway/session-companion-rpc.ts b/src/gateway/session-companion-rpc.ts index 0b4f1e0d2927..61544c3aa562 100644 --- a/src/gateway/session-companion-rpc.ts +++ b/src/gateway/session-companion-rpc.ts @@ -12,6 +12,28 @@ import { } from "../../packages/gateway-protocol/src/index.js"; import type { GatewayRequestHandlers } from "./server-methods/types.js"; import { SessionCompanionAskError } from "./session-companion-ask.js"; +import { resolveRequestedSessionAgentId } from "./session-request-agent.js"; +import { resolveSessionStoreKey } from "./session-store-key.js"; + +function resolveCompanionTarget( + params: { sessionKey: string; agentId?: string | undefined }, + context: Parameters[0]["context"], +) { + const cfg = context.getRuntimeConfig(); + const requested = resolveRequestedSessionAgentId(cfg, params.sessionKey, params.agentId); + if (!requested.ok) { + return requested; + } + return { + ok: true as const, + agentId: requested.agentId, + sessionKey: resolveSessionStoreKey({ + cfg, + sessionKey: params.sessionKey, + storeAgentId: requested.agentId, + }), + }; +} export const sessionCompanionHandlers: GatewayRequestHandlers = { "sessions.companion.ask": async ({ params, respond, client, context, signal }) => { @@ -26,7 +48,7 @@ export const sessionCompanionHandlers: GatewayRequestHandlers = { ); return; } - const { sessionKey, question } = params as SessionsCompanionAskParams; + const { sessionKey, agentId, question } = params as SessionsCompanionAskParams; if (!question.trim()) { respond( false, @@ -51,9 +73,15 @@ export const sessionCompanionHandlers: GatewayRequestHandlers = { ); return; } + const target = resolveCompanionTarget({ sessionKey, agentId }, context); + if (!target.ok) { + respond(false, undefined, target.error); + return; + } try { const result = await context.sessionCompanion.ask({ - sessionKey, + sessionKey: target.sessionKey, + agentId: target.agentId, question, connId: client.connId, ...(signal ? { signal } : {}), @@ -111,8 +139,19 @@ export const sessionCompanionHandlers: GatewayRequestHandlers = { ); return; } - const { sessionKey } = params as SessionsCompanionStateParams; - respond(true, context.sessionCompanion.state(sessionKey)); + const { sessionKey, agentId } = params as SessionsCompanionStateParams; + const target = resolveCompanionTarget({ sessionKey, agentId }, context); + if (!target.ok) { + respond(false, undefined, target.error); + return; + } + respond( + true, + context.sessionCompanion.state({ + agentId: target.agentId, + sessionKey: target.sessionKey, + }), + ); }, "sessions.companion.reset": ({ params, respond, context }) => { @@ -135,8 +174,16 @@ export const sessionCompanionHandlers: GatewayRequestHandlers = { ); return; } - const { sessionKey } = params as SessionsCompanionResetParams; - context.sessionCompanion.reset(sessionKey); + const { sessionKey, agentId } = params as SessionsCompanionResetParams; + const target = resolveCompanionTarget({ sessionKey, agentId }, context); + if (!target.ok) { + respond(false, undefined, target.error); + return; + } + context.sessionCompanion.reset({ + agentId: target.agentId, + sessionKey: target.sessionKey, + }); respond(true, { ok: true }); }, }; diff --git a/src/gateway/session-companion.test.ts b/src/gateway/session-companion.test.ts index c19419a29f76..818468bf9882 100644 --- a/src/gateway/session-companion.test.ts +++ b/src/gateway/session-companion.test.ts @@ -84,6 +84,7 @@ describe("session companion asks", () => { await expect( harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Why is it reading that file?", connId: "conn-1", @@ -108,7 +109,9 @@ describe("session companion asks", () => { ]); expect(call?.messages[0]?.content).toContain("Headline: Running tests"); expect(call?.messages[0]?.content).toContain("Tool: read package.json"); - expect(harness.service.state("agent:main:main").exchanges).toEqual([ + expect( + harness.service.state({ agentId: "main", sessionKey: "agent:main:main" }).exchanges, + ).toEqual([ { question: "Why is it reading that file?", answer: "Evidence says the build is green.", @@ -133,6 +136,7 @@ describe("session companion asks", () => { }); await harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "What happened?", connId: "conn-1", @@ -168,6 +172,7 @@ describe("session companion asks", () => { const unavailable = await harness.service .ask({ + agentId: "main", sessionKey: "agent:main:main", question: "What recovered?", connId: "conn-1", @@ -179,6 +184,7 @@ describe("session companion asks", () => { await expect( harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "What recovered?", connId: "conn-1", @@ -200,6 +206,7 @@ describe("session companion asks", () => { }); await expect( empty.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "What is in the project?", connId: "conn-1", @@ -216,6 +223,7 @@ describe("session companion asks", () => { }); const missingError = await missing.service .ask({ + agentId: "main", sessionKey: "agent:main:main", question: "What happened?", connId: "conn-1", @@ -234,6 +242,7 @@ describe("session companion asks", () => { }); await expect( wrapper.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Return the first message.", connId: "conn-1", @@ -241,7 +250,9 @@ describe("session companion asks", () => { ).rejects.toMatchObject({ reason: "unavailable", } satisfies Partial); - expect(wrapper.service.state("agent:main:main")).toEqual({ exchanges: [] }); + expect(wrapper.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ + exchanges: [], + }); wrapper.service.dispose(); const legitimate = createHarness({ @@ -253,6 +264,7 @@ describe("session companion asks", () => { }); await expect( legitimate.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Return JSON with these exact field names.", connId: "conn-1", @@ -281,6 +293,7 @@ describe("session companion asks", () => { run: async () => (runCount++ === 0 ? await pending.promise : "fresh answer"), }); const active = harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Which session?", connId: "conn-1", @@ -292,10 +305,13 @@ describe("session companion asks", () => { await expect(active).rejects.toMatchObject({ reason: "context-unavailable", } satisfies Partial); - expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] }); + expect(harness.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ + exchanges: [], + }); await expect( harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Which session now?", connId: "conn-1", @@ -310,6 +326,7 @@ describe("session companion asks", () => { const pending = deferred(); const harness = createHarness({ run: async () => await pending.promise }); const first = harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "First?", connId: "conn-1", @@ -318,6 +335,7 @@ describe("session companion asks", () => { await expect( harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Second?", connId: "conn-2", @@ -329,11 +347,44 @@ describe("session companion asks", () => { harness.service.dispose(); }); + it("isolates the same bare session key by owning agent", async () => { + vi.useFakeTimers(); + const harness = createHarness(); + await harness.service.ask({ + agentId: "main", + sessionKey: "global", + question: "Main?", + connId: "conn-main", + }); + await harness.service.ask({ + agentId: "work", + sessionKey: "global", + question: "Work?", + connId: "conn-work", + }); + + expect(harness.service.state({ agentId: "main", sessionKey: "global" }).exchanges).toEqual([ + expect.objectContaining({ question: "Main?" }), + ]); + expect(harness.service.state({ agentId: "work", sessionKey: "global" }).exchanges).toEqual([ + expect.objectContaining({ question: "Work?" }), + ]); + harness.service.reset({ agentId: "main", sessionKey: "global" }); + expect(harness.service.state({ agentId: "main", sessionKey: "global" })).toEqual({ + exchanges: [], + }); + expect(harness.service.state({ agentId: "work", sessionKey: "global" }).exchanges).toHaveLength( + 1, + ); + harness.service.dispose(); + }); + it("enforces the per-connection rate window", async () => { vi.useFakeTimers(); const harness = createHarness(); for (let index = 0; index < 4; index += 1) { await harness.service.ask({ + agentId: "main", sessionKey: `agent:main:session-${index}`, question: `Question ${index}?`, connId: "conn-1", @@ -341,6 +392,7 @@ describe("session companion asks", () => { } await expect( harness.service.ask({ + agentId: "main", sessionKey: "agent:main:session-5", question: "One too many?", connId: "conn-1", @@ -358,6 +410,7 @@ describe("session companion asks", () => { const harness = createHarness(); for (let index = 0; index < 12; index += 1) { await harness.service.ask({ + agentId: "main", sessionKey: `agent:main:global-${index}`, question: `Question ${index}?`, connId: `conn-${index}`, @@ -365,6 +418,7 @@ describe("session companion asks", () => { } await expect( harness.service.ask({ + agentId: "main", sessionKey: "agent:main:global-overflow", question: "One too many globally?", connId: "conn-overflow", @@ -383,6 +437,7 @@ describe("session companion asks", () => { snapshot: () => ({ agentId: "main", notes }), }); await harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "First?", connId: "conn-1", @@ -393,6 +448,7 @@ describe("session companion asks", () => { { sequence: 3, text: "third note" }, ]; await harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Second?", connId: "conn-2", @@ -435,6 +491,7 @@ describe("session companion asks", () => { vi.useFakeTimers(); const harness = createHarness({ run: async () => "🦞".repeat(601) }); const result = await harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Long answer?", connId: "conn-1", @@ -448,13 +505,16 @@ describe("session companion asks", () => { let now = 0; const harness = createHarness({ now: () => now }); await harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Before idle?", connId: "conn-1", }); now = 2 * 60 * 60_000; await vi.advanceTimersByTimeAsync(10 * 60_000); - expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] }); + expect(harness.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ + exchanges: [], + }); harness.service.dispose(); }); @@ -463,16 +523,19 @@ describe("session companion asks", () => { const pending = deferred(); const harness = createHarness({ run: async () => await pending.promise }); const active = harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Still there?", connId: "conn-1", }); await vi.waitFor(() => expect(harness.run).toHaveBeenCalledOnce()); - harness.service.reset("agent:main:main"); + harness.service.reset({ agentId: "main", sessionKey: "agent:main:main" }); await expect(active).rejects.toMatchObject({ reason: "unavailable", } satisfies Partial); - expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] }); + expect(harness.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ + exchanges: [], + }); harness.service.dispose(); }); @@ -481,19 +544,22 @@ describe("session companion asks", () => { const pending = deferred(); const harness = createHarness({ run: async () => await pending.promise }); const active = harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Still the same backing session?", connId: "conn-1", }); await vi.waitFor(() => expect(harness.run).toHaveBeenCalledOnce()); - notifyGatewaySessionReset("agent:main:main"); + notifyGatewaySessionReset("agent:main:main", "main"); pending.resolve("stale answer"); await expect(active).rejects.toMatchObject({ reason: "context-unavailable", } satisfies Partial); - expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] }); + expect(harness.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ + exchanges: [], + }); harness.service.dispose(); }); @@ -506,11 +572,13 @@ describe("session companion asks", () => { run: async () => (runCount++ === 0 ? "existing answer" : await pending.promise), }); await harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "What is already known?", connId: "conn-1", }); const active = harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Will a disconnected request commit?", connId: "conn-1", @@ -524,7 +592,7 @@ describe("session companion asks", () => { await expect(active).rejects.toMatchObject({ reason: "unavailable", } satisfies Partial); - expect(harness.service.state("agent:main:main")).toEqual({ + expect(harness.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ exchanges: [ { question: "What is already known?", @@ -541,6 +609,7 @@ describe("session companion asks", () => { const pending = deferred(); const harness = createHarness({ run: async () => await pending.promise }); const active = harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Will this survive shutdown?", connId: "conn-1", @@ -553,7 +622,9 @@ describe("session companion asks", () => { await expect(active).rejects.toMatchObject({ reason: "unavailable", } satisfies Partial); - expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] }); + expect(harness.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ + exchanges: [], + }); }); it("keeps provider failures terminal after one model call", async () => { @@ -566,6 +637,7 @@ describe("session companion asks", () => { await expect( harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Can the provider answer?", connId: "conn-1", @@ -574,7 +646,9 @@ describe("session companion asks", () => { reason: "unavailable", } satisfies Partial); expect(harness.run).toHaveBeenCalledOnce(); - expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] }); + expect(harness.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ + exchanges: [], + }); harness.service.dispose(); }); @@ -582,15 +656,20 @@ describe("session companion asks", () => { vi.useFakeTimers(); const harness = createHarness(); await harness.service.ask({ + agentId: "main", sessionKey: "agent:main:main", question: "Before reset?", connId: "conn-1", }); - expect(harness.service.state("agent:main:main").exchanges).toHaveLength(1); + expect( + harness.service.state({ agentId: "main", sessionKey: "agent:main:main" }).exchanges, + ).toHaveLength(1); - notifyGatewaySessionReset("agent:main:main"); + notifyGatewaySessionReset("agent:main:main", "main"); - expect(harness.service.state("agent:main:main")).toEqual({ exchanges: [] }); + expect(harness.service.state({ agentId: "main", sessionKey: "agent:main:main" })).toEqual({ + exchanges: [], + }); harness.service.dispose(); }); }); diff --git a/src/gateway/session-companion.ts b/src/gateway/session-companion.ts index a9086770028e..17fe698b688b 100644 --- a/src/gateway/session-companion.ts +++ b/src/gateway/session-companion.ts @@ -2,22 +2,27 @@ import type { SessionsCompanionAskResult, SessionsCompanionStateResult, } from "../../packages/gateway-protocol/src/schema/sessions.js"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; import { createSessionCompanionAskRuntime, type SessionCompanionAskDeps, } from "./session-companion-ask.js"; import type { SessionCompanionThread } from "./session-companion-state.js"; +import { sessionObserverScopeKey } from "./session-observer-model.js"; import { onGatewaySessionReset } from "./session-reset-notifications.js"; +type SessionCompanionTarget = { sessionKey: string; agentId: string }; + export type SessionCompanionService = { ask: (params: { + agentId: string; sessionKey: string; question: string; connId: string; signal?: AbortSignal; }) => Promise; - state: (sessionKey: string) => SessionsCompanionStateResult; - reset: (sessionKey: string) => void; + state: (target: SessionCompanionTarget) => SessionsCompanionStateResult; + reset: (target: SessionCompanionTarget) => void; dispose: () => void; }; @@ -43,35 +48,43 @@ export function createSessionCompanion(deps: SessionCompanionDeps): SessionCompa }); const reset = ( - sessionKey: string, + target: SessionCompanionTarget, cancellation: "backing-session-revoked" | "explicit-reset", ) => { - const key = sessionKey.trim(); - if (!key) { + const sessionKey = target.sessionKey.trim(); + const agentId = target.agentId.trim(); + if (!sessionKey || !agentId) { return; } - askRuntime.cancel(key, cancellation); + const key = sessionObserverScopeKey(sessionKey, agentId); + askRuntime.cancel(sessionKey, agentId, cancellation); threads.delete(key); }; const sweep = () => { const cutoff = now() - SESSION_COMPANION_IDLE_TTL_MS; - for (const [sessionKey, thread] of threads) { + for (const [key, thread] of threads) { if (!thread.busy && thread.lastUsedAt <= cutoff) { - reset(sessionKey, "explicit-reset"); + threads.delete(key); } } }; const sweepTimer = setIntervalFn(sweep, SESSION_COMPANION_SWEEP_INTERVAL_MS); sweepTimer.unref?.(); - const unsubscribeReset = onGatewaySessionReset((sessionKey) => - reset(sessionKey, "backing-session-revoked"), - ); + const unsubscribeReset = onGatewaySessionReset((sessionKey, suppliedAgentId) => { + let agentId = suppliedAgentId; + try { + agentId ??= resolveSessionAgentId({ sessionKey, config: deps.getConfig() }); + } catch { + return; + } + reset({ sessionKey, agentId }, "backing-session-revoked"); + }); return { ask: askRuntime.ask, - state(sessionKey) { - const key = sessionKey.trim(); + state(target) { + const key = sessionObserverScopeKey(target.sessionKey.trim(), target.agentId.trim()); const thread = threads.get(key); if (!thread) { return { exchanges: [] }; @@ -81,8 +94,8 @@ export function createSessionCompanion(deps: SessionCompanionDeps): SessionCompa exchanges: thread.exchanges.map(({ question, answer, ts }) => ({ question, answer, ts })), }; }, - reset(sessionKey) { - reset(sessionKey, "explicit-reset"); + reset(target) { + reset(target, "explicit-reset"); }, dispose() { if (disposed) { diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index a08cf8d9c8d5..9b55cfdd9e70 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -11,11 +11,7 @@ import { missingScopeErrorShape, } from "../../packages/gateway-protocol/src/index.js"; import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js"; -import { - resolveAgentDir, - resolveAgentWorkspaceDir, - resolveDefaultAgentId, -} from "../agents/agent-scope.js"; +import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js"; import { normalizeInheritedToolAllowlist, @@ -84,7 +80,6 @@ import { import { resolvePluginSessionOwnershipError } from "./session-plugin-ownership.js"; import { resolveRequestedSessionAgentId } from "./session-request-agent.js"; import { isSessionVisibilityAllowed, resolveSessionVisibility } from "./session-sharing.js"; -import { resolveSessionStoreKey } from "./session-store-key.js"; import { loadGatewaySessionEntryReadOnly, resolveGatewaySessionStoreTarget, @@ -299,8 +294,43 @@ export async function createGatewaySession(params: { const parentSessionKey = normalizeOptionalString(params.parentSessionKey); const generatedDisplayName = normalizeOptionalString(params.generatedDisplayName); const projectId = normalizeOptionalString(params.projectId); + const explicitAgentId = normalizeOptionalString(params.agentId); + const explicitKeyAgentId = parseAgentSessionKey(requestedKey)?.agentId; + if ( + explicitAgentId && + explicitKeyAgentId && + normalizeAgentId(explicitKeyAgentId) !== normalizeAgentId(explicitAgentId) + ) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `sessions.create key agent (${explicitKeyAgentId}) does not match agentId (${normalizeAgentId(explicitAgentId)})`, + ), + }; + } + const requestedKeyAgent = requestedKey + ? resolveRequestedSessionAgentId(params.cfg, requestedKey, explicitAgentId, { + allowUnconfiguredExplicitAgent: true, + }) + : undefined; + if (requestedKeyAgent && !requestedKeyAgent.ok) { + return requestedKeyAgent; + } + // Resolve the main alias under an explicit selection before compatibility ownership. + const implicitSelectionKey = explicitAgentId + ? `agent:${normalizeAgentId(explicitAgentId)}:main` + : "main"; + const implicitAgent = requestedKeyAgent + ? undefined + : resolveRequestedSessionAgentId(params.cfg, implicitSelectionKey, explicitAgentId, { + allowUnconfiguredExplicitAgent: true, + }); + if (implicitAgent && !implicitAgent.ok) { + return implicitAgent; + } const agentId = normalizeAgentId( - normalizeOptionalString(params.agentId) ?? resolveDefaultAgentId(params.cfg), + explicitAgentId ?? requestedKeyAgent?.agentId ?? implicitAgent?.agentId, ); const catalogModel = normalizeOptionalString(params.catalogTarget?.model); const catalogAgentRuntime = normalizeOptionalAgentRuntimeId(params.catalogTarget?.agentRuntime); @@ -334,22 +364,6 @@ export async function createGatewaySession(params: { }; } } - if (requestedKey) { - const requestedAgentId = parseAgentSessionKey(requestedKey)?.agentId; - if ( - requestedAgentId && - requestedAgentId !== agentId && - normalizeOptionalString(params.agentId) - ) { - return { - ok: false, - error: errorShape( - ErrorCodes.INVALID_REQUEST, - `sessions.create key agent (${requestedAgentId}) does not match agentId (${agentId})`, - ), - }; - } - } const loweredRequestedKey = normalizeOptionalLowercaseString(requestedKey); const explicitTargetKey = requestedKey ? loweredRequestedKey === "global" || loweredRequestedKey === "unknown" @@ -479,25 +493,21 @@ export async function createGatewaySession(params: { let parentSelectedAgentId: string | undefined; let parentSessionTarget: ReturnType | undefined; if (parentSessionKey) { - const parentCanonicalKey = resolveSessionStoreKey({ - cfg: params.cfg, - sessionKey: parentSessionKey, - }); - if (parentCanonicalKey === "global") { - const parentRequestedAgent = resolveRequestedSessionAgentId( - params.cfg, - parentSessionKey, - params.agentId, - ); - if (!parentRequestedAgent.ok) { - return parentRequestedAgent; - } - parentSelectedAgentId = parentRequestedAgent.agentId; - } - const parent = loadGatewaySessionEntryReadOnly( + const parentRequestedAgent = resolveRequestedSessionAgentId( + params.cfg, parentSessionKey, - parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined, + !parseAgentSessionKey(parentSessionKey) && + ["global", "unknown"].includes(parentSessionKey.toLowerCase()) + ? explicitAgentId + : undefined, ); + if (!parentRequestedAgent.ok) { + return parentRequestedAgent; + } + parentSelectedAgentId = parentRequestedAgent.agentId; + const parent = loadGatewaySessionEntryReadOnly(parentSessionKey, { + agentId: parentSelectedAgentId, + }); if (!parent.entry?.sessionId) { return { ok: false, @@ -527,9 +537,7 @@ export async function createGatewaySession(params: { parentSessionTarget = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: parentSessionKey, - ...(canonicalParentSessionKey === "global" && parentSelectedAgentId - ? { agentId: parentSelectedAgentId } - : {}), + ...(parentSelectedAgentId ? { agentId: parentSelectedAgentId } : {}), }); } const parentIncognito = @@ -629,9 +637,7 @@ export async function createGatewaySession(params: { params.cfg.session?.dmScope === "main" ) { const parentAgentId = normalizeAgentId( - parentSelectedAgentId ?? - resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? - resolveDefaultAgentId(params.cfg), + parentSelectedAgentId ?? resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? agentId, ); const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId }); if (canonicalParentSessionKey === parentMainKey) { @@ -649,9 +655,7 @@ export async function createGatewaySession(params: { const execCwd = normalizeOptionalString(params.execCwd); const resetResult = await performGatewaySessionReset({ key: canonicalParentSessionKey, - ...(canonicalParentSessionKey === "global" && parentSelectedAgentId - ? { agentId: parentSelectedAgentId } - : {}), + ...(parentSelectedAgentId ? { agentId: parentSelectedAgentId } : {}), reason: "new", commandSource: params.commandSource, ...(params.creation ? { creation: params.creation } : {}), @@ -768,9 +772,7 @@ export async function createGatewaySession(params: { if (canonicalParentSessionKey && parentSessionTarget && params.emitCommandHooks === true) { const parentEntry = currentParentSessionEntry; const parentAgentId = normalizeAgentId( - parentSelectedAgentId ?? - resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? - resolveDefaultAgentId(params.cfg), + parentSelectedAgentId ?? resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? agentId, ); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, parentAgentId); if (hasInternalHookListeners("command", "new")) { diff --git a/src/gateway/session-kill-http.test.ts b/src/gateway/session-kill-http.test.ts index 4515456ecb23..4b4ef35e88ee 100644 --- a/src/gateway/session-kill-http.test.ts +++ b/src/gateway/session-kill-http.test.ts @@ -209,6 +209,7 @@ describe("POST /sessions/:sessionKey/kill", () => { expect(killSubagentRunAdminMock).toHaveBeenCalledWith({ cfg, sessionKey: WORKER_SESSION_KEY, + agentId: "main", }); }); @@ -264,6 +265,7 @@ describe("POST /sessions/:sessionKey/kill", () => { expect(killSubagentRunAdminMock).toHaveBeenCalledWith({ cfg, sessionKey: WORKER_SESSION_KEY, + agentId: "main", }); }); diff --git a/src/gateway/session-kill-http.ts b/src/gateway/session-kill-http.ts index 2402380c958a..ccfca77db129 100644 --- a/src/gateway/session-kill-http.ts +++ b/src/gateway/session-kill-http.ts @@ -16,6 +16,7 @@ import { resolveTrustedHttpOperatorScopes, } from "./http-utils.js"; import { ADMIN_SCOPE, authorizeOperatorScopesForRequiredScope } from "./method-scopes.js"; +import { resolveRequestedSessionAgentId } from "./session-request-agent.js"; import { loadSessionEntry } from "./session-utils.js"; type SessionKeyPathResolution = @@ -87,7 +88,18 @@ export async function handleSessionKillHttpRequest( return true; } - const { entry, canonicalKey } = loadSessionEntry(sessionKey); + const requestedAgent = resolveRequestedSessionAgentId( + cfg, + sessionKey, + url.searchParams.get("agentId") ?? undefined, + ); + if (!requestedAgent.ok) { + sendInvalidRequest(res, requestedAgent.error.message); + return true; + } + const { entry, canonicalKey } = loadSessionEntry(sessionKey, { + agentId: requestedAgent.agentId, + }); if (!entry) { sendJson(res, 404, { ok: false, @@ -102,6 +114,7 @@ export async function handleSessionKillHttpRequest( const result = await killSubagentRunAdmin({ cfg, sessionKey: canonicalKey, + agentId: requestedAgent.agentId, }); sendJson(res, 200, { diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index e854bcf6b053..094ea73ef0a2 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -1910,7 +1910,11 @@ describe("session.message websocket events", () => { test("routes selected-agent global transcript updates to matching message subscribers", async () => { const storePath = await createSessionStoreFile(); - testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; + testState.agentsConfig = { + ownership: "explicit", + list: [{ id: "main" }, { id: "work" }], + }; + testState.agentConfig = { sessionStore: { agentId: "work" } }; const transcriptPath = path.join(path.dirname(storePath), "global-work.jsonl"); await writeSessionStore({ entries: { @@ -1955,27 +1959,30 @@ describe("session.message websocket events", () => { await connectOk(workWs, { scopes: ["operator.read"] }); await connectOk(mainWs, { scopes: ["operator.read"] }); await connectOk(bareWs, { scopes: ["operator.read"] }); - await rpcReq(workWs, "sessions.messages.subscribe", { - key: "global", - agentId: "work", - }); - await rpcReq(mainWs, "sessions.messages.subscribe", { - key: "global", - agentId: "main", - }); - await rpcReq(bareWs, "sessions.messages.subscribe", { - key: "global", - }); + expect( + await rpcReq(workWs, "sessions.messages.subscribe", { + key: "global", + agentId: "work", + }), + ).toMatchObject({ ok: true, payload: { key: "global", subscribed: true } }); + expect( + await rpcReq(mainWs, "sessions.messages.subscribe", { + key: "global", + agentId: "main", + }), + ).toMatchObject({ ok: false }); + expect( + await rpcReq(bareWs, "sessions.messages.subscribe", { + key: "global", + }), + ).toMatchObject({ ok: true, payload: { key: "global", subscribed: true } }); const workMessagePromise = waitForSessionMessageEvent(workWs, "global"); const mainMessagePromise = expectNoMessageWithin({ watch: (timeoutMs) => waitForSessionMessageEvent(mainWs, "global", timeoutMs), timeoutMs: 250, }); - const bareMessagePromise = expectNoMessageWithin({ - watch: (timeoutMs) => waitForSessionMessageEvent(bareWs, "global", timeoutMs), - timeoutMs: 250, - }); + const bareMessagePromise = waitForSessionMessageEvent(bareWs, "global"); emitSessionTranscriptUpdate({ sessionFile: transcriptPath, sessionKey: "global", @@ -1986,7 +1993,7 @@ describe("session.message websocket events", () => { const workMessage = await workMessagePromise; await mainMessagePromise; - await bareMessagePromise; + const bareMessage = await bareMessagePromise; expectRecordFields(workMessage.payload, { sessionKey: "global", agentId: "work", @@ -2004,18 +2011,28 @@ describe("session.message websocket events", () => { continuationTurns: 0, }, }); + expectRecordFields(bareMessage.payload, { + sessionKey: "global", + agentId: "work", + messageId: "msg-work-global", + }); } finally { workWs.close(); mainWs.close(); bareWs.close(); testState.agentsConfig = undefined; + testState.agentConfig = undefined; testState.sessionStorePath = undefined; } }); test("routes a subscribed global observer event through the real gateway socket once", async () => { const storePath = await createSessionStoreFile(); - testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; + testState.agentsConfig = { + ownership: "explicit", + list: [{ id: "main" }, { id: "work" }], + }; + testState.agentConfig = { sessionStore: { agentId: "work" } }; await writeSessionStore({ entries: { global: { sessionId: "sess-work-observer", updatedAt: Date.now() } }, storePath, @@ -2046,7 +2063,9 @@ describe("session.message websocket events", () => { agentId: " WORK ", }), ).toMatchObject({ ok: true, payload: { key: "global", subscribed: true } }); - await rpcReq(mainWs, "sessions.messages.subscribe", { key: "global", agentId: "main" }); + expect( + await rpcReq(mainWs, "sessions.messages.subscribe", { key: "global", agentId: "main" }), + ).toMatchObject({ ok: false }); await rpcReq(workWs, "sessions.observer.visibility", { visible: true }); await rpcReq(mainWs, "sessions.observer.visibility", { visible: true }); @@ -2080,6 +2099,7 @@ describe("session.message websocket events", () => { workWs.close(); mainWs.close(); testState.agentsConfig = undefined; + testState.agentConfig = undefined; testState.sessionStorePath = undefined; } }); diff --git a/src/gateway/session-observer-audience.ts b/src/gateway/session-observer-audience.ts index e7ceff78dd4b..1b9fbe93e419 100644 --- a/src/gateway/session-observer-audience.ts +++ b/src/gateway/session-observer-audience.ts @@ -1,3 +1,6 @@ +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SessionEventSubscriberRegistry, SessionMessageSubscriberRegistry, @@ -8,10 +11,16 @@ export function createSessionObserverAudience(params: { subscribers: SessionMessageSubscriberRegistry; sessionEventSubscribers?: SessionEventSubscriberRegistry; isVisible: (connId: string) => boolean; - getDefaultAgentId: () => string; + getConfig: () => OpenClawConfig; }) { const messageSubscriberKeys = (sessionKey: string, agentId: string): string[] => { - return resolveSessionSubscriptionKeys(sessionKey, agentId, params.getDefaultAgentId()); + const config = params.getConfig(); + const persistedOwner = resolvePersistedSessionStoreOwnerForKey(config, sessionKey); + const compatibilityAgentId = + persistedOwner.kind === "configured" + ? persistedOwner.agentId + : tryResolveLegacyCompatibilityAgentId(config); + return resolveSessionSubscriptionKeys(sessionKey, agentId, compatibilityAgentId); }; const messageRecipients = (sessionKey: string, agentId: string): Set => { diff --git a/src/gateway/session-observer-companion.ts b/src/gateway/session-observer-companion.ts new file mode 100644 index 000000000000..8614cb4fc550 --- /dev/null +++ b/src/gateway/session-observer-companion.ts @@ -0,0 +1,43 @@ +import { resolveSessionAgentId } from "../agents/agent-scope.js"; +import { flushSessionActivityAssistantNote } from "../agents/session-activity-notes.js"; +import type { SessionObserverCompanionSnapshot } from "./session-observer-contract.js"; +import type { SessionObserverDeps, SessionObserverState } from "./session-observer-model.js"; +import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; +import { resolveSessionSubscriptionKey } from "./session-subscription-keys.js"; + +export function createSessionObserverCompanionSnapshotReader(params: { + getConfig: SessionObserverDeps["getConfig"]; + readSession: NonNullable; + states: Map; +}): (sessionKey: string, selectedAgentId?: string) => SessionObserverCompanionSnapshot { + return (sessionKey, selectedAgentId) => { + const cfg = params.getConfig(); + const agentId = resolveSessionAgentId({ + sessionKey, + config: cfg, + ...(selectedAgentId ? { agentId: selectedAgentId } : {}), + }); + const canonicalSessionKey = resolveStoredSessionKeyForAgentStore({ + cfg, + agentId, + sessionKey, + }); + const state = params.states.get(resolveSessionSubscriptionKey(canonicalSessionKey, agentId)); + if (state) { + flushSessionActivityAssistantNote(state); + return { + agentId: state.agentId, + runId: state.runId, + ...(state.previousDigest ? { digest: state.previousDigest } : {}), + notes: state.notes.map((note) => ({ sequence: note.sequence, text: note.text })), + }; + } + const digest = params.readSession(canonicalSessionKey, agentId)?.observerDigest; + return { + agentId, + ...(digest?.runId ? { runId: digest.runId } : {}), + ...(digest ? { digest } : {}), + notes: [], + }; + }; +} diff --git a/src/gateway/session-observer-contract.ts b/src/gateway/session-observer-contract.ts index 8c07edb4c259..4b8b2e438fa3 100644 --- a/src/gateway/session-observer-contract.ts +++ b/src/gateway/session-observer-contract.ts @@ -21,6 +21,6 @@ export type SessionObserverService = { handleEvent: (event: SessionObserverEvent) => void; setConnectionVisibility: (connId: string, visible: boolean) => void; removeConnection: (connId: string) => void; - getCompanionSnapshot: (sessionKey: string) => SessionObserverCompanionSnapshot; + getCompanionSnapshot: (sessionKey: string, agentId?: string) => SessionObserverCompanionSnapshot; dispose: () => void; }; diff --git a/src/gateway/session-observer-model.ts b/src/gateway/session-observer-model.ts index cb4ddd8a3291..e800a1a16710 100644 --- a/src/gateway/session-observer-model.ts +++ b/src/gateway/session-observer-model.ts @@ -25,6 +25,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { AgentEventPayload } from "../infra/agent-events.js"; import { pruneMapToMaxSize } from "../infra/map-size.js"; import { redactToolPayloadText } from "../logging/redact.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import type { SessionEventSubscriberRegistry, SessionMessageSubscriberRegistry, @@ -39,6 +40,12 @@ const MAX_DORMANT_RUNS = 256; const MAX_DISABLED_RUNS = 512; export const SESSION_OBSERVER_MODEL_MAX_TOKENS = 300; + +export function sessionObserverScopeKey(sessionKey: string, agentId: string): string { + return parseAgentSessionKey(sessionKey) + ? sessionKey + : `agent:${normalizeAgentId(agentId)}:${sessionKey}`; +} type PrepareModel = typeof prepareSimpleCompletionModelForAgent; type CompleteModel = typeof completeWithPreparedSimpleCompletionModel; type PreparedModel = Awaited>; diff --git a/src/gateway/session-observer.schema.test.ts b/src/gateway/session-observer.schema.test.ts new file mode 100644 index 000000000000..ca967146688e --- /dev/null +++ b/src/gateway/session-observer.schema.test.ts @@ -0,0 +1,43 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { SessionObserverDigestSchema } from "../../packages/gateway-protocol/src/schema/sessions.js"; +import { normalizeSessionObserverModelOutput } from "./session-observer-model.js"; + +describe("session observer schema", () => { + it("validates protocol digests", () => { + expect( + Value.Check(SessionObserverDigestSchema, { + sessionKey: "agent:main:session-1", + agentId: "main", + runId: "run-1", + revision: 1, + updatedAt: 1, + headline: "Checking the implementation", + health: "on-track", + planProgress: { completed: 2, total: 4 }, + }), + ).toBe(true); + expect( + Value.Check(SessionObserverDigestSchema, { + sessionKey: "agent:main:session-1", + revision: 1, + updatedAt: 1, + headline: "x".repeat(121), + health: "on-track", + }), + ).toBe(false); + }); + + it("rejects loose JSON and truncates accepted strings to hard caps", () => { + expect(normalizeSessionObserverModelOutput("```json\n{}\n```")).toBeNull(); + const normalized = normalizeSessionObserverModelOutput( + JSON.stringify({ + headline: "h".repeat(140), + assessment: "a".repeat(400), + health: "grinding", + }), + ); + expect(normalized?.headline).toHaveLength(120); + expect(normalized?.assessment).toHaveLength(320); + }); +}); diff --git a/src/gateway/session-observer.test.ts b/src/gateway/session-observer.test.ts index 0e24ce9326ed..6d86d0c489eb 100644 --- a/src/gateway/session-observer.test.ts +++ b/src/gateway/session-observer.test.ts @@ -1,11 +1,6 @@ -import { Value } from "typebox/value"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - SessionObserverDigestSchema, - type SessionObserverDigest, -} from "../../packages/gateway-protocol/src/schema/sessions.js"; +import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeSessionObserverModelOutput } from "./session-observer-model.js"; import { createHarness, declareObserverVisibility, @@ -153,6 +148,51 @@ describe("session observer", () => { harness.observer.dispose(); }); + it("keeps the persisted fixed-store owner on the bare global observer stream", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const config = { + gateway: { controlUi: { sessionObserver: true } }, + session: { scope: "global" as const, store: "/tmp/owned-shared.sqlite" }, + agents: { + ownership: "explicit" as const, + defaults: { + utilityModel: "openai/gpt-test", + sessionStore: { agentId: "ops" }, + }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + const harness = createHarness({ subscribe: false, config }); + harness.subscribers.subscribe("conn-global", "global")?.commit(); + harness.subscribers.subscribe("conn-scoped", "agent:ops:global")?.commit(); + declareObserverVisibility(harness.observer, "conn-global"); + declareObserverVisibility(harness.observer, "conn-scoped"); + + harness.observer.handleEvent( + event({ + runId: "run-ops", + sessionKey: "global", + agentId: "ops", + stream: "item", + data: { kind: "preamble", phase: "update", progressText: "Ops agent work" }, + }), + ); + await flushObserver(); + + expect(harness.broadcastToConnIds).toHaveBeenCalledWith( + "session.observer", + expect.objectContaining({ agentId: "ops", sessionKey: "global" }), + new Set(["conn-scoped", "conn-global"]), + expect.objectContaining({ + agentId: "ops", + dropIfSlow: true, + sessionKeys: ["agent:ops:global", "global"], + }), + ); + harness.observer.dispose(); + }); + it("resolves an explicit global alias to its agent-scoped companion snapshot", () => { const config = { gateway: { controlUi: { sessionObserver: true } }, @@ -1049,42 +1089,3 @@ describe("session observer", () => { harness.observer.dispose(); }); }); - -describe("session observer schema", () => { - it("validates protocol digests", () => { - expect( - Value.Check(SessionObserverDigestSchema, { - sessionKey: "agent:main:session-1", - agentId: "main", - runId: "run-1", - revision: 1, - updatedAt: 1, - headline: "Checking the implementation", - health: "on-track", - planProgress: { completed: 2, total: 4 }, - }), - ).toBe(true); - expect( - Value.Check(SessionObserverDigestSchema, { - sessionKey: "agent:main:session-1", - revision: 1, - updatedAt: 1, - headline: "x".repeat(121), - health: "on-track", - }), - ).toBe(false); - }); - - it("rejects loose JSON and truncates accepted strings to hard caps", () => { - expect(normalizeSessionObserverModelOutput("```json\n{}\n```")).toBeNull(); - const normalized = normalizeSessionObserverModelOutput( - JSON.stringify({ - headline: "h".repeat(140), - assessment: "a".repeat(400), - health: "grinding", - }), - ); - expect(normalized?.headline).toHaveLength(120); - expect(normalized?.assessment).toHaveLength(320); - }); -}); diff --git a/src/gateway/session-observer.ts b/src/gateway/session-observer.ts index b37bc9b5e6d0..434a99da0565 100644 --- a/src/gateway/session-observer.ts +++ b/src/gateway/session-observer.ts @@ -1,6 +1,5 @@ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js"; -import { resolveDefaultAgentId, resolveSessionAgentId } from "../agents/agent-scope.js"; import { createSessionActivityNoteState, flushSessionActivityAssistantNote, @@ -11,12 +10,9 @@ import { resolveUtilityModelRefForAgent } from "../agents/utility-model.js"; import { getAgentRunContext } from "../infra/agent-run-registry.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSessionObserverAudience } from "./session-observer-audience.js"; +import { createSessionObserverCompanionSnapshotReader } from "./session-observer-companion.js"; import { createSessionObserverCompletion } from "./session-observer-completion.js"; -import type { - SessionObserverCompanionSnapshot, - SessionObserverEvent, - SessionObserverService, -} from "./session-observer-contract.js"; +import type { SessionObserverEvent, SessionObserverService } from "./session-observer-contract.js"; import { createSessionObserverModelSlots } from "./session-observer-model-slots.js"; import { createDormantSessionObserverRun, @@ -39,7 +35,6 @@ import type { } from "./session-observer-model.js"; import { createSessionObserverDigestPersister } from "./session-observer-persistence.js"; import { createSessionObserverPreamblePublisher } from "./session-observer-preamble.js"; -import { resolveStoredSessionKeyForAgentStore as resolveStoreKey } from "./session-store-key.js"; import { resolveSessionSubscriptionKey } from "./session-subscription-keys.js"; const observerLog = createSubsystemLogger("gateway/session-observer"); @@ -72,33 +67,16 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve const disabledRuns = new Set(); const visibleConnections = new Set(); let disposed = false; - const getCompanionSnapshot = (sessionKey: string): SessionObserverCompanionSnapshot => { - const cfg = deps.getConfig(); - const agentId = resolveSessionAgentId({ sessionKey, config: cfg }); - const canonicalSessionKey = resolveStoreKey({ cfg, agentId, sessionKey }); - const state = states.get(resolveSessionSubscriptionKey(canonicalSessionKey, agentId)); - if (state) { - flushSessionActivityAssistantNote(state); - return { - agentId: state.agentId, - runId: state.runId, - ...(state.previousDigest ? { digest: state.previousDigest } : {}), - notes: state.notes.map((note) => ({ sequence: note.sequence, text: note.text })), - }; - } - const digest = readSession(canonicalSessionKey, agentId)?.observerDigest; - return { - agentId, - ...(digest?.runId ? { runId: digest.runId } : {}), - ...(digest ? { digest } : {}), - notes: [], - }; - }; + const getCompanionSnapshot = createSessionObserverCompanionSnapshotReader({ + getConfig: deps.getConfig, + readSession, + states, + }); const audience = createSessionObserverAudience({ subscribers: deps.subscribers, sessionEventSubscribers: deps.sessionEventSubscribers, isVisible: (connId) => visibleConnections.has(connId), - getDefaultAgentId: () => resolveDefaultAgentId(deps.getConfig()), + getConfig: deps.getConfig, }); // Narrow run-identity guard shared by persist paths: a digest may still land // while its session is unwatched, but never after a newer run replaces it. diff --git a/src/gateway/session-request-agent.test.ts b/src/gateway/session-request-agent.test.ts new file mode 100644 index 000000000000..5d67496e9f2d --- /dev/null +++ b/src/gateway/session-request-agent.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + resolveRequestedSessionAgentId, + tryResolveSessionCompatibilityOwnerAgentId, +} from "./session-request-agent.js"; + +function fixedStoreConfig(owner: string): OpenClawConfig { + return { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: owner } }, + entries: { ops: {}, research: {} }, + }, + }; +} + +describe("requested session agent ownership", () => { + it("uses the configured persisted owner for a bare key", () => { + expect(tryResolveSessionCompatibilityOwnerAgentId(fixedStoreConfig("ops"), "global")).toBe( + "ops", + ); + expect(resolveRequestedSessionAgentId(fixedStoreConfig("ops"), "global")).toEqual({ + ok: true, + agentId: "ops", + }); + }); + + it("rejects conflicting and retired persisted owners", () => { + expect(resolveRequestedSessionAgentId(fixedStoreConfig("ops"), "global", "research").ok).toBe( + false, + ); + expect(resolveRequestedSessionAgentId(fixedStoreConfig("retired"), "global").ok).toBe(false); + }); + + it("uses a legacy compatibility owner for a bare key", () => { + const cfg: OpenClawConfig = { + agents: { entries: { ops: { default: true }, research: {} } }, + }; + + expect(resolveRequestedSessionAgentId(cfg, "global")).toEqual({ + ok: true, + agentId: "ops", + }); + }); + + it("returns a typed selection error for an ownerless bare key", () => { + const cfg: OpenClawConfig = { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + }; + + expect(tryResolveSessionCompatibilityOwnerAgentId(cfg, "global")).toBeUndefined(); + expect(resolveRequestedSessionAgentId(cfg, "global")).toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + message: expect.stringContaining("has no explicit owner"), + }, + }); + }); + + it("returns typed ownership results for arbitrary bare keys before canonicalization", () => { + const cfg: OpenClawConfig = { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + }; + + expect(resolveRequestedSessionAgentId(cfg, "thread-1")).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("has no explicit owner") }, + }); + expect(resolveRequestedSessionAgentId(cfg, "thread-1", "research")).toEqual({ + ok: true, + agentId: "research", + }); + }); + + it("keeps retired agent-qualified history readable outside global scope", () => { + const cfg: OpenClawConfig = { + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + }; + + expect(resolveRequestedSessionAgentId(cfg, "agent:retired:main")).toEqual({ + ok: true, + agentId: "retired", + }); + expect( + resolveRequestedSessionAgentId( + { ...cfg, session: { scope: "global" } }, + "agent:retired:main", + ), + ).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" } }); + }); +}); diff --git a/src/gateway/session-request-agent.ts b/src/gateway/session-request-agent.ts index e19960edbdc0..f36baa4f4d89 100644 --- a/src/gateway/session-request-agent.ts +++ b/src/gateway/session-request-agent.ts @@ -4,62 +4,118 @@ import { type ErrorShape, errorShape, } from "../../packages/gateway-protocol/src/index.js"; -import { listAgentIds } from "../agents/agent-scope.js"; +import { AgentSelectionRequiredError, listAgentIds } from "../agents/agent-scope.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; -import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js"; +import { + normalizeAgentId, + normalizeMainKey, + parseAgentSessionKey, +} from "../routing/session-key.js"; type RequestedSessionAgentIdResolution = - | { ok: true; agentId?: string } + | { ok: true; agentId: string } | { ok: false; error: ErrorShape }; +/** Resolves only stable implicit ownership for unscoped session rows and active runs. */ +export function tryResolveSessionCompatibilityOwnerAgentId( + cfg: OpenClawConfig, + key: string, +): string | undefined { + const persistedStoreOwner = resolvePersistedSessionStoreOwnerForKey(cfg, key); + if (persistedStoreOwner.kind === "configured") { + return persistedStoreOwner.agentId; + } + return persistedStoreOwner.kind === "retired" + ? undefined + : tryResolveLegacyCompatibilityAgentId(cfg); +} + export function resolveRequestedSessionAgentId( cfg: OpenClawConfig, key: string, explicitAgentId?: string, + options?: { allowUnconfiguredExplicitAgent?: boolean }, ): RequestedSessionAgentIdResolution { - const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key }); - const parsed = parseAgentSessionKey(key); + const parsed = parseAgentSessionKey(key.trim()); const requestedAgentId = normalizeOptionalString(explicitAgentId); - if (requestedAgentId) { - const agentId = normalizeAgentId(requestedAgentId); - if (!listAgentIds(cfg).includes(agentId)) { - return { - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${explicitAgentId}"`), - }; - } - if (parsed?.agentId && normalizeAgentId(parsed.agentId) !== agentId) { - return { - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"), - }; - } - if (canonicalKey !== "global") { - const keyAgentId = parsed?.agentId - ? normalizeAgentId(parsed.agentId) - : normalizeAgentId(resolveSessionStoreAgentId(cfg, canonicalKey)); - if (keyAgentId !== agentId) { - return { - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"), - }; - } - } - return { ok: true, agentId }; - } - if (!parsed?.agentId) { - return { ok: true }; - } - const inferredAgentId = normalizeAgentId(parsed.agentId); - if (canonicalKey === "global" && !listAgentIds(cfg).includes(inferredAgentId)) { + const configuredAgentIds = listAgentIds(cfg); + const normalizedRequestedAgentId = requestedAgentId + ? normalizeAgentId(requestedAgentId) + : undefined; + if ( + normalizedRequestedAgentId && + !options?.allowUnconfiguredExplicitAgent && + !configuredAgentIds.includes(normalizedRequestedAgentId) + ) { return { ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${parsed.agentId}"`), + error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${explicitAgentId}"`), }; } + if (parsed?.agentId) { + const keyAgentId = normalizeAgentId(parsed.agentId); + const keyIsGlobalMainAlias = + cfg.session?.scope === "global" && + (parsed.rest === "main" || parsed.rest === normalizeMainKey(cfg.session?.mainKey)); + if ( + keyIsGlobalMainAlias && + !options?.allowUnconfiguredExplicitAgent && + !configuredAgentIds.includes(keyAgentId) + ) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${parsed.agentId}"`), + }; + } + if (normalizedRequestedAgentId && keyAgentId !== normalizedRequestedAgentId) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `agent "${explicitAgentId}" does not match session key agent "${keyAgentId}"`, + ), + }; + } + return { ok: true, agentId: keyAgentId }; + } + + const persistedStoreOwner = resolvePersistedSessionStoreOwnerForKey(cfg, key); + if (persistedStoreOwner.kind === "retired") { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `session key belongs to retired agent "${persistedStoreOwner.agentId}"`, + ), + }; + } + if (normalizedRequestedAgentId) { + if ( + persistedStoreOwner.kind === "configured" && + persistedStoreOwner.agentId !== normalizedRequestedAgentId + ) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `agent "${explicitAgentId}" does not match session key agent "${persistedStoreOwner.agentId}"`, + ), + }; + } + return { ok: true, agentId: normalizedRequestedAgentId }; + } + const inferredAgentId = tryResolveSessionCompatibilityOwnerAgentId(cfg, key); + if (inferredAgentId) { + return { ok: true, agentId: inferredAgentId }; + } + const selectionError = new AgentSelectionRequiredError(configuredAgentIds, { + surface: `session key "${key}"`, + hint: "Pass agentId or use an agent-prefixed session key.", + }); return { - ok: true, - agentId: canonicalKey === "global" ? inferredAgentId : undefined, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, selectionError.message), }; } diff --git a/src/gateway/session-reset-notifications.ts b/src/gateway/session-reset-notifications.ts index 39920ab972cd..dff9a9252628 100644 --- a/src/gateway/session-reset-notifications.ts +++ b/src/gateway/session-reset-notifications.ts @@ -1,6 +1,6 @@ import { resolveGlobalSet } from "../shared/global-singleton.js"; -type GatewaySessionResetListener = (sessionKey: string) => void; +type GatewaySessionResetListener = (sessionKey: string, agentId?: string) => void; const listeners = resolveGlobalSet( Symbol.for("openclaw.gatewaySessionResetListeners"), @@ -14,10 +14,10 @@ export function onGatewaySessionReset(listener: GatewaySessionResetListener): () } /** Notifies lifecycle-owned in-memory services after the session reset commits. */ -export function notifyGatewaySessionReset(sessionKey: string): void { +export function notifyGatewaySessionReset(sessionKey: string, agentId?: string): void { for (const listener of listeners) { try { - listener(sessionKey); + listener(sessionKey, agentId); } catch { // A process-local cleanup listener must not turn a committed reset into // an apparent failure or prevent the remaining lifecycle owners running. diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 124dbf0a459e..584e37eb40aa 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -15,6 +15,7 @@ import { listAgentIds, resolveAgentWorkspaceDir, resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, } from "../agents/agent-scope.js"; import { clearBootstrapSnapshot, @@ -117,6 +118,12 @@ import { retireSessionWorkerPlacementBeforeMutation, } from "./worker-environments/session-placement-lifecycle.js"; +function resolveLifecycleAgentId(cfg: OpenClawConfig, agentId?: string): string { + return normalizeAgentId( + agentId ?? tryResolveLegacyCompatibilityAgentId(cfg) ?? resolveDefaultAgentId(cfg), + ); +} + type McpRunEndWatcherState = { cancellations: Map void>; retirements: Set>; @@ -454,7 +461,7 @@ async function ensureSessionRuntimeCleanup(params: { clearFinishedSessionsForScopes(processScopeKeys); clearSessionResetRuntimeState([...queueKeys], { activeReplySessionId: params.sessionId, - agentId: normalizeAgentId(params.target.agentId ?? resolveDefaultAgentId(params.cfg)), + agentId: resolveLifecycleAgentId(params.cfg, params.target.agentId), }); await stopSubagentsForRequester({ cfg: params.cfg, @@ -587,6 +594,7 @@ async function runAcpCleanupStep(params: { async function closeAcpRuntimeForSession(params: { cfg: OpenClawConfig; sessionKey: string; + agentId?: string; fallbackSessionKeys?: Array; reason: "session-reset" | "session-delete"; onResetMeta?: (params: { sessionKey: string; meta: SessionAcpMeta }) => void; @@ -609,7 +617,7 @@ async function closeAcpRuntimeForSession(params: { let acpMeta: SessionAcpMeta | undefined; let acpSessionKey = params.sessionKey; for (const sessionKey of sessionKeys) { - acpMeta = readAcpSessionMeta({ sessionKey }); + acpMeta = readAcpSessionMeta({ sessionKey, agentId: params.agentId, cfg: params.cfg }); if (acpMeta) { acpSessionKey = sessionKey; break; @@ -684,6 +692,7 @@ async function closeAcpRuntimeForSession(params: { await upsertAcpSessionMeta({ cfg: params.cfg, sessionKey: acpSessionKey, + agentId: params.agentId, mutate: () => null, }); params.assertCurrent?.(); @@ -696,6 +705,7 @@ async function closeAcpRuntimeForSession(params: { const resetMeta = await ensureFreshAcpResetState({ cfg: params.cfg, sessionKey: acpSessionKey, + agentId: params.agentId, reason: params.reason, acpMeta, assertCurrent: params.assertCurrent, @@ -734,6 +744,7 @@ function buildPendingAcpMeta(base: SessionAcpMeta, now: number): SessionAcpMeta async function ensureFreshAcpResetState(params: { cfg: OpenClawConfig; sessionKey: string; + agentId?: string; reason: "session-reset" | "session-delete"; acpMeta: SessionAcpMeta; assertCurrent?: () => void; @@ -745,6 +756,8 @@ async function ensureFreshAcpResetState(params: { const latestMeta = readAcpSessionMeta({ sessionKey: params.sessionKey, + agentId: params.agentId, + cfg: params.cfg, }) ?? params.acpMeta; if ( !latestMeta?.identity || @@ -783,6 +796,7 @@ async function ensureFreshAcpResetState(params: { await upsertAcpSessionMeta({ cfg: params.cfg, sessionKey: params.sessionKey, + agentId: params.agentId, mutate: (current) => { if (params.shouldApply && !params.shouldApply()) { return current; @@ -900,6 +914,7 @@ export async function cleanupSessionBeforeMutation(params: { const parentAcpError = await closeAcpRuntimeForSession({ cfg: params.cfg, sessionKey: parentSessionKey, + agentId: params.target.agentId, fallbackSessionKeys: [params.canonicalKey, params.legacyKey, params.key], reason: params.reason, onResetMeta: params.onAcpResetMeta, @@ -920,7 +935,7 @@ export async function cleanupSessionBeforeMutation(params: { // Clear physical harness ownership after the old run drains but before the // store can expose a successor generation to a new turn. const resetParams = { - agentId: normalizeAgentId(params.target.agentId ?? resolveDefaultAgentId(params.cfg)), + agentId: resolveLifecycleAgentId(params.cfg, params.target.agentId), sessionId: params.entry.sessionId, sessionKey: params.target.canonicalKey ?? params.key, sessionFile: params.target.canonicalKey ?? params.key, @@ -948,7 +963,7 @@ export async function emitGatewayBeforeResetPluginHook(params: { const sessionKey = params.target.canonicalKey ?? params.key; const sessionId = params.entry?.sessionId; - const agentId = normalizeAgentId(params.target.agentId ?? resolveDefaultAgentId(params.cfg)); + const agentId = resolveLifecycleAgentId(params.cfg, params.target.agentId); const sessionFile = sessionId ? formatSqliteSessionFileMarker({ agentId, sessionId, storePath: params.storePath }) : undefined; @@ -1351,7 +1366,7 @@ export async function performGatewaySessionReset(params: { ? normalizeOptionalString(entry?.worktree?.id) : undefined; const resetLifecycleRevision = entry?.lifecycleRevision; - const agentId = normalizeAgentId(target.agentId ?? resolveDefaultAgentId(cfg)); + const agentId = resolveLifecycleAgentId(cfg, target.agentId); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const resetPluginRegistry = getActivePluginRegistry(); const isResetLifecycleCurrent = () => { @@ -1394,6 +1409,7 @@ export async function performGatewaySessionReset(params: { const parentAcpError = await closeAcpRuntimeForSession({ cfg, sessionKey: parentSessionKey, + agentId: target.agentId, fallbackSessionKeys: [canonicalKey, legacyKey, params.key], reason: "session-reset", deferResetState: true, @@ -1432,9 +1448,7 @@ export async function performGatewaySessionReset(params: { } const beforeResetMessages = getGlobalHookRunner()?.hasHooks("before_reset") ? await readGatewayBeforeResetPluginHookMessages({ - agentId: normalizeAgentId( - target.agentId ?? requestedAgentId ?? resolveDefaultAgentId(cfg), - ), + agentId: resolveLifecycleAgentId(cfg, target.agentId ?? requestedAgentId), entry, sessionId: entry?.sessionId, sessionKey: target.canonicalKey ?? params.key, @@ -1481,7 +1495,7 @@ export async function performGatewaySessionReset(params: { }; } handleSessionStateSessionDeleted(target.canonicalKey, agentId); - notifyGatewaySessionReset(target.canonicalKey); + notifyGatewaySessionReset(target.canonicalKey, target.agentId); emitGatewaySessionEndPluginHook({ cfg, sessionKey: target.canonicalKey, @@ -1746,7 +1760,7 @@ export async function performGatewaySessionReset(params: { if (!resetSkipped) { const resetSessionKey = target.canonicalKey ?? params.key; handleSessionStateSessionReset(resetSessionKey); - notifyGatewaySessionReset(resetSessionKey); + notifyGatewaySessionReset(resetSessionKey, target.agentId); } const next = lifecycle.nextEntry; const selectedModel = resolveSessionModelRef(cfg, next, target.agentId); diff --git a/src/gateway/session-sharing-target-input.ts b/src/gateway/session-sharing-target-input.ts new file mode 100644 index 000000000000..7084fb7ca77c --- /dev/null +++ b/src/gateway/session-sharing-target-input.ts @@ -0,0 +1,40 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { isIncognitoSessionKey } from "../routing/session-key.js"; + +export type SessionMutationTarget = { + sessionKey: string; + agentId?: string; +}; + +export function resolveDirectIncognitoTargets( + method: string, + params: unknown, +): SessionMutationTarget[] { + if (method === "sessions.create" || method === "sessions.list") { + return []; + } + if (!params || typeof params !== "object" || Array.isArray(params)) { + return []; + } + const record = params as Record; + const candidates = [record.key, record.sessionKey]; + if (Array.isArray(record.keys)) { + candidates.push(...record.keys); + } + if (Array.isArray(record.sessionKeys)) { + candidates.push(...record.sessionKeys); + } + const agentId = normalizeOptionalString(record.agentId); + return candidates.flatMap((candidate): SessionMutationTarget[] => + typeof candidate === "string" && isIncognitoSessionKey(candidate) + ? [{ sessionKey: candidate, ...(agentId ? { agentId } : {}) }] + : [], + ); +} + +export function readSessionSharingStringParam(params: unknown, key: string): string | undefined { + if (!params || typeof params !== "object" || Array.isArray(params)) { + return undefined; + } + return normalizeOptionalString((params as Record)[key]); +} diff --git a/src/gateway/session-sharing.ts b/src/gateway/session-sharing.ts index 2f2a93a917b5..524b6bb88ea3 100644 --- a/src/gateway/session-sharing.ts +++ b/src/gateway/session-sharing.ts @@ -6,6 +6,7 @@ import { type SessionSharingRole, type SessionVisibility, } from "../../packages/gateway-protocol/src/index.js"; +import { AgentSelectionRequiredError } from "../agents/agent-scope.js"; import { isSessionMember, resolveAllAgentSessionStoreTargetsSync, @@ -27,6 +28,11 @@ import { loadCachedSessionSharingSnapshot, type SessionSharingSnapshot, } from "./session-sharing-snapshot-cache.js"; +import { + readSessionSharingStringParam as readStringParam, + resolveDirectIncognitoTargets, + type SessionMutationTarget, +} from "./session-sharing-target-input.js"; import type { GatewaySessionStoreCache, GatewaySessionStoreDiscoveryCache, @@ -45,11 +51,6 @@ type SessionSharingTarget = { storePath: string; }; -type SessionMutationTarget = { - sessionKey: string; - agentId?: string; -}; - type AuthorizedSessionMutationTarget = SessionMutationTarget & { resolved: Pick< SessionSharingTarget, @@ -266,36 +267,6 @@ export function authorizeSessionSharingTarget(params: { }); } -function resolveDirectIncognitoTargets(method: string, params: unknown): SessionMutationTarget[] { - if (method === "sessions.create" || method === "sessions.list") { - return []; - } - if (!params || typeof params !== "object" || Array.isArray(params)) { - return []; - } - const record = params as Record; - const candidates = [record.key, record.sessionKey]; - if (Array.isArray(record.keys)) { - candidates.push(...record.keys); - } - if (Array.isArray(record.sessionKeys)) { - candidates.push(...record.sessionKeys); - } - const agentId = normalizeOptionalString(record.agentId); - return candidates.flatMap((candidate): SessionMutationTarget[] => - typeof candidate === "string" && isIncognitoSessionKey(candidate) - ? [{ sessionKey: candidate, ...(agentId ? { agentId } : {}) }] - : [], - ); -} - -function readStringParam(params: unknown, key: string): string | undefined { - if (!params || typeof params !== "object" || Array.isArray(params)) { - return undefined; - } - return normalizeOptionalString((params as Record)[key]); -} - const SESSION_KEY_PARAM_BY_METHOD = new Map([ ["agent", "sessionKey"], ["board.event", "sessionKey"], @@ -499,15 +470,35 @@ export function resolveSessionMutationAuthorization(params: { targetDiscoveryCache: GatewaySessionStoreDiscoveryCache; } => ({ storeCache: new Map(), targetDiscoveryCache: new Map() }); const lookupCaches = createLookupCaches(); + const resolveAuthorizedTarget = ( + targetRef: SessionMutationTarget, + ): { target: SessionSharingTarget | null } | { error: ErrorShape } => { + try { + return { + target: resolveSessionSharingTarget({ + cfg: getCfg(), + sessionKey: targetRef.sessionKey, + agentId: targetRef.agentId, + ...lookupCaches, + }), + }; + } catch (error) { + if (error instanceof AgentSelectionRequiredError) { + return { + error: errorShape(ErrorCodes.INVALID_REQUEST, error.message), + }; + } + throw error; + } + }; // Incognito direct reads and writes share this central participation choke point; // hidden keys use the stale-session refusal instead of revealing existence. for (const targetRef of resolveDirectIncognitoTargets(params.method, params.requestParams)) { - const target = resolveSessionSharingTarget({ - cfg: getCfg(), - sessionKey: targetRef.sessionKey, - agentId: targetRef.agentId, - ...lookupCaches, - }); + const resolved = resolveAuthorizedTarget(targetRef); + if ("error" in resolved) { + return { error: resolved.error }; + } + const target = resolved.target; const error = authorizeIncognitoSessionTarget({ client: params.client, sessionKey: targetRef.sessionKey, @@ -533,15 +524,13 @@ export function resolveSessionMutationAuthorization(params: { } return { error: null }; } - const cfg = getCfg(); const authorizedTargets: AuthorizedSessionMutationTarget[] = []; for (const targetRef of targetRefs) { - const target = resolveSessionSharingTarget({ - cfg, - sessionKey: targetRef.sessionKey, - agentId: targetRef.agentId, - ...lookupCaches, - }); + const resolved = resolveAuthorizedTarget(targetRef); + if ("error" in resolved) { + return { error: resolved.error }; + } + const target = resolved.target; const error = (params.method === "sessions.patchMany" ? authorizeIncognitoSessionTarget({ diff --git a/src/gateway/session-store-key.ts b/src/gateway/session-store-key.ts index 89ba6c017134..40fb7ff2514d 100644 --- a/src/gateway/session-store-key.ts +++ b/src/gateway/session-store-key.ts @@ -3,12 +3,13 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { AgentSelectionRequiredError, listAgentIds } from "../agents/agent-scope.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { canonicalizeMainSessionAlias, resolveAgentMainSessionKey, - resolveMainSessionKey, } from "../config/sessions/main-session.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { DEFAULT_AGENT_ID, @@ -32,8 +33,27 @@ export function canonicalizeSessionKeyForAgent(agentId: string, key: string): st return `agent:${normalizeAgentId(agentId)}:${normalized}`; } -function resolveDefaultStoreAgentId(cfg: OpenClawConfig): string { - return normalizeAgentId(resolveDefaultAgentId(cfg)); +// Logical unscoped keys must honor the durable fixed-store owner. The physical-store +// compatibility fallback is intentionally not used here because it can name a retired agent. +function resolveLogicalSessionStoreAgentId(cfg: OpenClawConfig, sessionKey: string): string { + const persistedOwner = resolvePersistedSessionStoreOwnerForKey(cfg, sessionKey); + if (persistedOwner.kind === "configured") { + return persistedOwner.agentId; + } + if (persistedOwner.kind === "retired") { + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: `session key "${sessionKey}"`, + hint: `Its recorded owner "${persistedOwner.agentId}" is no longer configured. Select a configured agent explicitly.`, + }); + } + const compatibilityAgentId = tryResolveLegacyCompatibilityAgentId(cfg); + if (compatibilityAgentId) { + return normalizeAgentId(compatibilityAgentId); + } + throw new AgentSelectionRequiredError(listAgentIds(cfg), { + surface: `session key "${sessionKey}"`, + hint: "Use an agent-prefixed session key or select an agent explicitly.", + }); } function shouldRemapLegacyDefaultMainAlias( @@ -45,13 +65,16 @@ function shouldRemapLegacyDefaultMainAlias( if (agentId !== DEFAULT_AGENT_ID || listAgentIds(cfg).includes(DEFAULT_AGENT_ID)) { return false; } - const defaultAgentId = resolveDefaultStoreAgentId(cfg); - if (options?.storeAgentId && normalizeAgentId(options.storeAgentId) !== defaultAgentId) { - return false; - } const rest = normalizeLowercaseStringOrEmpty(parsed.rest); const mainKey = normalizeMainKey(cfg.session?.mainKey); - return rest === "main" || rest === mainKey; + if (rest !== "main" && rest !== mainKey) { + return false; + } + if (options?.storeAgentId) { + return true; + } + resolveLogicalSessionStoreAgentId(cfg, "main"); + return true; } function resolveParsedSessionStoreKey( @@ -66,7 +89,9 @@ function resolveParsedSessionStoreKey( sessionKey: normalizeSessionKeyPreservingOpaquePeerIds(raw), }; } - const agentId = resolveDefaultStoreAgentId(cfg); + const agentId = options?.storeAgentId + ? normalizeAgentId(options.storeAgentId) + : resolveLogicalSessionStoreAgentId(cfg, "main"); const rest = normalizeLowercaseStringOrEmpty(parsed.rest); return { agentId, sessionKey: `agent:${agentId}:${rest}` }; } @@ -106,25 +131,28 @@ export function resolveSessionStoreKey(params: { const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey); const storeAgentId = params.storeAgentId ? normalizeAgentId(params.storeAgentId) : undefined; if (lowered === "main" || lowered === rawMainKey) { - if (storeAgentId) { - return resolveAgentMainSessionKey({ cfg: params.cfg, agentId: storeAgentId }); + if (params.cfg.session?.scope === "global") { + return "global"; } - return resolveMainSessionKey(params.cfg); + return resolveAgentMainSessionKey({ + cfg: params.cfg, + agentId: storeAgentId ?? resolveLogicalSessionStoreAgentId(params.cfg, raw), + }); } - const agentId = storeAgentId ?? resolveDefaultStoreAgentId(params.cfg); + const agentId = storeAgentId ?? resolveLogicalSessionStoreAgentId(params.cfg, raw); return canonicalizeSessionKeyForAgent(agentId, raw); } /** Resolve the agent that owns a canonical session-store key. */ export function resolveSessionStoreAgentId(cfg: OpenClawConfig, canonicalKey: string): string { if (canonicalKey === "global" || canonicalKey === "unknown") { - return resolveDefaultStoreAgentId(cfg); + return resolveLogicalSessionStoreAgentId(cfg, canonicalKey); } const parsed = parseAgentSessionKey(canonicalKey); if (parsed?.agentId) { return normalizeAgentId(parsed.agentId); } - return resolveDefaultStoreAgentId(cfg); + return resolveLogicalSessionStoreAgentId(cfg, canonicalKey); } /** Resolve a session key for lookup inside a specific agent's store. */ @@ -141,6 +169,16 @@ export function resolveStoredSessionKeyForAgentStore(params: { if (lowered === "global" || lowered === "unknown") { return lowered; } + const persistedOwner = resolvePersistedSessionStoreOwnerForKey(params.cfg, raw); + if ( + !parseAgentSessionKey(raw) && + persistedOwner.kind === "configured" && + persistedOwner.agentId === normalizeAgentId(params.agentId) && + lowered !== "main" && + lowered !== normalizeMainKey(params.cfg.session?.mainKey) + ) { + return raw; + } const key = parseAgentSessionKey(raw) ? raw : canonicalizeSessionKeyForAgent(params.agentId, raw); return resolveSessionStoreKey({ cfg: params.cfg, diff --git a/src/gateway/session-utils-list.ts b/src/gateway/session-utils-list.ts index 98f128cacd39..a7b0a3e95def 100644 --- a/src/gateway/session-utils-list.ts +++ b/src/gateway/session-utils-list.ts @@ -5,7 +5,6 @@ import { } from "@openclaw/normalization-core/string-coerce"; import type { SessionsListParams } from "../../packages/gateway-protocol/src/index.js"; import { readAcpSessionMetaBatch } from "../acp/runtime/session-meta.js"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { countActiveDescendantRuns, @@ -22,7 +21,10 @@ import { } from "../routing/session-key.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { type SessionEntryPair, sortAndLimitSessionEntries } from "./session-list-order.js"; -import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; +import { + resolveSessionStoreAgentId, + resolveStoredSessionKeyForAgentStore, +} from "./session-store-key.js"; import { readSessionTitleFieldsFromTranscriptBatch as readScopedSessionTitleFieldsFromTranscriptBatch } from "./session-transcript-title-reader.js"; import type { SessionActorProfileIdentity, @@ -139,9 +141,7 @@ function populateSessionListAcpMetadata(params: { const entries = params.entries.map(([key, entry]) => { const parsed = parseAgentSessionKey(key); const agentId = normalizeAgentId( - key === "global" && typeof params.opts.agentId === "string" - ? params.opts.agentId - : (parsed?.agentId ?? resolveDefaultAgentId(params.cfg)), + parsed?.agentId ?? params.opts.agentId ?? resolveSessionStoreAgentId(params.cfg, key), ); return { sessionKey: resolveStoredSessionKeyForAgentStore({ @@ -149,10 +149,14 @@ function populateSessionListAcpMetadata(params: { agentId, sessionKey: key, }), + agentId, entry, }; }); - params.rowContext.acpSessionMetaByEntry = readAcpSessionMetaBatch({ entries }); + params.rowContext.acpSessionMetaByEntry = readAcpSessionMetaBatch({ + entries, + cfg: params.cfg, + }); } function resolveSessionsListLimit( @@ -293,6 +297,7 @@ function filterSessionEntries(params: { shouldResolveDerivedSessionModelSearchFields(search) && matchesSessionListSearch( resolveSessionListSearchModelFields({ + ...(agentId ? { agentId } : {}), cfg, key, entry, @@ -431,6 +436,7 @@ function prepareSessionList(params: ListSessionsFromStoreParams) { function buildSessionsListResult(params: { cfg: OpenClawConfig; + agentId?: string; list: ReturnType; modelCatalog?: ModelCatalogEntry[]; sessions: GatewaySessionRow[]; @@ -447,6 +453,7 @@ function buildSessionsListResult(params: { hasMore: list.hasMore, creators: list.creators, defaults: getSessionDefaults(params.cfg, params.modelCatalog, { + ...(params.agentId ? { agentId: params.agentId } : {}), allowPluginNormalization: false, }), sessions, @@ -468,7 +475,7 @@ export function listSessionsFromStore(params: ListSessionsFromStoreParams): Sess const sessions = list.entries.map(([key, entry], index) => { const includeTranscriptFields = index < SESSIONS_LIST_TRANSCRIPT_FIELD_ROWS; const rowAgentId = - key === "global" && typeof opts.agentId === "string" + !parseAgentSessionKey(key) && typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : undefined; const storeChildSessionsByKey = @@ -497,7 +504,13 @@ export function listSessionsFromStore(params: ListSessionsFromStoreParams): Sess lightweightListRow: params.lightweightListRows === true, }); }); - return buildSessionsListResult({ cfg, list, modelCatalog: params.modelCatalog, sessions }); + return buildSessionsListResult({ + cfg, + list, + modelCatalog: params.modelCatalog, + sessions, + agentId: opts.agentId, + }); } /** @@ -529,12 +542,9 @@ export async function listSessionsFromStoreAsync( return []; } const parsed = parseAgentSessionKey(key); - const agentId = - key === "global" && typeof opts.agentId === "string" - ? normalizeAgentId(opts.agentId) - : parsed?.agentId - ? normalizeAgentId(parsed.agentId) - : resolveDefaultAgentId(cfg); + const agentId = normalizeAgentId( + parsed?.agentId ?? opts.agentId ?? resolveSessionStoreAgentId(cfg, key), + ); return [ { agentId, @@ -551,7 +561,7 @@ export async function listSessionsFromStoreAsync( const [key, entry] = expectDefined(list.entries[i], "entries entry at i"); const includeTranscriptFields = i < SESSIONS_LIST_TRANSCRIPT_FIELD_ROWS; const rowAgentId = - key === "global" && typeof opts.agentId === "string" + !parseAgentSessionKey(key) && typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : undefined; const storeChildSessionsByKey = @@ -606,6 +616,12 @@ export async function listSessionsFromStoreAsync( } } - return buildSessionsListResult({ cfg, list, modelCatalog: params.modelCatalog, sessions }); + return buildSessionsListResult({ + cfg, + list, + modelCatalog: params.modelCatalog, + sessions, + agentId: opts.agentId, + }); }); } diff --git a/src/gateway/session-utils-model.acp-owner.test.ts b/src/gateway/session-utils-model.acp-owner.test.ts new file mode 100644 index 000000000000..f6afd4dae0d5 --- /dev/null +++ b/src/gateway/session-utils-model.acp-owner.test.ts @@ -0,0 +1,36 @@ +// Session model projection tests verify ACP metadata reads preserve row ownership. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const readAcpSessionMeta = vi.hoisted(() => vi.fn(() => undefined)); + +vi.mock("../acp/runtime/session-meta.js", () => ({ readAcpSessionMeta })); + +import { resolveGatewaySessionThinkingProjectionInternal } from "./session-utils-model.js"; + +describe("resolveGatewaySessionThinkingProjectionInternal", () => { + beforeEach(() => { + readAcpSessionMeta.mockClear(); + }); + + it("reads bare-key ACP metadata under the resolved row owner", () => { + const cfg: OpenClawConfig = { + session: { scope: "global", store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }; + + resolveGatewaySessionThinkingProjectionInternal({ + cfg, + agentId: "ops", + provider: "openai", + model: "gpt-5.6-sol", + sessionKey: "global", + }); + + expect(readAcpSessionMeta).toHaveBeenCalledWith({ sessionKey: "global", agentId: "ops" }); + }); +}); diff --git a/src/gateway/session-utils-model.ts b/src/gateway/session-utils-model.ts index 9224ec97959a..ec3bbaae00ee 100644 --- a/src/gateway/session-utils-model.ts +++ b/src/gateway/session-utils-model.ts @@ -5,11 +5,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { readAcpSessionMeta } from "../acp/runtime/session-meta.js"; import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; -import { - resolveAgentConfig, - resolveDefaultAgentId, - resolveSessionAgentId, -} from "../agents/agent-scope.js"; +import { resolveAgentConfig, resolveSessionAgentId } from "../agents/agent-scope.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { @@ -38,9 +34,10 @@ import { normalizeThinkLevel, resolveSupportedThinkingLevel, } from "../auto-reply/thinking.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { resolveAgentMainSessionKey, type SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import type { GatewayModelCatalogSnapshot } from "./server-model-catalog.types.js"; import { createSessionRowModelCacheKey, @@ -198,7 +195,7 @@ export function resolveGatewaySessionThinkingProjectionInternal( params.entry?.acp ?? (params.entry && cachedAcpMeta?.has(params.entry) ? cachedAcpMeta.get(params.entry) - : readAcpSessionMeta({ sessionKey: params.sessionKey })); + : readAcpSessionMeta({ sessionKey: params.sessionKey, agentId: params.agentId })); const configuredAgentRuntime = resolveModelAgentRuntimeMetadata({ cfg: params.cfg, agentId: params.agentId, @@ -273,19 +270,28 @@ export function resolveGatewaySessionThinkingProjectionInternal( export function getSessionDefaults( cfg: OpenClawConfig, modelCatalog?: ModelCatalogEntry[], - options?: { allowPluginNormalization?: boolean }, + options?: { agentId?: string; allowPluginNormalization?: boolean }, ): GatewaySessionsDefaults { - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - allowPluginNormalization: options?.allowPluginNormalization, - }); + const agentId = normalizeAgentId( + options?.agentId ?? tryResolveLegacyCompatibilityAgentId(cfg) ?? LEGACY_IMPLICIT_AGENT_ID, + ); + const resolved = options?.agentId + ? resolveDefaultModelForAgent({ + cfg, + agentId, + allowPluginNormalization: options.allowPluginNormalization, + }) + : resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + allowPluginNormalization: options?.allowPluginNormalization, + }); const contextTokens = + resolveAgentConfig(cfg, agentId)?.contextTokens ?? cfg.agents?.defaults?.contextTokens ?? lookupContextTokens(resolved.model, { allowAsyncLoad: false }) ?? DEFAULT_CONTEXT_TOKENS; - const agentId = normalizeAgentId(resolveDefaultAgentId(cfg)); const sessionKey = resolveAgentMainSessionKey({ cfg, agentId }); const agentRuntime = resolveModelAgentRuntimeMetadata({ cfg, @@ -558,7 +564,7 @@ export async function projectSessionPatchResult(params: { const agentId = resolveSessionAgentId({ config: params.cfg, sessionKey: params.canonicalKey, - ...(params.canonicalKey === "global" ? { agentId: params.targetAgentId } : {}), + agentId: params.targetAgentId, }); const resolved = resolveSessionModelRef(params.cfg, params.entry, agentId); const displayModel = resolveSessionDisplayModelIdentityRef({ diff --git a/src/gateway/session-utils-projection.ts b/src/gateway/session-utils-projection.ts index 90c1c597fe0a..c9aea3e65f90 100644 --- a/src/gateway/session-utils-projection.ts +++ b/src/gateway/session-utils-projection.ts @@ -1,5 +1,4 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveContextTokensForModel } from "../agents/context.js"; import { normalizeStoredOverrideModel } from "../agents/model-selection.js"; import { resolveSessionModelRef } from "../agents/session-model-ref.js"; @@ -8,6 +7,7 @@ import { resolveSessionStorePathCore, type SessionEntry } from "../config/sessio import { resolveConcreteSessionStorePath } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import { resolveSessionStoreAgentId } from "./session-store-key.js"; import { readRecentSessionUsageFromTranscript as readScopedRecentSessionUsageFromTranscript } from "./session-transcript-readers.js"; import type { SessionActorProfileIdentity, @@ -171,7 +171,7 @@ export function resolveTranscriptUsageFallback(params: { const parsed = parseAgentSessionKey(params.key); const agentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) - : normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.cfg)); + : normalizeAgentId(params.agentId ?? resolveSessionStoreAgentId(params.cfg, params.key)); const storePath = resolveConcreteSessionStorePath(params.storePath) ?? resolveSessionStorePathCore(params.cfg.session?.store, { agentId }); diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 5d69e458cda2..30f7436534b4 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -1,7 +1,6 @@ import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { SessionCreatedActor } from "../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; @@ -35,7 +34,10 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js" import { resolveCurrentUserProfileDisplay } from "./current-user-profile-display.js"; import { sessionHasAutomation } from "./session-automation-index.js"; import { sessionClassificationForRow } from "./session-classification.js"; -import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; +import { + resolveSessionStoreAgentId, + resolveStoredSessionKeyForAgentStore, +} from "./session-store-key.js"; import { readSessionTitleFieldsFromTranscript as readScopedSessionTitleFieldsFromTranscript } from "./session-transcript-title-reader.js"; import type { SessionActorProfileIdentity, @@ -158,7 +160,7 @@ export function buildGatewaySessionRow(params: { // titles leaks account names into the sidebar while the generated title is pending. (isDashboardSession ? undefined : originLabel); const sessionAgentId = normalizeAgentId( - parsedAgent?.agentId ?? params.agentId ?? resolveDefaultAgentId(cfg), + parsedAgent?.agentId ?? params.agentId ?? resolveSessionStoreAgentId(cfg, key), ); const skipTranscriptUsage = params.skipTranscriptUsageFallback === true; const rowContext = params.rowContext; @@ -482,7 +484,7 @@ export function buildGatewaySessionRow(params: { estimatedCostUsd, status: subagentRun ? subagentStatus : entry?.status, lastRunError: entry?.lastRunError, - hasAutomation: sessionHasAutomation(key, cfg) ? true : undefined, + hasAutomation: sessionHasAutomation(key, cfg, sessionAgentId) ? true : undefined, subagentRunState, hasActiveSubagentRun: subagentRun ? liveSubagentRunActive : undefined, startedAt: subagentRun ? subagentStartedAt : entry?.startedAt, diff --git a/src/gateway/session-utils-search.ts b/src/gateway/session-utils-search.ts index c00892750454..e74f5a436ef7 100644 --- a/src/gateway/session-utils-search.ts +++ b/src/gateway/session-utils-search.ts @@ -2,7 +2,6 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { DEFAULT_MODEL } from "../agents/defaults.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveSessionModelIdentityRef } from "../agents/session-model-ref.js"; @@ -15,6 +14,7 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { sessionDeliveryChannel, sessionDeliveryOrigin } from "../utils/delivery-context.shared.js"; +import { resolveSessionStoreAgentId } from "./session-store-key.js"; import type { SessionListRowContext, SessionListRowContextProvider, @@ -101,13 +101,16 @@ export function resolveSessionListRowContext(params: { } export function resolveSessionListSearchModelFields(params: { + agentId?: string; cfg: OpenClawConfig; key: string; entry?: SessionEntry; rowContext?: SessionListRowContext; }): Array { const parsedAgent = parseAgentSessionKey(params.key); - const agentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(params.cfg)); + const agentId = normalizeAgentId( + parsedAgent?.agentId ?? params.agentId ?? resolveSessionStoreAgentId(params.cfg, params.key), + ); const subagentRun = params.rowContext ? params.rowContext.subagentRuns.getDisplaySubagentRun(params.key) : getSessionDisplaySubagentRunByChildSessionKey(params.key); diff --git a/src/gateway/session-utils-store.ts b/src/gateway/session-utils-store.ts index f047946fda2d..7c62549404b2 100644 --- a/src/gateway/session-utils-store.ts +++ b/src/gateway/session-utils-store.ts @@ -32,6 +32,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { isAcpSessionKey } from "../sessions/session-key-utils.js"; import { listGatewayAgentsBasic } from "./agent-list.js"; +import type { GatewayAgentOwnership } from "./agent-list.js"; +import { tryResolveSessionCompatibilityOwnerAgentId } from "./session-request-agent.js"; import { resolveGatewayModelThinkingProfile } from "./session-utils-model.js"; import { resolveGatewaySessionStoreTarget, @@ -99,8 +101,12 @@ function readAcpMetaForDeletedAgentCheck(params: { directKeys.add(params.sessionKey); for (const directKey of directKeys) { + const agentId = + parseAgentSessionKey(directKey)?.agentId ?? + tryResolveSessionCompatibilityOwnerAgentId(params.cfg, directKey); const acpMeta = readAcpSessionMetaForEntry({ sessionKey: directKey, + ...(agentId ? { agentId } : {}), entry: params.entry ?? undefined, }); if (acpMeta) { @@ -113,8 +119,12 @@ function readAcpMetaForDeletedAgentCheck(params: { candidateSessionKeys: directKeys, entry: params.entry ?? undefined, }); + const finalAgentId = + parseAgentSessionKey(params.sessionKey)?.agentId ?? + tryResolveSessionCompatibilityOwnerAgentId(params.cfg, params.sessionKey); return readAcpSessionMetaForEntry({ sessionKey: params.sessionKey, + ...(finalAgentId ? { agentId: finalAgentId } : {}), entry: params.entry ?? undefined, }); } @@ -149,6 +159,7 @@ function loadSessionEntryWithMode( : canonicalMatch?.entry; return { cfg, + agentId: target.agentId, storePath, store, entry, @@ -301,6 +312,8 @@ export function listAgentsForGateway( }, ): { defaultId: string; + ownership: GatewayAgentOwnership; + selectionRequired: boolean; mainKey: string; scope: SessionScope; agents: GatewayAgentRow[]; @@ -372,5 +385,12 @@ export function listAgentsForGateway( model ? { model } : {}, ); }); - return { defaultId: basic.defaultId, mainKey: basic.mainKey, scope: basic.scope, agents }; + return { + defaultId: basic.defaultId, + ownership: basic.ownership!, + selectionRequired: basic.selectionRequired!, + mainKey: basic.mainKey, + scope: basic.scope, + agents, + }; } diff --git a/src/gateway/session-utils.perf.test.ts b/src/gateway/session-utils.perf.test.ts index 90a412b3e623..087298bd3c62 100644 --- a/src/gateway/session-utils.perf.test.ts +++ b/src/gateway/session-utils.perf.test.ts @@ -210,8 +210,8 @@ describe("listSessionsFromStore resolver cache", () => { return originalPrepare(sql); }); try { - // Cross the production 500-key chunk boundary without materializing - // tens of thousands of rows just to prove the same batching behavior. + // Composite and legacy identities share the production 500-key chunks. + // Cross two boundaries without materializing tens of thousands of rows. const aboveBatchChunkSize = Array.from({ length: 501 }, (_, index) => ({ sessionKey: `agent:default:webchat:dm:missing-${index}`, entry: { @@ -223,7 +223,7 @@ describe("listSessionsFromStore resolver cache", () => { expect(chunkedBatch.size).toBe(aboveBatchChunkSize.length); expect(chunkedBatch.get(aboveBatchChunkSize[0]!.entry)).toBeUndefined(); expect(chunkedBatch.get(aboveBatchChunkSize.at(-1)!.entry)).toBeUndefined(); - expect(acpSelects).toBe(2); + expect(acpSelects).toBe(3); acpSelects = 0; const result = listSessionsFromStore({ diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 9a3d9ce261ee..567b5934f6a8 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -5,8 +5,10 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, onTestFinished, test, vi } from "vitest"; import { writeAcpSessionMetaForMigration } from "../acp/runtime/session-meta.js"; +import { resolveLegacyInheritedAuthAgentId } from "../agents/legacy-inherited-auth-dir.js"; import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import type { SessionEntry } from "../config/sessions.js"; import { appendTranscriptMessageSync, @@ -26,7 +28,7 @@ import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared. import type { GatewayModelCatalogSnapshot } from "./server-model-catalog.types.js"; import { registerSessionAutomationSource } from "./session-automation-index.js"; import { buildGatewaySessionEventFields } from "./session-event-payload.js"; -import { resolveSessionStoreKey } from "./session-store-key.js"; +import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js"; import { deriveSessionTitle } from "./session-utils-core.js"; import { listSessionsFromStore, listSessionsFromStoreAsync } from "./session-utils-list.js"; import { getSessionDefaults, resolveGatewayModelSupportsImages } from "./session-utils-model.js"; @@ -63,6 +65,31 @@ function closeSessionSqliteDatabasesForTest(): void { closeOpenClawStateDatabaseForTest(); } +test("resolves fixed-store and auth compatibility owners", () => { + const cfg = retainLegacyDefaultAgentId( + { + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: " " } }, + entries: { ops: {}, research: {} }, + }, + session: { mainKey: "work", store: "/tmp/openclaw-fixed-sessions.json" }, + }, + "ops", + ); + expect(resolveSessionStoreKey({ cfg, sessionKey: "incident-42" })).toBe("agent:ops:incident-42"); + const explicit = { agents: { ownership: "explicit" as const, entries: { a: {}, b: {} } } }; + expect( + resolveLegacyInheritedAuthAgentId({ + ...explicit, + agents: { ...explicit.agents, defaults: { authInheritance: { agentId: "saved" } } }, + }), + ).toBe("saved"); + expect(resolveLegacyInheritedAuthAgentId(explicit)).toBe("main"); + expect(resolveLegacyInheritedAuthAgentId(retainLegacyDefaultAgentId(explicit, "a"))).toBe("a"); + expect(resolveLegacyInheritedAuthAgentId({ agents: { entries: { solo: {} } } })).toBe("solo"); +}); + async function withStateDirEnv( prefix: string, fn: (ctx: { tempRoot: string; stateDir: string }) => Promise, @@ -1828,6 +1855,21 @@ describe("gateway session utils", () => { ); }); + test("resolveSessionStoreKey preserves an explicit retired store's non-main key", () => { + const cfg = { + session: { mainKey: "work" }, + agents: { ownership: "explicit", entries: { ops: {}, research: {} } }, + } as OpenClawConfig; + + expect( + resolveSessionStoreKey({ + cfg, + sessionKey: "agent:main:history", + storeAgentId: "main", + }), + ).toBe("agent:main:history"); + }); + test("resolveDeletedAgentIdFromSessionKey rejects non-alias main keys when main is absent", () => { const cfg = { session: { mainKey: "work" }, @@ -1958,14 +2000,47 @@ describe("gateway session utils", () => { ); }); - test("resolveSessionStoreKey falls back to first list entry when no agent is marked default", () => { + test("resolveSessionStoreKey rejects ownerless bare keys without a compatibility owner", () => { const cfg = { session: { mainKey: "main" }, agents: { list: [{ id: "ops" }, { id: "review" }] }, } as OpenClawConfig; + expect(() => resolveSessionStoreKey({ cfg, sessionKey: "main" })).toThrowError( + expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" }), + ); + expect(() => resolveSessionStoreKey({ cfg, sessionKey: "discord:group:123" })).toThrowError( + expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" }), + ); + }); + + test("resolveSessionStoreKey uses configured fixed-store ownership for bare keys", () => { + const cfg = { + session: { mainKey: "main", store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } as OpenClawConfig; expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:ops:main"); - expect(resolveSessionStoreKey({ cfg, sessionKey: "discord:group:123" })).toBe( - "agent:ops:discord:group:123", + expect(resolveSessionStoreKey({ cfg, sessionKey: "thread-1" })).toBe("agent:ops:thread-1"); + expect(resolveSessionStoreAgentId(cfg, "global")).toBe("ops"); + }); + + test("session-store key ownership rejects a retired fixed-store owner", () => { + const cfg = { + session: { mainKey: "main", store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "retired" } }, + entries: { ops: {}, research: {} }, + }, + } as OpenClawConfig; + expect(() => resolveSessionStoreKey({ cfg, sessionKey: "thread-1" })).toThrowError( + expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" }), + ); + expect(() => resolveSessionStoreAgentId(cfg, "global")).toThrowError( + expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" }), ); }); @@ -3114,6 +3189,35 @@ describe("listSessionsFromStore selected model display", () => { }); }); + test("searches a selected agent's global row in an ownerless explicit fleet", () => { + const now = Date.now(); + const result = listSessionsFromStore({ + cfg: { + agents: { + ownership: "explicit", + defaults: { model: { primary: "openai/gpt-5.4" } }, + entries: { + main: { model: { primary: "openai/gpt-5.4" } }, + work: { model: { primary: "anthropic/claude-opus-4-6" } }, + }, + }, + } as OpenClawConfig, + storePath: "/tmp/sessions.json", + store: { + global: { sessionId: "global", updatedAt: now } as SessionEntry, + }, + opts: { agentId: "work", includeGlobal: true, search: "claude-opus" }, + }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + key: "global", + agentId: "work", + modelProvider: "anthropic", + model: "claude-opus-4-6", + }); + }); + test("filters phantom agent store placeholder rows from session lists", () => { const now = Date.now(); const result = listSessionsFromStore({ diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index cf5fe679a8ec..6e53f8d30427 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -1401,6 +1401,7 @@ describe("gateway sessions patch", () => { expectPatchError(result, 'thinkingLevel "ultra" is not supported'); expect(acpSessionMetaMocks.readAcpSessionMetaForEntry).toHaveBeenCalledWith({ sessionKey: MAIN_SESSION_KEY, + agentId: "main", entry: expect.objectContaining({ sessionId: "sess" }), }); }); diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 9b59031c4e00..aea41948540b 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -207,7 +207,11 @@ export async function projectSessionsPatchEntry(params: { ): string => { // ACP metadata can own canonical agent keys (for example agent:main:main), // so key shape alone cannot identify the runtime that validates thinking. - const acpMeta = readAcpSessionMetaForEntry({ sessionKey: storeKey, entry }); + const acpMeta = readAcpSessionMetaForEntry({ + sessionKey: storeKey, + agentId: sessionAgentId, + entry, + }); return ( acpMeta?.backend ?? resolveEffectiveAgentRuntime({ diff --git a/src/gateway/sessions-resolve-store.test.ts b/src/gateway/sessions-resolve-store.test.ts index d64ec53a4710..2d4ccc00dfd3 100644 --- a/src/gateway/sessions-resolve-store.test.ts +++ b/src/gateway/sessions-resolve-store.test.ts @@ -73,14 +73,14 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { cfg, p: { sessionId: "sess-default-alias" }, }), - ).resolves.toEqual({ ok: true, key: "agent:ops:main" }); + ).resolves.toEqual({ ok: true, key: "agent:ops:main", agentId: "ops" }); await expect( resolveSessionKeyFromResolveParams({ cfg, p: { label: "default-alias" }, }), - ).resolves.toEqual({ ok: true, key: "agent:ops:main" }); + ).resolves.toEqual({ ok: true, key: "agent:ops:main", agentId: "ops" }); }); }); @@ -126,6 +126,36 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { }); }); + it("rejects an exact bare key scoped to a different fixed-store owner", async () => { + await withStateDirEnv("openclaw-sessions-resolve-owner-conflict-", async ({ stateDir }) => { + const storePath = path.join(stateDir, "shared-sessions.sqlite"); + const cfg = { + session: { store: storePath, scope: "global" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + } satisfies OpenClawConfig; + await seedSessionStore(storePath, { + global: { sessionId: "sess-owned-global", updatedAt: freshUpdatedAt() }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { key: "global", agentId: "research" }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: 'agent "research" does not match session key agent "ops"', + }, + }); + }); + }); + it("preserves cross-agent ambiguity when agentId is absent", async () => { await withStateDirEnv("openclaw-sessions-resolve-cross-agent-", async () => { const cfg: OpenClawConfig = { @@ -179,6 +209,48 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { }); }); + it("resolves duplicate bare global rows with the selected row owner", async () => { + await withStateDirEnv("openclaw-sessions-resolve-global-owner-", async () => { + const cfg: OpenClawConfig = { + agents: { + ownership: "explicit", + list: [{ id: "ops" }, { id: "research" }], + }, + }; + await seedSessionStore(resolveSessionStorePathCore(cfg.session?.store, { agentId: "ops" }), { + global: { sessionId: "session-ops", updatedAt: freshUpdatedAt() }, + }); + await seedSessionStore( + resolveSessionStorePathCore(cfg.session?.store, { agentId: "research" }), + { + global: { sessionId: "session-research", updatedAt: freshUpdatedAt() }, + }, + ); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { sessionId: "session-research", includeGlobal: true }, + }), + ).resolves.toEqual({ ok: true, key: "global", agentId: "research" }); + }); + }); + + it("selects the deterministic winner within one agent before cross-agent checks", async () => { + await withStateDirEnv("openclaw-sessions-resolve-same-agent-", async () => { + const cfg: OpenClawConfig = { agents: { list: [{ id: "main" }] } }; + const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId: "main" }); + await seedSessionStore(storePath, { + "agent:main:older": { sessionId: "session-duplicate", updatedAt: 10 }, + "agent:main:newer": { sessionId: "session-duplicate", updatedAt: 20 }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ cfg, p: { sessionId: "session-duplicate" } }), + ).resolves.toEqual({ ok: true, key: "agent:main:newer", agentId: "main" }); + }); + }); + it("still rejects non-alias agent:main matches when main is no longer configured", async () => { await withStateDirEnv("openclaw-sessions-resolve-stale-main-", async () => { const cfg = { @@ -286,21 +358,21 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { cfg, p: { key: acpKey }, }), - ).resolves.toEqual({ ok: true, key: acpKey }); + ).resolves.toEqual({ ok: true, key: acpKey, agentId: "claude" }); await expect( resolveSessionKeyFromResolveParams({ cfg, p: { sessionId: "sess-acp-harness" }, }), - ).resolves.toEqual({ ok: true, key: acpKey }); + ).resolves.toEqual({ ok: true, key: acpKey, agentId: "claude" }); await expect( resolveSessionKeyFromResolveParams({ cfg, p: { label: "claude-delegate" }, }), - ).resolves.toEqual({ ok: true, key: acpKey }); + ).resolves.toEqual({ ok: true, key: acpKey, agentId: "claude" }); }); }); @@ -339,14 +411,14 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { cfg, p: { key: acpKey }, }), - ).resolves.toEqual({ ok: true, key: acpKey }); + ).resolves.toEqual({ ok: true, key: acpKey, agentId: "claude" }); await expect( resolveSessionKeyFromResolveParams({ cfg, p: { key: acpKey }, }), - ).resolves.toEqual({ ok: true, key: acpKey }); + ).resolves.toEqual({ ok: true, key: acpKey, agentId: "claude" }); }); }); diff --git a/src/gateway/sessions-resolve.test.ts b/src/gateway/sessions-resolve.test.ts index 7607df02acdf..4306cce349e4 100644 --- a/src/gateway/sessions-resolve.test.ts +++ b/src/gateway/sessions-resolve.test.ts @@ -58,6 +58,7 @@ describe("resolveSessionKeyFromResolveParams", () => { ).resolves.toEqual({ ok: true, key: canonicalKey, + agentId: "main", }); expect(hoisted.listSessionsFromStoreMock).not.toHaveBeenCalled(); }; @@ -192,6 +193,7 @@ describe("resolveSessionKeyFromResolveParams", () => { ).resolves.toEqual({ ok: true, key: acpKey, + agentId: "claude", }); }); @@ -262,7 +264,7 @@ describe("resolveSessionKeyFromResolveParams", () => { p: { sessionId: "sess-target", agentId: "main" }, }); - expect(result).toEqual({ ok: true, key: "agent:main:target" }); + expect(result).toEqual({ ok: true, key: "agent:main:target", agentId: "main" }); expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, { agentId: "main", }); @@ -288,7 +290,7 @@ describe("resolveSessionKeyFromResolveParams", () => { cfg: {}, p: { shortId: "ABCDEF12", agentId: "main" }, }), - ).resolves.toEqual({ ok: true, key }); + ).resolves.toEqual({ ok: true, key, agentId: "main" }); }); it("uses a display-name slug only to narrow a short-id tie", async () => { @@ -307,7 +309,7 @@ describe("resolveSessionKeyFromResolveParams", () => { cfg: {}, p: { shortId: "12345678", slugHint: "deploy-monitor" }, }), - ).resolves.toEqual({ ok: true, key: deployKey }); + ).resolves.toEqual({ ok: true, key: deployKey, agentId: "main" }); }); it("ignores a deleted-agent short-id collision before resolving a unique match", async () => { @@ -326,7 +328,7 @@ describe("resolveSessionKeyFromResolveParams", () => { cfg: {}, p: { shortId: "12345678", slugHint: "deleted-session" }, }), - ).resolves.toEqual({ ok: true, key: survivingKey }); + ).resolves.toEqual({ ok: true, key: survivingKey, agentId: "main" }); }); it("reports a deleted-agent-only short-id match as missing", async () => { @@ -373,6 +375,7 @@ describe("resolveSessionKeyFromResolveParams", () => { ambiguous: true, candidates: expectedKeys.map((key, index) => ({ key, + agentId: "main", displayName: `Candidate ${index}`, })), }); @@ -394,7 +397,7 @@ describe("resolveSessionKeyFromResolveParams", () => { cfg: { agents: { list: [{ id: "main", default: true }, { id: "work" }] } }, p: { shortId: "feedface", agentId: "main" }, }), - ).resolves.toEqual({ ok: true, key: mainKey }); + ).resolves.toEqual({ ok: true, key: mainKey, agentId: "main" }); }); it("supports allowMissing for short ids", async () => { diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index acc6b7cf001d..77e405fdf523 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -13,12 +13,14 @@ import { SESSION_UUID_SUFFIX_RE, SHORT_SESSION_ID_RE, } from "../../packages/session-url-contract/src/index.js"; +import { listAgentIds } from "../agents/agent-scope.js"; import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveSessionIdMatchSelection } from "../sessions/session-id-resolution.js"; import { parseSessionLabel } from "../sessions/session-label.js"; import type { GatewayClient } from "./server-methods/types.js"; +import { resolveRequestedSessionAgentId } from "./session-request-agent.js"; import { createSessionListEntryFilter } from "./session-sharing.js"; import { buildGatewaySessionInfo, @@ -29,10 +31,10 @@ import { resolveGatewaySessionStoreTargetWithStore, } from "./session-utils.js"; -type SessionsResolveCandidate = { key: string; displayName?: string }; +type SessionsResolveCandidate = { key: string; agentId: string; displayName?: string }; export type SessionsResolveResult = - | { ok: true; key: string } + | { ok: true; key: string; agentId: string } | { ok: true; missing: true } | { ok: true; ambiguous: true; candidates: SessionsResolveCandidate[] } | { ok: false; error: ErrorShape }; @@ -100,14 +102,12 @@ function findVisibleSessionIdMatches(params: { sessionId: string; entryFilter?: (key: string, entry: SessionEntry) => boolean; }): Array<[string, SessionEntry]> { - const now = Date.now(); - const entries = filterAndSortSessionEntries({ + return filterAndSortSessionEntries({ cfg: params.cfg, store: params.store, - now, + now: Date.now(), opts: resolveSessionVisibilityFilterOptions(params.p), - }); - return entries.filter( + }).filter( ([key, entry]) => (params.entryFilter?.(key, entry) ?? true) && (entry?.sessionId === params.sessionId || key === params.sessionId), @@ -152,7 +152,16 @@ function findVisibleShortIdMatches(params: { entry, now, }); - return [{ key, ...(row.displayName ? { displayName: row.displayName } : {}) }]; + return [ + { + key, + agentId: expectDefined( + row.agentId ?? parseAgentSessionKey(key)?.agentId ?? params.p.agentId, + "short-id session agent", + ), + ...(row.displayName ? { displayName: row.displayName } : {}), + }, + ]; }); } @@ -201,7 +210,16 @@ export async function resolveSessionKeyFromResolveParams(params: { if (hasKey) { // Exact-key lookup follows the proof-of-knowledge read semantics of get/describe/history; // only discovery selectors use list visibility. Incognito keys are gated pre-dispatch. - const target = resolveGatewaySessionStoreTargetWithStore({ cfg, key, clone: false }); + const requestedAgent = resolveRequestedSessionAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + return requestedAgent; + } + const target = resolveGatewaySessionStoreTargetWithStore({ + cfg, + key, + clone: false, + ...(requestedAgent.agentId ? { agentId: requestedAgent.agentId } : {}), + }); const store = target.store; if (store[target.canonicalKey]) { if ( @@ -223,14 +241,77 @@ export async function resolveSessionKeyFromResolveParams(params: { if (agentCheck) { return agentCheck; } - return { ok: true, key: target.canonicalKey }; + return { ok: true, key: target.canonicalKey, agentId: requestedAgent.agentId }; } return noSessionFoundResult({ p, message: `No session found: ${key}` }); } if (hasSessionId) { - // sessionId can collide across stores; delegate selection so exact key - // matches and ambiguity rules stay shared with other session-id callers. + if (!p.agentId) { + const ownerTaggedMatches = new Map< + string, + { agentId: string; entry: SessionEntry; key: string } + >(); + for (const agentId of listAgentIds(cfg)) { + const loaded = loadCombinedSessionStoreForGatewayCore(cfg, { agentId }); + const agentMatches = findVisibleSessionIdMatches({ + cfg, + store: loaded.store, + p: { ...p, agentId }, + sessionId, + entryFilter, + }); + const agentSelection = resolveSessionIdMatchSelection(agentMatches, sessionId); + if (agentSelection.kind === "ambiguous") { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `Multiple sessions found for sessionId: ${sessionId} (${agentSelection.sessionKeys.join(", ")})`, + ), + }; + } + if (agentSelection.kind === "selected") { + const entry = agentMatches.find( + ([matchKey]) => matchKey === agentSelection.sessionKey, + )?.[1]; + const owner = resolveRequestedSessionAgentId(cfg, agentSelection.sessionKey, agentId); + if (entry && owner.ok) { + ownerTaggedMatches.set(`${owner.agentId}\0${agentSelection.sessionKey}`, { + agentId: owner.agentId, + entry, + key: agentSelection.sessionKey, + }); + } + } + } + if (ownerTaggedMatches.size > 1) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `Multiple sessions found for sessionId: ${sessionId} (${[...ownerTaggedMatches.values()] + .map((match) => `${match.agentId}:${match.key}`) + .join(", ")})`, + ), + }; + } + const ownerTaggedMatch = ownerTaggedMatches.values().next().value; + if (ownerTaggedMatch) { + const agentCheck = validateSessionAgentExists( + cfg, + ownerTaggedMatch.key, + ownerTaggedMatch.entry, + ); + return ( + agentCheck ?? { + ok: true, + key: ownerTaggedMatch.key, + agentId: ownerTaggedMatch.agentId, + } + ); + } + } const { store } = loadCombinedSessionStoreForGatewayCore(cfg, { agentId: p.agentId }); const matches = findVisibleSessionIdMatches({ cfg, store, p, sessionId, entryFilter }); const selection = resolveSessionIdMatchSelection(matches, sessionId); @@ -238,16 +319,23 @@ export async function resolveSessionKeyFromResolveParams(params: { return noSessionFoundResult({ p, message: `No session found: ${sessionId}` }); } if (selection.kind === "ambiguous") { - const keys = selection.sessionKeys.join(", "); return { ok: false, error: errorShape( ErrorCodes.INVALID_REQUEST, - `Multiple sessions found for sessionId: ${sessionId} (${keys})`, + `Multiple sessions found for sessionId: ${sessionId} (${selection.sessionKeys.join(", ")})`, ), }; } const selectedEntry = matches.find(([matchKey]) => matchKey === selection.sessionKey)?.[1]; + let selectedAgentId = parseAgentSessionKey(selection.sessionKey)?.agentId ?? p.agentId; + if (!selectedAgentId) { + const resolvedOwner = resolveRequestedSessionAgentId(cfg, selection.sessionKey); + if (!resolvedOwner.ok) { + return resolvedOwner; + } + selectedAgentId = resolvedOwner.agentId; + } const agentCheckSessionId = validateSessionAgentExists( cfg, selection.sessionKey, @@ -256,7 +344,7 @@ export async function resolveSessionKeyFromResolveParams(params: { if (agentCheckSessionId) { return agentCheckSessionId; } - return { ok: true, key: selection.sessionKey }; + return { ok: true, key: selection.sessionKey, agentId: selectedAgentId }; } if (hasShortId) { @@ -295,7 +383,7 @@ export async function resolveSessionKeyFromResolveParams(params: { return { ok: true, ambiguous: true, candidates: narrowed.slice(0, 10) }; } const selected = expectDefined(narrowed[0], "short session match at 0"); - return { ok: true, key: selected.key }; + return { ok: true, key: selected.key, agentId: selected.agentId }; } const parsedLabel = parseSessionLabel(p.label); @@ -347,5 +435,11 @@ export async function resolveSessionKeyFromResolveParams(params: { return { ok: true, key: labelKey, + agentId: expectDefined( + expectDefined(list.sessions[0], "sessions entry at 0").agentId ?? + parseAgentSessionKey(labelKey)?.agentId ?? + p.agentId, + "label session agent", + ), }; } diff --git a/src/gateway/terminal/launch.test.ts b/src/gateway/terminal/launch.test.ts index 1a7de498b33f..8f8eb7324972 100644 --- a/src/gateway/terminal/launch.test.ts +++ b/src/gateway/terminal/launch.test.ts @@ -83,13 +83,14 @@ describe("createTerminalLaunchPolicy", () => { it("keeps restart and commit restrictions isolated across agents", () => { const baseConfig: OpenClawConfig = { - agents: { list: [{ id: "alpha" }, { id: "beta" }] }, + agents: { ownership: "explicit", list: [{ id: "alpha" }, { id: "beta" }] }, }; const policy = createTerminalLaunchPolicy(baseConfig); policy.prepareConfig( { agents: { + ownership: "explicit", list: [{ id: "alpha", sandbox: { mode: "all" } }, { id: "beta" }], }, }, @@ -98,6 +99,7 @@ describe("createTerminalLaunchPolicy", () => { policy.prepareConfig( { agents: { + ownership: "explicit", list: [{ id: "alpha" }, { id: "beta", sandbox: { mode: "all" } }], }, }, diff --git a/src/gateway/terminal/launch.ts b/src/gateway/terminal/launch.ts index 55d56c29ae3c..0c74ffcfab95 100644 --- a/src/gateway/terminal/launch.ts +++ b/src/gateway/terminal/launch.ts @@ -156,10 +156,7 @@ export function createTerminalLaunchPolicy(initialConfig: OpenClawConfig): Termi restrictions.disabled = true; return; } - const activeAgentIds = new Set([ - ...listAgentIds(activeConfig), - resolveDefaultAgentId(activeConfig), - ]); + const activeAgentIds = new Set(listAgentIds(activeConfig)); for (const agentId of activeAgentIds) { const candidate = resolveForConfig(config, agentId); if (!candidate.ok) { diff --git a/src/gateway/terminal/session-manager.task-lifecycle.test.ts b/src/gateway/terminal/session-manager.task-lifecycle.test.ts index 07cdec4cad4c..d31dd43658ec 100644 --- a/src/gateway/terminal/session-manager.task-lifecycle.test.ts +++ b/src/gateway/terminal/session-manager.task-lifecycle.test.ts @@ -31,6 +31,33 @@ describe("TerminalSessionManager task lifecycle", () => { expect(manager.size).toBe(0); }); + it("does not authorize interactive access through a colliding task id", async () => { + const fake = makeFakePty(); + const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: async () => fake }); + const owner = { + kind: "agent", + agentSessionKey: "agent:ops:main", + agentId: "ops", + taskId: "agent:research:main", + } as const; + const opened = await manager.open(baseOpenRequest({ owner })); + if (!opened.ok) { + throw new Error("expected terminal session"); + } + + expect(manager.writeAgent("agent:research:main", opened.sessionId, "nope", "research")).toBe( + false, + ); + expect(manager.resizeAgent("agent:research:main", opened.sessionId, 90, 30, "research")).toBe( + false, + ); + expect( + manager.snapshotAgent("agent:research:main", opened.sessionId, "research"), + ).toBeUndefined(); + expect(manager.closeAgent("agent:research:main", opened.sessionId, "research")).toBe(false); + expect(fake.killed).toBe(false); + }); + it("closes one task owner with viewer cleanup while preserving persistent owners", async () => { const emit = vi.fn(); const runPtys = [makeFakePty(), makeFakePty()]; diff --git a/src/gateway/terminal/session-manager.ts b/src/gateway/terminal/session-manager.ts index 3ae482d920a2..a6f90a83fa25 100644 --- a/src/gateway/terminal/session-manager.ts +++ b/src/gateway/terminal/session-manager.ts @@ -40,11 +40,22 @@ const log = createSubsystemLogger("gateway/terminal"); // conversation-scoped while lifecycle cleanup can target the exact producer. type TaskBoundAgentOwner = Extract & { taskId?: string }; -function terminalOwnerMatches(owner: TerminalOwner | null, ownerKey: string): boolean { +function terminalOwnerMatches( + owner: TerminalOwner | null, + ownerKey: string, + agentId?: string, +): boolean { if (owner?.kind !== "agent") { return false; } - return owner.agentSessionKey === ownerKey || (owner as TaskBoundAgentOwner).taskId === ownerKey; + return owner.agentSessionKey === ownerKey && owner.agentId === agentId; +} + +function terminalLifecycleOwnerMatches(owner: TerminalOwner | null, ownerKey: string): boolean { + return ( + owner?.kind === "agent" && + (owner.agentSessionKey === ownerKey || (owner as TaskBoundAgentOwner).taskId === ownerKey) + ); } /** @@ -306,8 +317,8 @@ export class TerminalSessionManager { } /** Writes agent input after proving session-key ownership. */ - writeAgent(agentSessionKey: string, sessionId: string, data: string): boolean { - const session = this.agentOwnedSession(agentSessionKey, sessionId); + writeAgent(agentSessionKey: string, sessionId: string, data: string, agentId?: string): boolean { + const session = this.agentOwnedSession(agentSessionKey, sessionId, agentId); return session ? this.writeSession(session, data) : false; } @@ -333,8 +344,14 @@ export class TerminalSessionManager { } /** Resizes an agent-owned PTY after proving session-key ownership. */ - resizeAgent(agentSessionKey: string, sessionId: string, cols: number, rows: number): boolean { - const session = this.agentOwnedSession(agentSessionKey, sessionId); + resizeAgent( + agentSessionKey: string, + sessionId: string, + cols: number, + rows: number, + agentId?: string, + ): boolean { + const session = this.agentOwnedSession(agentSessionKey, sessionId, agentId); return session ? this.resizeSession(session, cols, rows) : false; } @@ -385,8 +402,8 @@ export class TerminalSessionManager { } /** Closes an agent-owned PTY after proving session-key ownership. */ - closeAgent(agentSessionKey: string, sessionId: string): boolean { - const session = this.agentOwnedSession(agentSessionKey, sessionId); + closeAgent(agentSessionKey: string, sessionId: string, agentId?: string): boolean { + const session = this.agentOwnedSession(agentSessionKey, sessionId, agentId); if (!session) { return false; } @@ -395,14 +412,22 @@ export class TerminalSessionManager { } /** Closes every live or spawning PTY owned by one exact agent session or task. */ - closeAgentSessions(agentSessionKey: string): number { + closeAgentSessions(agentSessionKey: string, agentId?: string): number { for (const [pending, owner] of this.pendingOpens) { - if (terminalOwnerMatches(owner, agentSessionKey)) { + if ( + agentId + ? terminalOwnerMatches(owner, agentSessionKey, agentId) + : terminalLifecycleOwnerMatches(owner, agentSessionKey) + ) { pending.abort("terminal closed because its task ended"); } } const owned = [...this.sessions.values()].filter( - (session) => !session.closed && terminalOwnerMatches(session.owner, agentSessionKey), + (session) => + !session.closed && + (agentId + ? terminalOwnerMatches(session.owner, agentSessionKey, agentId) + : terminalLifecycleOwnerMatches(session.owner, agentSessionKey)), ); for (const session of owned) { this.finalize(session, "closed", {}); @@ -492,13 +517,21 @@ export class TerminalSessionManager { } /** Raw buffer for an agent-owned session, guarded by the caller session key. */ - snapshotAgent(agentSessionKey: string, sessionId: string): string | undefined { - return this.agentOwnedSession(agentSessionKey, sessionId)?.buffer.snapshot(); + snapshotAgent(agentSessionKey: string, sessionId: string, agentId?: string): string | undefined { + return this.agentOwnedSession(agentSessionKey, sessionId, agentId)?.buffer.snapshot(); } /** Live sessions owned by one agent tool caller. */ - listAgent(agentSessionKey: string): TerminalSessionSummary[] { - return this.list().filter((summary) => summary.owner === `agent:${agentSessionKey}`); + listAgent(agentSessionKey: string, agentId?: string): TerminalSessionSummary[] { + const sessionIds = new Set( + [...this.sessions.values()] + .filter( + (session) => + !session.closed && terminalOwnerMatches(session.owner, agentSessionKey, agentId), + ) + .map((session) => session.id), + ); + return this.list().filter((summary) => sessionIds.has(summary.sessionId)); } private trackPendingOpen(owner: TerminalOwner, pending: TerminalPendingOpen): void { @@ -713,13 +746,14 @@ export class TerminalSessionManager { private agentOwnedSession( agentSessionKey: string, sessionId: string, + agentId?: string, ): TerminalSession | undefined { const session = this.sessions.get(sessionId); if ( !session || session.closed || session.owner?.kind !== "agent" || - session.owner.agentSessionKey !== agentSessionKey + !terminalOwnerMatches(session.owner, agentSessionKey, agentId) ) { return undefined; } diff --git a/src/gateway/terminal/session-manager.types.ts b/src/gateway/terminal/session-manager.types.ts index 921510e9b862..e8ee14b21dff 100644 --- a/src/gateway/terminal/session-manager.types.ts +++ b/src/gateway/terminal/session-manager.types.ts @@ -9,7 +9,7 @@ export type TerminalExitReason = "process_exit" | "closed" | "disconnected" | "d export type TerminalOwner = | { kind: "conn"; connId: string } - | { kind: "agent"; agentSessionKey: string }; + | { kind: "agent"; agentSessionKey: string; agentId?: string }; export type TerminalSession = { id: string; diff --git a/src/gateway/tool-resolution.test.ts b/src/gateway/tool-resolution.test.ts index 12491108671f..a8b89f4556ff 100644 --- a/src/gateway/tool-resolution.test.ts +++ b/src/gateway/tool-resolution.test.ts @@ -90,6 +90,51 @@ describe("resolveGatewayScopedTools", () => { expect(grantBound.tools.some((tool) => tool.name === "image")).toBe(true); }); + it("applies a borrowed runtime policy without reassigning session tools", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { + main: {}, + worker: { tools: { deny: ["sessions_list"] } }, + }, + }, + } satisfies OpenClawConfig; + + const result = resolveGatewayScopedTools({ + cfg, + sessionKey: "agent:main:main", + agentId: "main", + runtimePolicySessionKey: "agent:worker:discord:default:direct:peer-42", + runtimePolicyAgentId: "worker", + surface: "loopback", + }); + + expect(result.agentId).toBe("main"); + expect(result.tools.some((tool) => tool.name === "sessions_list")).toBe(false); + expect(result.tools.some((tool) => tool.name === "sessions_history")).toBe(true); + }); + + it("rejects a runtime policy agent that conflicts with its session key", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { main: {}, worker: {} }, + }, + } satisfies OpenClawConfig; + + expect(() => + resolveGatewayScopedTools({ + cfg, + sessionKey: "agent:main:main", + agentId: "main", + runtimePolicySessionKey: "agent:worker:main", + runtimePolicyAgentId: "main", + surface: "loopback", + }), + ).toThrowError(expect.objectContaining({ code: "AGENT_SELECTION_REQUIRED" })); + }); + it("materializes an executable write tool on the mediated CLI surface", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mediated-write-")); try { diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 03ec79507141..48c52bf36930 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -1,5 +1,5 @@ // Gateway-scoped tool resolution for HTTP and loopback tool surfaces. -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "../agents/agent-scope.js"; import { createOpenClawCodingTools } from "../agents/agent-tools.js"; import { filterToolsByMessageProvider } from "../agents/agent-tools.message-provider-policy.js"; import { resolveEffectiveToolPolicy } from "../agents/agent-tools.policy.js"; @@ -62,6 +62,7 @@ export function resolveGatewayScopedTools(params: { agentDir?: string; sessionKey: string; runtimePolicySessionKey?: string; + runtimePolicyAgentId?: string; agentId?: string; sessionId?: string; runId?: string; @@ -112,8 +113,23 @@ export function resolveGatewayScopedTools(params: { scheduledToolPolicy?: ScheduledToolPolicyContext; }) { const runtimePolicySessionKey = params.runtimePolicySessionKey?.trim() || params.sessionKey; + const sessionAgentId = resolveSessionAgentIds({ + config: params.cfg, + sessionKey: params.sessionKey, + agentId: params.agentId, + }).sessionAgentId; + const hasSeparateRuntimePolicyIdentity = Boolean( + params.runtimePolicySessionKey?.trim() || params.runtimePolicyAgentId?.trim(), + ); + const runtimePolicyAgentId = hasSeparateRuntimePolicyIdentity + ? resolveSessionAgentIds({ + config: params.cfg, + sessionKey: runtimePolicySessionKey, + agentId: params.runtimePolicyAgentId, + }).sessionAgentId + : sessionAgentId; const { - agentId, + agentId: resolvedPolicyAgentId, globalPolicy, globalProviderPolicy, agentPolicy, @@ -125,10 +141,11 @@ export function resolveGatewayScopedTools(params: { } = resolveEffectiveToolPolicy({ config: params.cfg, sessionKey: runtimePolicySessionKey, - agentId: params.agentId, + agentId: runtimePolicyAgentId, modelProvider: params.modelProvider, modelId: params.modelId, }); + const policyAgentId = resolvedPolicyAgentId ?? runtimePolicyAgentId; const profilePolicy = resolveToolProfilePolicy(profile); const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); const surface = params.surface ?? "http"; @@ -162,7 +179,7 @@ export function resolveGatewayScopedTools(params: { config: params.cfg, sessionKey: runtimePolicySessionKey, subagentSessionKey: runtimePolicySessionKey, - agentId, + agentId: policyAgentId, spawnedBy: params.spawnedBy, messageProvider: params.messageProvider, groupId: params.groupId, @@ -186,8 +203,10 @@ export function resolveGatewayScopedTools(params: { const { groupPolicy, senderPolicy, subagentPolicy, inheritedToolPolicy } = requesterPolicies; const sandboxRuntime = resolveSandboxRuntimeStatus({ cfg: params.cfg, - sessionKey: runtimePolicySessionKey, - agentId, + sessionKey: params.sessionKey, + agentId: sessionAgentId, + classificationSessionKey: runtimePolicySessionKey, + classificationAgentId: policyAgentId, }); const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined; const excludedToolNames = params.excludeToolNames ? Array.from(params.excludeToolNames) : []; @@ -214,8 +233,7 @@ export function resolveGatewayScopedTools(params: { : []; // HTTP callers start with additional surface denies because they cross auth only. const workspaceDir = - params.workspaceDir?.trim() || - resolveAgentWorkspaceDir(params.cfg, agentId ?? resolveDefaultAgentId(params.cfg)); + params.workspaceDir?.trim() || resolveAgentWorkspaceDir(params.cfg, sessionAgentId); const explicitDenylist = collectExplicitDenylist([ profilePolicy, providerProfilePolicy, @@ -254,7 +272,7 @@ export function resolveGatewayScopedTools(params: { const openClawTools = createOpenClawTools({ agentSessionKey: params.sessionKey, - requesterAgentIdOverride: agentId, + requesterAgentIdOverride: sessionAgentId, agentChannel: params.messageProvider ?? undefined, agentAccountId: params.accountId, inboundEventKind: params.inboundEventKind, @@ -309,7 +327,7 @@ export function resolveGatewayScopedTools(params: { cfg: params.cfg, sessionEntry: params.execSession, execOverrides: params.execOverrides, - agentId, + agentId: policyAgentId, sessionKey: runtimePolicySessionKey, sandboxAvailable: sandboxRuntime.sandboxed, }) @@ -318,7 +336,7 @@ export function resolveGatewayScopedTools(params: { nodeExecSurface && execDefaults?.canRequestNode === true ? execDefaults : undefined; const includeNodeExecTool = nodeExecDefaults !== undefined; const execConfig = includeNodeExecTool - ? resolveExecToolConfig({ cfg: params.cfg, agentId }) + ? resolveExecToolConfig({ cfg: params.cfg, agentId: policyAgentId }) : undefined; const includeMediatedBaseCodingTools = ["read", "write", "edit"].some((name) => mediatedToolNames.has(name), @@ -330,7 +348,7 @@ export function resolveGatewayScopedTools(params: { surface === "loopback" && (includeMediatedBaseCodingTools || includeMediatedShellTools) ? createOpenClawCodingTools({ config: params.cfg, - agentId, + agentId: policyAgentId, sessionKey: runtimePolicySessionKey, runSessionKey: params.sessionKey, sessionId: params.sessionId, @@ -418,7 +436,7 @@ export function resolveGatewayScopedTools(params: { safeBinProfiles: execConfig?.safeBinProfiles, reviewer: execConfig?.reviewer, config: params.cfg, - agentId, + agentId: policyAgentId, elevated: params.bashElevated, cwd: workspaceDir, allowBackground: false, @@ -475,7 +493,7 @@ export function resolveGatewayScopedTools(params: { agentProviderPolicy, groupPolicy, senderPolicy, - agentId, + agentId: policyAgentId, }), { policy: sandboxPolicy, label: "sandbox tools.allow" }, { policy: subagentPolicy, label: "subagent tools.allow" }, @@ -508,7 +526,7 @@ export function resolveGatewayScopedTools(params: { ); return { - agentId, + agentId: sessionAgentId, tools, workspaceDir, }; diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index f176a13f710d..dfc8470690b7 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -42,7 +42,8 @@ vi.mock("../config/io.js", () => ({ getRuntimeConfig: () => cfg, })); -vi.mock("../config/sessions.js", () => ({ +vi.mock("../config/sessions.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveMainSessionKey: (params?: { session?: { scope?: string; mainKey?: string }; agents?: { list?: Array<{ id?: string; default?: boolean }> }; @@ -63,6 +64,10 @@ vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + loadExactSessionEntryReadOnly: (params: { sessionKey: string }) => { + const entry = sessionEntries.get(params.sessionKey); + return entry ? { sessionKey: params.sessionKey, entry } : undefined; + }, resolveSessionEntryAccessTarget: (params: { sessionKey: string }) => ({ entry: sessionEntries.get(params.sessionKey), }), @@ -1302,7 +1307,7 @@ describe("tools.invoke Gateway RPC", () => { expect(call?.[1]?.toolName).toBe("agents_list"); const error = call?.[1]?.error as { code?: string; message?: string } | undefined; expect(error?.code).toBe("validation_error"); - expect(error?.message).toBe('agent id "other" does not match session agent "main"'); + expect(error?.message).toBe('agent "other" does not match session key agent "main"'); }); it("rejects malformed params at the RPC boundary", async () => { diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts index 3ee9521979e8..065104a8bc21 100644 --- a/src/gateway/tools-invoke-shared.ts +++ b/src/gateway/tools-invoke-shared.ts @@ -17,8 +17,6 @@ import { normalizeConversationReadInvocationOrigin, type ConversationReadInvocationOrigin, } from "../channels/plugins/conversation-read-origin.js"; -import { resolveMainSessionKey } from "../config/sessions.js"; -import { resolveSessionEntryAccessTarget } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { logWarn } from "../logger.js"; @@ -30,7 +28,9 @@ import { isAgentHarnessSessionKey, isAgentHarnessSessionStoreEntryProtected, } from "../sessions/agent-harness-session-key.js"; -import { canonicalizeSessionKeyForAgent } from "./session-store-key.js"; +import { resolveRequestedSessionAgentId } from "./session-request-agent.js"; +import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; +import { loadGatewaySessionEntryReadOnly } from "./session-utils.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]); @@ -68,16 +68,25 @@ type ToolsInvokeOutcome = }; }; -function resolveSessionKey(params: { cfg: OpenClawConfig; input: ToolsInvokeInput }): string { - const rawSessionKey = normalizeOptionalString(params.input.sessionKey); - if (rawSessionKey && rawSessionKey !== "main") { - return rawSessionKey; +function resolveSessionTarget(params: { cfg: OpenClawConfig; input: ToolsInvokeInput }) { + const rawSessionKey = normalizeOptionalString(params.input.sessionKey) ?? "main"; + const resolved = resolveRequestedSessionAgentId( + params.cfg, + rawSessionKey, + normalizeOptionalString(params.input.agentId), + ); + if (!resolved.ok) { + return resolved; } - const agentId = normalizeOptionalString(params.input.agentId); - if (agentId) { - return canonicalizeSessionKeyForAgent(agentId, "main"); - } - return resolveMainSessionKey(params.cfg); + return { + ok: true as const, + agentId: resolved.agentId, + sessionKey: resolveStoredSessionKeyForAgentStore({ + cfg: params.cfg, + agentId: resolved.agentId, + sessionKey: rawSessionKey, + }), + }; } function resolveMemoryToolDisableReasons(cfg: OpenClawConfig): string[] { @@ -212,9 +221,18 @@ export async function invokeGatewayTool(params: { argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw) ? (argsRaw as Record) : {}; - const sessionKey = resolveSessionKey({ cfg: params.cfg, input: params.input }); + const sessionTarget = resolveSessionTarget({ cfg: params.cfg, input: params.input }); + if (!sessionTarget.ok) { + return { + ok: false, + status: 400, + toolName, + error: { type: "invalid_request", message: sessionTarget.error.message }, + }; + } + const { agentId: selectedAgentId, sessionKey } = sessionTarget; const harnessEntry = isAgentHarnessSessionKey(sessionKey) - ? resolveSessionEntryAccessTarget({ cfg: params.cfg, sessionKey }).entry + ? loadGatewaySessionEntryReadOnly(sessionKey, { agentId: selectedAgentId }).entry : undefined; if ( isAgentHarnessSessionKey(sessionKey) && @@ -234,6 +252,7 @@ export async function invokeGatewayTool(params: { resolveGatewayScopedTools({ cfg: params.cfg, sessionKey, + agentId: selectedAgentId, messageProvider: params.messageChannel, accountId: params.accountId, agentTo: params.agentTo, diff --git a/src/infra/agent-run-registry.ts b/src/infra/agent-run-registry.ts index 46a14e8b95af..d565dd846c01 100644 --- a/src/infra/agent-run-registry.ts +++ b/src/infra/agent-run-registry.ts @@ -1,6 +1,7 @@ // Owns process-local agent run context, ownership, and projection state. import { randomUUID } from "node:crypto"; import type { VerboseLevel } from "../auto-reply/thinking.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { clearAgentRunUsage, resetAgentRunUsageForTest } from "./agent-run-usage.js"; @@ -559,12 +560,20 @@ export function listAgentRunsForSession(params: { export type ProjectedAgentRunIndex = { sessionKeys: ReadonlySet; sessionIds: ReadonlySet; + ownerlessSessionKeys: ReadonlySet; + ownerlessSessionIds: ReadonlySet; }; +function projectedRunIdentity(agentId: string, value: string): string { + return `${normalizeAgentId(agentId)}\0${value}`; +} + export function buildProjectedAgentRunIndex(): ProjectedAgentRunIndex { const state = getAgentRunRegistryState(); const sessionKeys = new Set(); const sessionIds = new Set(); + const ownerlessSessionKeys = new Set(); + const ownerlessSessionIds = new Set(); for (const context of state.contexts.values()) { if ( context.projectSessionActive !== true || @@ -572,25 +581,48 @@ export function buildProjectedAgentRunIndex(): ProjectedAgentRunIndex { ) { continue; } - if (context.sessionKey !== undefined) { - sessionKeys.add(context.sessionKey); + const agentId = context.agentId ?? parseAgentSessionKey(context.sessionKey)?.agentId; + if (context.sessionKey !== undefined && agentId) { + sessionKeys.add(projectedRunIdentity(agentId, context.sessionKey)); + } else if (context.sessionKey !== undefined) { + ownerlessSessionKeys.add(context.sessionKey); } - if (context.sessionId !== undefined) { - sessionIds.add(context.sessionId); + if (context.sessionId !== undefined && agentId) { + sessionIds.add(projectedRunIdentity(agentId, context.sessionId)); + } else if (context.sessionId !== undefined) { + ownerlessSessionIds.add(context.sessionId); } } - return { sessionKeys, sessionIds }; + return { sessionKeys, sessionIds, ownerlessSessionKeys, ownerlessSessionIds }; } export function hasProjectedAgentRunForSession(params: { sessionKeys: readonly string[]; sessionId?: string; + agentId?: string; + defaultAgentId?: string; index?: ProjectedAgentRunIndex; }): boolean { const index = params.index ?? buildProjectedAgentRunIndex(); + const agentId = + params.agentId ?? + params.sessionKeys.flatMap((key) => parseAgentSessionKey(key)?.agentId ?? [])[0] ?? + params.defaultAgentId; + if (!agentId) { + return false; + } + const mayAdoptOwnerless = + params.defaultAgentId !== undefined && + normalizeAgentId(agentId) === normalizeAgentId(params.defaultAgentId); return ( - params.sessionKeys.some((sessionKey) => index.sessionKeys.has(sessionKey)) || - (params.sessionId !== undefined && index.sessionIds.has(params.sessionId)) + params.sessionKeys.some((sessionKey) => + index.sessionKeys.has(projectedRunIdentity(agentId, sessionKey)), + ) || + (mayAdoptOwnerless && + params.sessionKeys.some((sessionKey) => index.ownerlessSessionKeys.has(sessionKey))) || + (params.sessionId !== undefined && + (index.sessionIds.has(projectedRunIdentity(agentId, params.sessionId)) || + (mayAdoptOwnerless && index.ownerlessSessionIds.has(params.sessionId)))) ); } diff --git a/src/infra/exec-approvals-effective.ts b/src/infra/exec-approvals-effective.ts index fbabc741695d..ac160a1e6957 100644 --- a/src/infra/exec-approvals-effective.ts +++ b/src/infra/exec-approvals-effective.ts @@ -1,6 +1,9 @@ // Resolves effective exec approval policy from config and policy files. import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { + listAgentEntries, + tryResolveLegacyCompatibilityAgentId, +} from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { DEFAULT_EXEC_APPROVAL_ASK_FALLBACK, @@ -302,7 +305,7 @@ export function collectExecPolicyScopeSnapshots(params: { hostDefaults?: ExecPolicyHostDefaults; hostDefaultSource?: string; }): ExecPolicyScopeSnapshot[] { - const defaultAgentId = resolveDefaultAgentId(params.cfg); + const defaultAgentId = tryResolveLegacyCompatibilityAgentId(params.cfg); const snapshots = [ resolveExecPolicyScopeSnapshot({ approvals: params.approvals, diff --git a/src/infra/heartbeat-agent-resolution.ts b/src/infra/heartbeat-agent-resolution.ts new file mode 100644 index 000000000000..d0b5b00923ba --- /dev/null +++ b/src/infra/heartbeat-agent-resolution.ts @@ -0,0 +1,16 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeAgentId } from "../routing/session-key.js"; + +export function resolveAmbientHeartbeatAgentId(cfg: OpenClawConfig): string { + return normalizeAgentId( + normalizeOptionalString(cfg.agents?.defaults?.heartbeat?.agentId) ?? + tryResolveLegacyCompatibilityAgentId(cfg) ?? + resolveDefaultAgentId(cfg, { + surface: "ambient heartbeat scheduling", + hint: "Set agents.defaults.heartbeat.agentId to the agent that owns ambient heartbeats.", + }), + ); +} diff --git a/src/infra/heartbeat-runner-config.ts b/src/infra/heartbeat-runner-config.ts index 3a411a4410a7..3e68fc085927 100644 --- a/src/infra/heartbeat-runner-config.ts +++ b/src/infra/heartbeat-runner-config.ts @@ -1,11 +1,6 @@ import { createHash } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - listAgentIds, - listAgentEntries, - resolveAgentConfig, - resolveDefaultAgentId, -} from "../agents/agent-scope.js"; +import { listAgentIds, listAgentEntries, resolveAgentConfig } from "../agents/agent-scope.js"; import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js"; import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js"; import { @@ -26,6 +21,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; import { readStoredDeviceIdentityReadOnly } from "./device-identity-store.js"; import { loadOrCreateDeviceIdentity } from "./device-identity.js"; import { resolveActiveHoursTimezone } from "./heartbeat-active-hours.js"; +import { resolveAmbientHeartbeatAgentId } from "./heartbeat-agent-resolution.js"; import { resolveHeartbeatIntervalMs } from "./heartbeat-summary.js"; import type { HeartbeatWakeSource } from "./heartbeat-wake.js"; @@ -150,11 +146,6 @@ function resolveHeartbeatConfig( return { ...defaults, ...overrides }; } -export function resolveAmbientHeartbeatAgentId(cfg: OpenClawConfig): string { - const configured = normalizeOptionalString(cfg.agents?.defaults?.heartbeat?.agentId); - return normalizeAgentId(configured ?? resolveDefaultAgentId(cfg)); -} - function omitExplicitHeartbeatDestination(heartbeat: HeartbeatConfig | undefined) { if (!heartbeat) { return undefined; @@ -324,3 +315,4 @@ export function resolveHeartbeatTypingIntervalSeconds(cfg: OpenClawConfig) { const configured = cfg.agents?.defaults?.typingIntervalSeconds; return typeof configured === "number" && configured > 0 ? configured : undefined; } +export { resolveAmbientHeartbeatAgentId } from "./heartbeat-agent-resolution.js"; diff --git a/src/infra/heartbeat-runner-execution.ts b/src/infra/heartbeat-runner-execution.ts index ad01b325a513..e611077943f1 100644 --- a/src/infra/heartbeat-runner-execution.ts +++ b/src/infra/heartbeat-runner-execution.ts @@ -1,6 +1,5 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js"; import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js"; import { listActiveEmbeddedRunSessionKeys } from "../agents/embedded-agent-runner/run-state.js"; @@ -59,6 +58,7 @@ import { isWithinActiveHours } from "./heartbeat-active-hours.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { heartbeatLog, + resolveAmbientHeartbeatAgentId, resolveHeartbeatAckMaxChars, resolveHeartbeatForWake, resolveHeartbeatTimeoutOverrideSeconds, @@ -156,7 +156,7 @@ export async function resolveHeartbeatWakeStage(opts: HeartbeatRunOptions) { const forcedSessionAgentId = explicitAgentId.length > 0 ? undefined : parseAgentSessionKey(opts.sessionKey)?.agentId; const agentId = normalizeAgentId( - explicitAgentId || forcedSessionAgentId || resolveDefaultAgentId(cfg), + explicitAgentId || forcedSessionAgentId || resolveAmbientHeartbeatAgentId(cfg), ); const wakeSource = opts.source ?? inferHeartbeatWakeSourceFromReason(opts.reason); const heartbeat = resolveHeartbeatForWake({ diff --git a/src/infra/heartbeat-runner-session.ts b/src/infra/heartbeat-runner-session.ts index 36cc7443d653..119b66f3224a 100644 --- a/src/infra/heartbeat-runner-session.ts +++ b/src/infra/heartbeat-runner-session.ts @@ -1,5 +1,4 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { canonicalizeMainSessionAlias, resolveAgentMainSessionKey, @@ -14,7 +13,7 @@ import { toAgentStoreSessionKey, } from "../routing/session-key.js"; import { resolveMainScopedEventSessionKey } from "./event-session-routing.js"; -import type { HeartbeatConfig } from "./heartbeat-runner-config.js"; +import { resolveAmbientHeartbeatAgentId, type HeartbeatConfig } from "./heartbeat-runner-config.js"; export function resolveHeartbeatSessionKey( cfg: OpenClawConfig, @@ -25,7 +24,7 @@ export function resolveHeartbeatSessionKey( ) { const sessionCfg = cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; - const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); + const resolvedAgentId = normalizeAgentId(agentId ?? resolveAmbientHeartbeatAgentId(cfg)); const mainSessionKey = scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId }); const storePath = resolveSessionStorePathCore(sessionCfg?.store, { diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index bbe03ed79294..f601edc7a76d 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -457,12 +457,23 @@ describe("isHeartbeatEnabledForAgent", () => { expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true); }); - it("falls back to default agent when no heartbeat config exists", () => { + it("uses the configured ambient heartbeat owner when one is explicit", () => { const cfg: OpenClawConfig = { agents: { + defaults: { heartbeat: { agentId: "ops", every: "30m" } }, list: [{ id: "main" }, { id: "ops" }], }, }; + expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(false); + expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true); + }); + + it("falls back to the sole agent when no heartbeat config exists", () => { + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "main" }], + }, + }; expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(true); expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(false); }); diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts index 81143573830b..9b1e5e812bf4 100644 --- a/src/infra/heartbeat-summary.ts +++ b/src/infra/heartbeat-summary.ts @@ -3,7 +3,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { listAgentEntries, resolveAgentConfig, - resolveDefaultAgentId, + tryResolveDefaultAgentId, } from "../agents/agent-scope.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -11,9 +11,11 @@ import { resolveHeartbeatPromptCore as resolveHeartbeatPromptText, } from "../auto-reply/heartbeat.js"; import { parseDurationMs } from "../cli/parse-duration.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import { resolveAmbientHeartbeatAgentId } from "./heartbeat-agent-resolution.js"; // Heartbeat summaries merge default and per-agent heartbeat config for CLI/UI // display without scheduling any work. @@ -40,7 +42,11 @@ function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) { /** Return whether heartbeat scheduling applies to an agent. */ export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean { - const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); + const ambientAgentId = + agentId === undefined + ? resolveAmbientHeartbeatAgentId(cfg) + : (tryResolveLegacyCompatibilityAgentId(cfg) ?? tryResolveDefaultAgentId(cfg)); + const resolvedAgentId = normalizeAgentId(agentId ?? ambientAgentId); const list = listAgentEntries(cfg); const hasExplicit = hasExplicitHeartbeatAgents(cfg); if (hasExplicit) { @@ -49,9 +55,13 @@ export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string ); } if (cfg.agents?.defaults?.heartbeat) { + const configuredAgentId = normalizeOptionalString(cfg.agents.defaults.heartbeat.agentId); + if (configuredAgentId) { + return resolvedAgentId === normalizeAgentId(configuredAgentId); + } return true; } - return resolvedAgentId === resolveDefaultAgentId(cfg); + return ambientAgentId !== undefined && resolvedAgentId === ambientAgentId; } /** Resolve a heartbeat interval string to milliseconds. */ diff --git a/src/infra/path-case.ts b/src/infra/path-case.ts index 0d5a2e0a53da..8770671353f3 100644 --- a/src/infra/path-case.ts +++ b/src/infra/path-case.ts @@ -78,12 +78,12 @@ function platformDefault(): boolean { return process.platform === "darwin" || process.platform === "win32"; } -function probeDirectory(dir: string): boolean { - return probeDirectoryContents(dir) ?? probeDirectoryWithTemporaryEntry(dir) ?? platformDefault(); +function probeDirectory(dir: string): boolean | undefined { + return probeDirectoryContents(dir) ?? probeDirectoryWithTemporaryEntry(dir); } -/** Returns whether the target path's filesystem matches names case-insensitively. */ -export function isPathCaseInsensitive(value: string): boolean { +/** Resolves path-local case semantics, or undefined when the filesystem cannot be probed. */ +export function tryResolvePathCaseInsensitive(value: string): boolean | undefined { const resolved = path.resolve(value); try { fs.lstatSync(resolved); @@ -92,23 +92,34 @@ export function isPathCaseInsensitive(value: string): boolean { } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTDIR") { - return platformDefault(); + return undefined; } } let candidate = path.dirname(resolved); for (;;) { + let isDirectory = false; try { - if (fs.statSync(candidate).isDirectory()) { - return probeDirectory(candidate); - } + isDirectory = fs.statSync(candidate).isDirectory(); } catch { // Keep walking to the nearest readable existing directory. } + if (isDirectory) { + try { + return probeDirectory(candidate); + } catch { + return undefined; + } + } const parent = path.dirname(candidate); if (parent === candidate) { - return platformDefault(); + return undefined; } candidate = parent; } } + +/** Returns whether the target path's filesystem matches names case-insensitively. */ +export function isPathCaseInsensitive(value: string): boolean { + return tryResolvePathCaseInsensitive(value) ?? platformDefault(); +} diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 34de3d14a685..fc0615ad70f7 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { discardLegacyRegistryWorktrees, hasLegacyRegistryWorktrees, @@ -11,8 +10,13 @@ import { import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin } from "../channels/plugins/registry.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; +import { + resolveSessionStoreCompatibilityAgentId, + tryResolveLegacyCompatibilityAgentId, +} from "../config/legacy.default-agent-owner.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; +import { isPerAgentSessionStoreConfig } from "../config/sessions/session-store-config.js"; import { resolveSessionStoreTargets } from "../config/sessions/targets.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -267,13 +271,18 @@ function createPluginDoctorStateMigrationContext( }; } -function resolveDoctorStateMigrationAgentId(cfg: OpenClawConfig): string { - try { - return normalizeAgentId(resolveDefaultAgentId(cfg)); - } catch { - // Detection must still inspect malformed/pre-roster state so Doctor can repair it. - return LEGACY_IMPLICIT_AGENT_ID; - } +function tryResolveDoctorStateMigrationAgentId(cfg: OpenClawConfig): string | undefined { + const agentId = tryResolveLegacyCompatibilityAgentId(cfg); + return agentId ? normalizeAgentId(agentId) : undefined; +} + +function tryResolveDoctorSessionMigrationAgentId(cfg: OpenClawConfig): string | undefined { + return ( + tryResolveDoctorStateMigrationAgentId(cfg) ?? + (!isPerAgentSessionStoreConfig(cfg.session?.store) + ? resolveSessionStoreCompatibilityAgentId(cfg) + : undefined) + ); } function resolveConcreteBindingAccountId(value: unknown): string | undefined { @@ -349,7 +358,9 @@ export async function detectLegacyStateMigrations(params: { const homedir = params.homedir ?? os.homedir; const stateDir = resolveStateDir(env, homedir); const oauthDir = resolveOAuthDir(env, stateDir); - const targetAgentId = resolveDoctorStateMigrationAgentId(params.cfg); + const migrationAgentId = tryResolveDoctorStateMigrationAgentId(params.cfg); + const sessionMigrationAgentId = tryResolveDoctorSessionMigrationAgentId(params.cfg); + const targetAgentId = migrationAgentId ?? sessionMigrationAgentId ?? LEGACY_IMPLICIT_AGENT_ID; const rawMainKey = params.cfg.session?.mainKey; const targetMainKey = typeof rawMainKey === "string" && rawMainKey.trim().length > 0 @@ -369,13 +380,23 @@ export async function detectLegacyStateMigrations(params: { env, pluginIds: collectRelevantDoctorPluginIds(pluginConfig), }); - const currentSessionStoreOwnership = resolveSessionStoreOwnership({ - cfg: params.cfg, - env, - stateDir, - targetAgentId, - pluginSessionStoreAgentIds, - }); + const currentSessionStoreOwnership = sessionMigrationAgentId + ? resolveSessionStoreOwnership({ + cfg: params.cfg, + env, + stateDir, + targetAgentId: sessionMigrationAgentId, + pluginSessionStoreAgentIds, + }) + : { + preserveAmbiguousKeys: true, + preserveForeignMainAliases: true, + targetStoreAliases: { + hasDistinctAliases: false, + hasFinalSymlink: false, + hasUnresolvedIdentity: false, + }, + }; const sessionStoreOwnership: SessionStoreOwnership = { preserveAmbiguousKeys: params.sessionStoreOwnership?.preserveAmbiguousKeys === true || @@ -604,17 +625,25 @@ export async function detectLegacyStateMigrations(params: { warnings: pluginPlanWarnings, }); + const sessionsHaveLegacy = + Boolean(sessionMigrationAgentId) && + (hasLegacySessions || legacyKeys.length > 0 || hasStaleSessionFiles); + const agentDirHasLegacy = Boolean(migrationAgentId) && hasLegacyAgentDir; + const deferred = + (!sessionMigrationAgentId && + (hasLegacySessions || legacyKeys.length > 0 || hasStaleSessionFiles)) || + (!migrationAgentId && hasLegacyAgentDir); const preview: string[] = []; - if (hasLegacySessions) { + if (sessionsHaveLegacy && hasLegacySessions) { preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`); } - if (legacyKeys.length > 0) { + if (sessionsHaveLegacy && legacyKeys.length > 0) { preview.push(`- Sessions: canonicalize legacy keys in ${sessionsTargetStorePath}`); } - if (hasStaleSessionFiles) { + if (sessionsHaveLegacy && hasStaleSessionFiles) { preview.push(`- Sessions: repair migrated transcript paths in ${sessionsTargetStorePath}`); } - if (hasLegacyAgentDir) { + if (agentDirHasLegacy) { preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`); } if (hasPluginStateSidecar) { @@ -733,8 +762,8 @@ export async function detectLegacyStateMigrations(params: { legacyStorePath: sessionsLegacyStorePath, targetDir: sessionsTargetDir, targetStorePath: sessionsTargetStorePath, - hasLegacy: hasLegacySessions || legacyKeys.length > 0 || hasStaleSessionFiles, - legacyKeys, + hasLegacy: sessionsHaveLegacy, + legacyKeys: sessionMigrationAgentId ? legacyKeys : [], preserveAmbiguousKeys: sessionStoreOwnership.preserveAmbiguousKeys, preserveForeignMainAliases, targetStoreAliases: sessionStoreOwnership.targetStoreAliases, @@ -742,7 +771,7 @@ export async function detectLegacyStateMigrations(params: { agentDir: { legacyDir: legacyAgentDir, targetDir: targetAgentDir, - hasLegacy: hasLegacyAgentDir, + hasLegacy: agentDirHasLegacy, }, pluginPlans: { hasLegacy: pluginPlans.length > 0, @@ -808,7 +837,11 @@ export async function detectLegacyStateMigrations(params: { subagentRegistry, rescuePending, channelPairing, - warnings: [...pluginPlanWarnings, ...legacySessionSurfaces.failures], + warnings: [ + ...pluginPlanWarnings, + ...legacySessionSurfaces.failures, + ...(deferred ? ["Deferred legacy agent/session migration: select an agent owner"] : []), + ], notices: [], preview, }; @@ -1319,7 +1352,9 @@ export async function autoMigrateLegacyState(params: { agentId: target.agentId, path: resolveSqliteTargetFromSessionStorePath(target.storePath, { agentId: target.agentId, - defaultAgentId: resolveDefaultAgentId(params.cfg), + defaultAgentId: isPerAgentSessionStoreConfig(params.cfg.session?.store) + ? target.agentId + : resolveSessionStoreCompatibilityAgentId(params.cfg), env, }).path, })), @@ -1360,13 +1395,16 @@ export async function autoMigrateLegacyState(params: { }); // Capture ownership before orphan-key rewrites. Atomic replacement can split // a configured filesystem alias from the standard target pathname. - const sessionStoreOwnership = resolveSessionStoreOwnership({ - cfg: params.cfg, - env, - stateDir, - targetAgentId: normalizeAgentId(resolveDefaultAgentId(params.cfg)), - pluginSessionStoreAgentIds, - }); + const ownershipAgentId = tryResolveDoctorSessionMigrationAgentId(params.cfg); + const sessionStoreOwnership = ownershipAgentId + ? resolveSessionStoreOwnership({ + cfg: params.cfg, + env, + stateDir, + targetAgentId: ownershipAgentId, + pluginSessionStoreAgentIds, + }) + : undefined; // Canonicalize orphaned session keys regardless of whether legacy migration // is needed — the orphan-key bug (#29683) affects all installs with // non-default agent IDs or mainKey configuration. diff --git a/src/infra/state-migrations.onboarding-recommendations.test.ts b/src/infra/state-migrations.onboarding-recommendations.test.ts index 413aa2f44976..969014efa90e 100644 --- a/src/infra/state-migrations.onboarding-recommendations.test.ts +++ b/src/infra/state-migrations.onboarding-recommendations.test.ts @@ -83,7 +83,9 @@ describe("onboarding recommendations scope migration", () => { }); expect(result).toEqual({ - changes: ["Migrated onboarding recommendation state to the default workspace scope."], + changes: [ + "Migrated onboarding recommendation state to the legacy owner workspace scope.", + ], warnings: [], }); expect( @@ -136,7 +138,7 @@ describe("onboarding recommendations scope migration", () => { expect(result).toEqual({ changes: [ - "Removed ambiguous legacy onboarding recommendation state; kept the default workspace record.", + "Removed ambiguous legacy onboarding recommendation state; kept the legacy owner workspace record.", ], warnings: [], }); diff --git a/src/infra/state-migrations.onboarding-recommendations.ts b/src/infra/state-migrations.onboarding-recommendations.ts index cdbffec5a39e..f785213e9f31 100644 --- a/src/infra/state-migrations.onboarding-recommendations.ts +++ b/src/infra/state-migrations.onboarding-recommendations.ts @@ -1,7 +1,8 @@ import { existsSync } from "node:fs"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolveWorkspaceStateIdentity } from "../agents/workspace-state-store.js"; import type { OpenClawConfig } from "../config/config.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; @@ -30,44 +31,47 @@ export function migrateLegacyOnboardingRecommendationsScope(params: { } try { - const workspaceDir = resolveAgentWorkspaceDir( - params.cfg, - resolveDefaultAgentId(params.cfg), - env, - ); - const workspaceKey = resolveWorkspaceStateIdentity(workspaceDir).workspaceKey; + const migrationAgentId = tryResolveLegacyCompatibilityAgentId(params.cfg); + const workspaceKey = migrationAgentId + ? resolveWorkspaceStateIdentity(resolveAgentWorkspaceDir(params.cfg, migrationAgentId, env)) + .workspaceKey + : undefined; const outcome = runOpenClawStateWriteTransaction( - ({ db: database }) => { - const db = getNodeSqliteKysely(database); - const legacy = executeSqliteQueryTakeFirstSync( - database, - db + ({ db: writeDatabase }) => { + const writeDb = + getNodeSqliteKysely(writeDatabase); + const legacyAtCommit = executeSqliteQueryTakeFirstSync( + writeDatabase, + writeDb .selectFrom("onboarding_recommendations") .select("config_key") .where("config_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY), ); - if (!legacy) { + if (!legacyAtCommit) { return "unchanged" as const; } + if (!workspaceKey) { + return "deferred" as const; + } const scoped = executeSqliteQueryTakeFirstSync( - database, - db + writeDatabase, + writeDb .selectFrom("onboarding_recommendations") .select("config_key") .where("config_key", "=", workspaceKey), ); if (scoped) { executeSqliteQuerySync( - database, - db + writeDatabase, + writeDb .deleteFrom("onboarding_recommendations") .where("config_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY), ); return "removed-legacy" as const; } executeSqliteQuerySync( - database, - db + writeDatabase, + writeDb .updateTable("onboarding_recommendations") .set({ config_key: workspaceKey }) .where("config_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY), @@ -80,18 +84,24 @@ export function migrateLegacyOnboardingRecommendationsScope(params: { if (outcome === "migrated") { return { - changes: ["Migrated onboarding recommendation state to the default workspace scope."], + changes: ["Migrated onboarding recommendation state to the legacy owner workspace scope."], warnings: [], }; } if (outcome === "removed-legacy") { return { changes: [ - "Removed ambiguous legacy onboarding recommendation state; kept the default workspace record.", + "Removed ambiguous legacy onboarding recommendation state; kept the legacy owner workspace record.", ], warnings: [], }; } + if (outcome === "deferred") { + return { + changes: [], + warnings: ["Deferred legacy onboarding recommendation migration: no owner is selected"], + }; + } return { changes: [], warnings: [] }; } catch (err) { return { diff --git a/src/infra/state-migrations.workspace-setup-sandbox.ts b/src/infra/state-migrations.workspace-setup-sandbox.ts index 8842e540e07a..ae0915f3bbcd 100644 --- a/src/infra/state-migrations.workspace-setup-sandbox.ts +++ b/src/infra/state-migrations.workspace-setup-sandbox.ts @@ -41,6 +41,7 @@ export function listSandboxWorkspaceDirs(params: { if (sandbox.scope === "agent") { const layout = resolveSandboxWorkspaceLayoutPaths({ cfg: { ...sandbox, workspaceRoot }, + agentId, rawSessionKey: `agent:${agentId}:main`, workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId, params.env), }); @@ -69,6 +70,7 @@ export function listSandboxWorkspaceDirs(params: { } const layout = resolveSandboxWorkspaceLayoutPaths({ cfg: { ...sandbox, workspaceRoot }, + agentId, rawSessionKey: sessionKey, workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId, params.env), }); diff --git a/src/memory-host-sdk/dreaming.test.ts b/src/memory-host-sdk/dreaming.test.ts index b2365fd027aa..9e774a9a833e 100644 --- a/src/memory-host-sdk/dreaming.test.ts +++ b/src/memory-host-sdk/dreaming.test.ts @@ -231,6 +231,29 @@ describe("memory dreaming host helpers", () => { ]); }); + it("does not require a default owner when no primary workspace is supplied", () => { + const cfg = { + agents: { + ownership: "explicit", + list: [ + { id: "alpha", workspace: "/workspace/alpha" }, + { id: "beta", workspace: "/workspace/beta" }, + ], + }, + } as OpenClawConfig; + + expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([ + { + workspaceDir: "/workspace/alpha", + agentIds: ["alpha"], + }, + { + workspaceDir: "/workspace/beta", + agentIds: ["beta"], + }, + ]); + }); + it("includes the runtime primary workspace alongside configured subagent workspaces", () => { const cfg = { agents: { diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts index 5b9b46b21094..15fab44f403a 100644 --- a/src/memory-host-sdk/dreaming.ts +++ b/src/memory-host-sdk/dreaming.ts @@ -625,9 +625,9 @@ export function resolveMemoryDreamingWorkspaces( for (const agentId of agentIds) { addWorkspace(resolveAgentWorkspaceDir(cfg, agentId, options.env), agentId); } - addWorkspace( - options.primaryWorkspaceDir ?? undefined, - options.primaryAgentId ?? resolveDefaultAgentId(cfg), - ); + const primaryWorkspaceDir = options.primaryWorkspaceDir?.trim(); + if (primaryWorkspaceDir) { + addWorkspace(primaryWorkspaceDir, options.primaryAgentId ?? resolveDefaultAgentId(cfg)); + } return [...byWorkspace.values()]; } diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index d6fbfc62e19b..d14e042b3cab 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -370,6 +370,7 @@ export { assignSafeServerNames as assignMcpCatalogSafeServerNames } from "../age */ export async function prepareHarnessNativeMcpAppPreview(params: { runtime: import("../agents/agent-bundle-mcp-types.js").SessionMcpRuntime; + agentId?: string; serverName: string; toolName: string; uiResourceUri: string; @@ -386,6 +387,7 @@ export async function prepareHarnessNativeMcpAppPreview(params: { await import("../agents/mcp-ui-resource.js"); const view = await fetchMcpAppView({ runtime: params.runtime, + agentId: params.agentId, serverName: params.serverName, toolName: params.toolName, uiResourceUri: params.uiResourceUri, diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index e6a550e97afc..ef8b5b435839 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -35,6 +35,7 @@ const loadPluginManifestRegistryCore = vi.hoisted(() => vi.fn()); const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn()); const loadPluginManifestRegistryForPluginRegistry = vi.hoisted(() => vi.fn()); const loadPluginRegistrySnapshot = vi.hoisted(() => vi.fn()); +const resolveConfigWidePluginManifestRegistry = vi.hoisted(() => vi.fn()); vi.mock("../channels/config-presence.js", () => ({ listPotentialConfiguredChannelIds, @@ -67,6 +68,10 @@ vi.mock("./plugin-registry-contributions.js", async (importOriginal) => { }; }); +vi.mock("../config/io.plugin-metadata.js", () => ({ + resolveConfigWidePluginManifestRegistry, +})); + import { hasConfiguredChannelsForReadOnlyScope, listConfiguredAnnounceChannelIdsForConfig, @@ -393,6 +398,7 @@ function useManifestRegistryFixture( .mockReset() .mockImplementation(() => loadPluginManifestRegistryCore()); loadPluginRegistrySnapshot.mockReset().mockReturnValue(index); + resolveConfigWidePluginManifestRegistry.mockReset().mockReturnValue(registry); return { registry, index }; } @@ -511,7 +517,7 @@ describe("resolveGatewayStartupPluginIdsFromRegistry", () => { .mockImplementation((config: OpenClawConfig) => { return listPotentialConfiguredChannelIds(config).map((channelId: string) => ({ channelId, - source: "config", + source: "env", })); }); useManifestRegistryFixture(); @@ -2484,6 +2490,16 @@ describe("resolveGatewayStartupPluginIdsFromRegistry", () => { options?: { includePersistedAuthState?: boolean }, ) => (options?.includePersistedAuthState === false ? [] : ["demo-channel"]), ); + listPotentialConfiguredChannelPresenceSignals.mockImplementation( + ( + _configForTest: OpenClawConfig, + _env: NodeJS.ProcessEnv, + options?: { includePersistedAuthState?: boolean }, + ) => + options?.includePersistedAuthState === false + ? [] + : [{ channelId: "demo-channel", source: "persisted-auth" }], + ); expectStartupPluginIds({ config: {} as OpenClawConfig, diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts index d01673c5c7ca..4efd03407a2d 100644 --- a/src/plugins/channel-presence-policy.ts +++ b/src/plugins/channel-presence-policy.ts @@ -1,7 +1,6 @@ // Resolves channel presence policy advertised by plugin metadata. import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isChannelConfigMetadataKey } from "../channels/config-metadata.js"; import { hasMeaningfulChannelConfig, @@ -10,6 +9,7 @@ import { type AmbientEnvTriggerPolicy, type ChannelPresenceSignalSource, } from "../channels/config-presence.js"; +import { resolveConfigWidePluginManifestRegistry } from "../config/io.plugin-metadata.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; @@ -342,6 +342,12 @@ function loadInstalledChannelManifestRecords(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; }): readonly PluginManifestRecord[] { + if (!params.workspaceDir) { + return resolveConfigWidePluginManifestRegistry({ + config: params.config, + env: params.env, + }).plugins; + } return loadPluginManifestRegistryForPluginRegistry({ config: params.config, workspaceDir: params.workspaceDir, @@ -361,9 +367,7 @@ export function resolveConfiguredChannelPresencePolicy(params: { manifestRecords?: readonly PluginManifestRecord[]; }): ConfiguredChannelPresencePolicyEntry[] { const env = params.env ?? process.env; - const workspaceDir = - params.workspaceDir ?? - resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config)); + const workspaceDir = params.workspaceDir; const records = params.manifestRecords ?? loadInstalledChannelManifestRecords({ @@ -449,6 +453,78 @@ export function resolveConfiguredChannelPresencePolicy(params: { return entries; } +function listChannelIdsForGatewayPolicy( + params: Omit< + Parameters[0], + "includePersistedAuthState" + >, + includePersistedAuthState: boolean, +): string[] { + return resolveConfiguredChannelPresencePolicy({ + ...params, + includePersistedAuthState, + }) + .filter( + (entry) => + entry.effective || + // A bundled disabled-by-default owner remains eligible even when an + // untrusted sibling manifest for the same channel is also blocked. + entry.blockedReasons.includes("bundled-disabled-by-default"), + ) + .map((entry) => entry.channelId); +} + +export function listGatewayActivatedChannelIds( + params: Omit< + Parameters[0], + "includePersistedAuthState" + >, +): string[] { + // Persisted credentials are migration evidence, not activation consent. + return listChannelIdsForGatewayPolicy(params, false); +} + +export function listChannelIdsForOwnershipMigration( + params: Omit< + Parameters[0], + "includePersistedAuthState" + >, +): string[] { + const env = params.env ?? process.env; + const workspaceDir = params.workspaceDir; + const records = + params.manifestRecords ?? + loadInstalledChannelManifestRecords({ config: params.config, workspaceDir, env }); + const trustConfig = params.activationSourceConfig ?? params.config; + const normalizedConfig = normalizePluginsConfig(trustConfig.plugins); + const persistedTrustedChannelIds = listPotentialConfiguredChannelPresenceSignals( + params.config, + env, + { + includePersistedAuthState: true, + ambientEnvTriggers: params.ambientEnvTriggers, + }, + ) + .filter((signal) => signal.source === "persisted-auth") + .map((signal) => signal.channelId) + .filter((channelId) => + records.some( + (plugin) => + recordDeclaresChannel(plugin, channelId) && + isChannelPluginEligibleForScopedOwnership({ + plugin, + normalizedConfig, + rootConfig: trustConfig, + }), + ), + ); + // Migration preserves trusted persisted state even when activation is disabled. + return normalizeChannelIds([ + ...listChannelIdsForGatewayPolicy(params, true), + ...persistedTrustedChannelIds, + ]); +} + /** Lists channels that suppression removes because their only presence is ambient env. */ export function listAmbientOnlyConfiguredChannelIds( params: Omit[0], "ambientEnvTriggers">, diff --git a/src/plugins/config-contracts.test.ts b/src/plugins/config-contracts.test.ts index c2c3abf66969..75de3cbcfc63 100644 --- a/src/plugins/config-contracts.test.ts +++ b/src/plugins/config-contracts.test.ts @@ -159,6 +159,48 @@ describe("resolvePluginConfigContractsById", () => { expect(mocks.loadBundledManifestRegistry).not.toHaveBeenCalled(); }); + it("hydrates supplied bundled registry records from explicit bundled discovery", () => { + mocks.loadBundledManifestRegistry.mockReturnValue( + createRegistry([ + createPluginRecord({ + id: "prepared-plugin", + origin: "bundled", + configContracts: { + secretInputs: { + paths: [{ path: "credentials.token", expected: "string" }], + }, + }, + }), + ]), + ); + + expect( + resolvePluginConfigContractsById({ + pluginIds: ["prepared-plugin"], + manifestRegistry: createRegistry([ + createPluginRecord({ id: "prepared-plugin", origin: "bundled" }), + ]), + fallbackToBundledMetadata: true, + fallbackToBundledMetadataForResolvedBundled: true, + fallbackBundledPluginIds: ["prepared-plugin"], + }), + ).toEqual( + new Map([ + [ + "prepared-plugin", + { + origin: "bundled", + configContracts: { + secretInputs: { + paths: [{ path: "credentials.token", expected: "string" }], + }, + }, + }, + ], + ]), + ); + }); + it("can hydrate missing contracts from bundled registry for resolved bundled plugins", () => { mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue( createRegistry([ diff --git a/src/plugins/config-contracts.ts b/src/plugins/config-contracts.ts index 002b2fa43bd0..c78cd1de63ab 100644 --- a/src/plugins/config-contracts.ts +++ b/src/plugins/config-contracts.ts @@ -100,13 +100,13 @@ export function resolvePluginConfigContractsById(params: { }); } - if (!params.manifestRegistry && (params.fallbackToBundledMetadata ?? true)) { + if (params.fallbackToBundledMetadata ?? true) { for (const pluginId of pluginIds) { const existing = matches.get(pluginId); const shouldHydrateBundledMatch = existing && ((params.fallbackToBundledMetadataForResolvedBundled && existing.origin === "bundled") || - fallbackBundledPluginIds.has(pluginId)); + (!params.manifestRegistry && fallbackBundledPluginIds.has(pluginId))); if (shouldHydrateBundledMatch) { const bundledConfigContracts = findBundledConfigContracts(pluginId); if (bundledConfigContracts) { @@ -136,6 +136,12 @@ export function resolvePluginConfigContractsById(params: { ) { continue; } + if (params.manifestRegistry && resolvedOrigin && resolvedOrigin !== "bundled") { + continue; + } + if (params.manifestRegistry && !fallbackBundledPluginIds.has(pluginId)) { + continue; + } const bundledConfigContracts = findBundledConfigContracts(pluginId); if (!bundledConfigContracts) { continue; diff --git a/src/plugins/effective-plugin-ids.test.ts b/src/plugins/effective-plugin-ids.test.ts index 2140d83a6245..183a4f060158 100644 --- a/src/plugins/effective-plugin-ids.test.ts +++ b/src/plugins/effective-plugin-ids.test.ts @@ -31,6 +31,13 @@ vi.mock("../channels/config-presence.js", () => ({ listPotentialConfiguredChannelIds: ( ...args: Parameters ) => mocks.listPotentialConfiguredChannelIds(...args), + listPotentialConfiguredChannelPresenceSignals: () => [ + { channelId: "credential-only", source: "persisted-auth" }, + ], +})); + +vi.mock("./channel-presence-policy.js", () => ({ + listExplicitConfiguredChannelIdsForConfig: () => [], })); vi.mock("./channel-plugin-ids.js", () => ({ @@ -56,6 +63,7 @@ vi.mock("./manifest-owner-policy.js", () => ({ })); import { resolveEffectivePluginIds } from "./effective-plugin-ids.js"; +import { collectConfiguredStartupChannelIds } from "./gateway-startup-plugin-config.js"; function resolve(config: OpenClawConfig): string[] { return resolveEffectivePluginIds({ @@ -95,6 +103,22 @@ describe("resolveEffectivePluginIds", () => { mocks.passesManifestOwnerBasePolicy.mockReturnValue(true); }); + it("uses persisted auth for migration discovery but never activation", () => { + mocks.listExplicitlyDisabledChannelIdsForConfig.mockReturnValue(["credential-only"]); + mocks.listPotentialConfiguredChannelIds.mockImplementation((_config, _env, options) => + options?.includePersistedAuthState ? ["credential-only"] : [], + ); + const collect = (includePersistedAuthState = false) => + collectConfiguredStartupChannelIds({ + config: {}, + activationSourceConfig: {}, + env: {}, + ...(includePersistedAuthState ? { includePersistedAuthState: true } : {}), + }); + expect(collect()).toEqual([]); + expect(collect(true)).toEqual(["credential-only"]); + }); + it("includes a selected context-engine slot even when omitted from explicit allow and entries", () => { expect( resolve({ diff --git a/src/plugins/gateway-startup-plugin-config.ts b/src/plugins/gateway-startup-plugin-config.ts index f225319fe9d5..742161356135 100644 --- a/src/plugins/gateway-startup-plugin-config.ts +++ b/src/plugins/gateway-startup-plugin-config.ts @@ -7,6 +7,7 @@ import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { listExplicitlyDisabledChannelIdsForConfig, listPotentialConfiguredChannelIds, + listPotentialConfiguredChannelPresenceSignals, type AmbientEnvTriggerPolicy, } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -76,21 +77,36 @@ function isConfigActivationValueEnabled(value: unknown): boolean { return true; } -export function listPotentialEnabledChannelIds( +function listPotentialEnabledChannelIds( config: OpenClawConfig, env: NodeJS.ProcessEnv, - ambientEnvTriggers: AmbientEnvTriggerPolicy = "allow", + options: { + ambientEnvTriggers?: AmbientEnvTriggerPolicy; + includePersistedAuthState?: boolean; + } = {}, ): string[] { const disabled = new Set(listExplicitlyDisabledChannelIdsForConfig(config)); - return sortUniquePluginIds([ + const enabledSignals = [ ...listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false, - ambientEnvTriggers, + ambientEnvTriggers: options.ambientEnvTriggers, }), ...listExplicitConfiguredChannelIdsForConfig(config), - ]) + ] .map((id) => normalizeOptionalLowercaseString(id) ?? "") .filter((id) => id && !disabled.has(id)); + if (options.includePersistedAuthState !== true) { + return sortUniquePluginIds(enabledSignals); + } + const persistedSignals = listPotentialConfiguredChannelPresenceSignals(config, env, { + includePersistedAuthState: true, + ambientEnvTriggers: options.ambientEnvTriggers, + }) + .filter((signal) => signal.source === "persisted-auth") + .map((signal) => normalizeOptionalLowercaseString(signal.channelId) ?? "") + .filter(Boolean); + // Only persisted-auth evidence bypasses disabled activation during migration. + return sortUniquePluginIds([...enabledSignals, ...persistedSignals]); } function isGatewayStartupMemoryPlugin(plugin: InstalledPluginIndexRecord): boolean { @@ -362,14 +378,17 @@ export function collectConfiguredStartupChannelIds(params: { config: OpenClawConfig; env: NodeJS.ProcessEnv; ambientEnvTriggers?: AmbientEnvTriggerPolicy; + includePersistedAuthState?: boolean; }): string[] { return sortUniquePluginIds([ - ...listPotentialEnabledChannelIds(params.config, params.env, params.ambientEnvTriggers), - ...listPotentialEnabledChannelIds( - params.activationSourceConfig, - params.env, - params.ambientEnvTriggers, - ), + ...listPotentialEnabledChannelIds(params.config, params.env, { + ambientEnvTriggers: params.ambientEnvTriggers, + includePersistedAuthState: params.includePersistedAuthState, + }), + ...listPotentialEnabledChannelIds(params.activationSourceConfig, params.env, { + ambientEnvTriggers: params.ambientEnvTriggers, + includePersistedAuthState: params.includePersistedAuthState, + }), ]); } @@ -414,6 +433,8 @@ export function collectConfigValidationChannelIds(params: { config: params.config, activationSourceConfig: params.config, env: params.env, + // Config reads and backup discovery must not create or migrate the state DB. + includePersistedAuthState: false, }), ...collectValidationHeartbeatTargetChannelIds(params.config), ]); diff --git a/src/plugins/gateway-startup-plugin-metadata.ts b/src/plugins/gateway-startup-plugin-metadata.ts index 87d68bf0c3e7..87a78f58f8d3 100644 --- a/src/plugins/gateway-startup-plugin-metadata.ts +++ b/src/plugins/gateway-startup-plugin-metadata.ts @@ -97,6 +97,7 @@ export function resolveGatewayStartupMetadataPluginIds(params: { activationSourceConfig, env: params.env, ambientEnvTriggers: params.ambientEnvTriggers, + includePersistedAuthState: false, }); if (!lookup.hasDirectChannelOwners(configuredChannelIds)) { return undefined; @@ -179,6 +180,7 @@ export function createGatewayStartupMetadataPluginIdScope(params: { activationSourceConfig: params.activationSourceConfig ?? params.config, env: params.env, ambientEnvTriggers: params.ambientEnvTriggers, + includePersistedAuthState: false, }); const workerProviderIds = normalizeWorkerProviderIds(params.workerProviderIds ?? []); return { diff --git a/src/plugins/gateway-startup-plugin-plan.ts b/src/plugins/gateway-startup-plugin-plan.ts index 4542a0576d69..cc9e0269a834 100644 --- a/src/plugins/gateway-startup-plugin-plan.ts +++ b/src/plugins/gateway-startup-plugin-plan.ts @@ -6,6 +6,7 @@ import { type AmbientEnvTriggerPolicy, } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { listGatewayActivatedChannelIds } from "./channel-presence-policy.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { @@ -14,7 +15,6 @@ import { } from "./gateway-startup-plugin-activation.js"; import { hasConfiguredStartupChannel, - listPotentialEnabledChannelIds, resolveAuthorizedGatewayStartupDreamingPluginIds, resolveContextEngineSlotStartupPluginId, resolveMemorySlotStartupPluginId, @@ -62,8 +62,15 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { const channelPluginIds = resolveChannelPluginIdsFromRegistry({ manifestRegistry: params.manifestRegistry, }); + const activationSourceConfig = params.activationSourceConfig ?? params.config; const configuredChannelIds = new Set( - listPotentialEnabledChannelIds(params.config, params.env, params.ambientEnvTriggers), + listGatewayActivatedChannelIds({ + config: params.config, + activationSourceConfig, + env: params.env, + ambientEnvTriggers: params.ambientEnvTriggers, + manifestRecords: params.manifestRegistry.plugins, + }), ); const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, params.index, { manifestRegistry: params.manifestRegistry, @@ -71,7 +78,6 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { // Startup must classify allowlist exceptions against the raw config snapshot, // not the auto-enabled effective snapshot, or configured-only channels can be // misclassified as explicit enablement. - const activationSourceConfig = params.activationSourceConfig ?? params.config; const activationSourcePlugins = normalizePluginsConfigWithRegistry( activationSourceConfig.plugins, params.index, diff --git a/src/plugins/host-hook-state.ts b/src/plugins/host-hook-state.ts index 6f997de5c990..e7462416a8bb 100644 --- a/src/plugins/host-hook-state.ts +++ b/src/plugins/host-hook-state.ts @@ -277,6 +277,7 @@ export function getPluginSessionExtensionStateSync(params: { export async function patchPluginSessionExtension(params: { cfg: OpenClawConfig; sessionKey: string; + agentId?: string; pluginId: string; namespace: string; value?: PluginJsonValue; @@ -318,7 +319,11 @@ export async function patchPluginSessionExtension(params: { } const slotKey = normalizedSlotKey?.ok === true ? normalizedSlotKey.key : undefined; const updated = await updateResolvedSessionEntry( - { cfg: params.cfg, sessionKey: params.sessionKey }, + { + cfg: params.cfg, + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + }, (entry, context) => { params.assertCurrent?.(); const entryRecord = entry as unknown as Record; diff --git a/src/plugins/host-hooks.ts b/src/plugins/host-hooks.ts index 5dc118fc296e..774318f7ec70 100644 --- a/src/plugins/host-hooks.ts +++ b/src/plugins/host-hooks.ts @@ -119,6 +119,7 @@ export type PluginSessionActionContext = { pluginId: string; actionId: string; sessionKey?: string; + agentId?: string; payload?: PluginJsonValue; client?: { connId?: string; diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 39ef4821ae62..64f301781da2 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -13,7 +13,7 @@ import { loadPluginManifestRegistryForInstalledIndex, resolveInstalledManifestRegistryIndexFingerprint, } from "./manifest-registry-installed.js"; -import type { PluginManifestRecord } from "./manifest-registry.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; import { buildPluginMetadataProviderFacts } from "./plugin-metadata-provider-facts.js"; import { registerPluginMetadataSnapshotReaders } from "./plugin-metadata-snapshot.runtime.js"; @@ -261,6 +261,28 @@ export function listPluginOriginsFromMetadataSnapshot( return new Map(snapshot.plugins.map((record) => [record.id, record.origin])); } +/** Rebuilds every manifest-derived snapshot fact from one authoritative registry. */ +export function rebasePluginMetadataSnapshotManifestRegistry( + snapshot: PluginMetadataSnapshot, + manifestRegistry: PluginManifestRegistry, +): PluginMetadataSnapshot { + const plugins = manifestRegistry.plugins; + return { + ...snapshot, + manifestRegistry, + plugins, + diagnostics: manifestRegistry.diagnostics, + byPluginId: new Map(plugins.map((plugin) => [plugin.id, plugin])), + normalizePluginId: snapshot.index + ? createPluginRegistryIdNormalizer(snapshot.index, { manifestRegistry }) + : snapshot.normalizePluginId, + owners: buildPluginMetadataOwnerMaps(plugins), + ...(snapshot.metrics + ? { metrics: { ...snapshot.metrics, manifestPluginCount: plugins.length } } + : {}), + }; +} + export function loadPluginMetadataSnapshot( params: LoadPluginMetadataSnapshotParams, ): PluginMetadataSnapshot { diff --git a/src/plugins/registry-contribution-types.ts b/src/plugins/registry-contribution-types.ts index 1275c9f229ef..a52332664b76 100644 --- a/src/plugins/registry-contribution-types.ts +++ b/src/plugins/registry-contribution-types.ts @@ -322,8 +322,8 @@ export type SessionDiscussionInfo = { export type SessionDiscussionProvider = { id: string; - info(params: { sessionKey: string }): Promise; - open(params: { sessionKey: string }): Promise; + info(params: { sessionKey: string; agentId: string }): Promise; + open(params: { sessionKey: string; agentId: string }): Promise; }; export type ResolvedPluginRuntimeArtifact = { source: string; rootDir: string }; diff --git a/src/plugins/runtime/load-context.test.ts b/src/plugins/runtime/load-context.test.ts index 90d27de8518e..ab6673a0a32c 100644 --- a/src/plugins/runtime/load-context.test.ts +++ b/src/plugins/runtime/load-context.test.ts @@ -15,6 +15,9 @@ const resolveAgentWorkspaceDirMock = vi.fn< const resolveDefaultAgentIdMock = vi.fn< typeof import("../../agents/agent-scope.js").resolveDefaultAgentId >(() => "default"); +const tryResolveConfiguredAgentWorkspaceDirMock = vi.fn< + typeof import("../../agents/agent-scope.js").tryResolveConfiguredAgentWorkspaceDir +>(() => "/resolved-workspace"); const manifestRegistry = { diagnostics: [], plugins: [] }; const metadataSnapshot = { configFingerprint: "fingerprint", @@ -27,6 +30,10 @@ const metadataSnapshot = { }; type MetadataSnapshotMock = typeof metadataSnapshot & { pluginIds?: readonly string[] }; const loadPluginMetadataSnapshotMock = vi.fn((): MetadataSnapshotMock => metadataSnapshot); +const rebasePluginMetadataSnapshotManifestRegistryMock = vi.fn( + (snapshot: MetadataSnapshotMock) => snapshot, +); +const resolveConfigWidePluginManifestRegistryMock = vi.fn(() => manifestRegistry); const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true); const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined); const setCurrentPluginMetadataSnapshotMock = vi.fn(); @@ -54,11 +61,17 @@ vi.mock("../../config/plugin-auto-enable.apply.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: resolveAgentWorkspaceDirMock, resolveDefaultAgentId: resolveDefaultAgentIdMock, + tryResolveConfiguredAgentWorkspaceDir: tryResolveConfiguredAgentWorkspaceDirMock, +})); + +vi.mock("../../config/io.plugin-metadata.js", () => ({ + resolveConfigWidePluginManifestRegistry: resolveConfigWidePluginManifestRegistryMock, })); vi.mock("../plugin-metadata-snapshot.js", () => ({ isPluginMetadataSnapshotCompatible: isPluginMetadataSnapshotCompatibleMock, loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, + rebasePluginMetadataSnapshotManifestRegistry: rebasePluginMetadataSnapshotManifestRegistryMock, resolvePluginMetadataSnapshot: loadPluginMetadataSnapshotMock, })); @@ -84,10 +97,13 @@ describe("resolvePluginRuntimeLoadContext", () => { isPluginMetadataSnapshotCompatibleMock.mockReset(); isPluginMetadataSnapshotCompatibleMock.mockReturnValue(true); loadPluginMetadataSnapshotMock.mockClear(); + rebasePluginMetadataSnapshotManifestRegistryMock.mockClear(); + resolveConfigWidePluginManifestRegistryMock.mockClear(); getCurrentPluginMetadataSnapshotMock.mockClear(); setCurrentPluginMetadataSnapshotMock.mockClear(); resolveAgentWorkspaceDirMock.mockClear(); resolveDefaultAgentIdMock.mockClear(); + tryResolveConfiguredAgentWorkspaceDirMock.mockClear(); loadConfigMock.mockReturnValue({ plugins: {} }); applyPluginAutoEnableMock.mockImplementation((params) => ({ @@ -153,8 +169,16 @@ describe("resolvePluginRuntimeLoadContext", () => { env, workspaceDir: "/resolved-workspace", }); - expect(resolveDefaultAgentIdMock).toHaveBeenCalledWith(resolvedConfig); - expect(resolveAgentWorkspaceDirMock).toHaveBeenCalledWith(resolvedConfig, "default"); + expect(tryResolveConfiguredAgentWorkspaceDirMock).toHaveBeenNthCalledWith(1, rawConfig, env); + expect(tryResolveConfiguredAgentWorkspaceDirMock).toHaveBeenNthCalledWith( + 2, + resolvedConfig, + env, + ); + expect(resolveConfigWidePluginManifestRegistryMock).toHaveBeenCalledWith({ + config: rawConfig, + env, + }); }); it("reuses a prepared metadata snapshot without resolving metadata again", () => { @@ -268,7 +292,7 @@ describe("resolvePluginRuntimeLoadContext", () => { ...metadataSnapshot, index: { installRecords: { - demo: { source: "registry", version: "1.0.0" }, + demo: { source: "npm", version: "1.0.0" }, }, plugins: [], policyHash: "policy", @@ -282,10 +306,10 @@ describe("resolvePluginRuntimeLoadContext", () => { }); expect(context.installRecords).toEqual({ - demo: { source: "registry", version: "1.0.0" }, + demo: { source: "npm", version: "1.0.0" }, }); expect(buildPluginRuntimeLoadOptions(context).installRecords).toEqual({ - demo: { source: "registry", version: "1.0.0" }, + demo: { source: "npm", version: "1.0.0" }, }); }); diff --git a/src/plugins/runtime/load-context.ts b/src/plugins/runtime/load-context.ts index 703aa521b4fc..60457f4e008b 100644 --- a/src/plugins/runtime/load-context.ts +++ b/src/plugins/runtime/load-context.ts @@ -1,6 +1,7 @@ // Plugin runtime load context helpers resolve agent and workspace facts for runtime activation. -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { tryResolveConfiguredAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { getRuntimeConfig } from "../../config/config.js"; +import { resolveConfigWidePluginManifestRegistry } from "../../config/io.plugin-metadata.js"; import { fingerprintPluginAutoEnableConfig, fingerprintPluginAutoEnableEnv, @@ -17,6 +18,7 @@ import type { PluginManifestRegistry } from "../manifest-registry.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugin-metadata-lifecycle.js"; import { isPluginMetadataSnapshotCompatible, + rebasePluginMetadataSnapshotManifestRegistry, resolvePluginMetadataSnapshot, } from "../plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../plugin-metadata-snapshot.types.js"; @@ -180,17 +182,35 @@ export function resolvePluginRuntimeLoadContext( const env = options?.env ?? process.env; const rawConfig = options?.config ?? getRuntimeConfig(); const rawWorkspaceDir = - options?.workspaceDir ?? resolveAgentWorkspaceDir(rawConfig, resolveDefaultAgentId(rawConfig)); + options?.workspaceDir ?? tryResolveConfiguredAgentWorkspaceDir(rawConfig, env); + const resolveMetadataSnapshot = (params: { + config: OpenClawConfig; + index?: PluginMetadataSnapshot["index"]; + }): PluginMetadataSnapshot => { + const snapshot = resolvePluginMetadataSnapshot({ + config: params.config, + env, + workspaceDir: rawWorkspaceDir, + allowWorkspaceScopedCurrent: true, + ...(params.index ? { index: params.index } : {}), + ...(options?.onlyPluginIds !== undefined ? { pluginIds: options.onlyPluginIds } : {}), + }); + if (options?.workspaceDir !== undefined) { + return snapshot; + } + return rebasePluginMetadataSnapshotManifestRegistry( + snapshot, + resolveConfigWidePluginManifestRegistry({ + config: params.config, + env, + ...(options?.onlyPluginIds !== undefined ? { pluginIds: options.onlyPluginIds } : {}), + }), + ); + }; const initialMetadataSnapshot = options?.metadataSnapshot ?? (options?.manifestRegistry === undefined - ? resolvePluginMetadataSnapshot({ - config: rawConfig, - env, - workspaceDir: rawWorkspaceDir, - allowWorkspaceScopedCurrent: true, - ...(options?.onlyPluginIds !== undefined ? { pluginIds: options.onlyPluginIds } : {}), - }) + ? resolveMetadataSnapshot({ config: rawConfig }) : undefined); const manifestRegistry = options?.manifestRegistry ?? initialMetadataSnapshot?.manifestRegistry; const activationSourceConfig = resolvePluginActivationSourceConfig({ @@ -205,8 +225,7 @@ export function resolvePluginRuntimeLoadContext( snapshot: initialMetadataSnapshot, }); const config = autoEnabled.config; - const workspaceDir = - options?.workspaceDir ?? resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const workspaceDir = options?.workspaceDir ?? tryResolveConfiguredAgentWorkspaceDir(config, env); const metadataSnapshot = options?.manifestRegistry !== undefined ? undefined @@ -218,13 +237,9 @@ export function resolvePluginRuntimeLoadContext( workspaceDir, }) ? initialMetadataSnapshot - : resolvePluginMetadataSnapshot({ + : resolveMetadataSnapshot({ config, - env, - workspaceDir, - allowWorkspaceScopedCurrent: true, ...(initialMetadataSnapshot ? { index: initialMetadataSnapshot.index } : {}), - ...(options?.onlyPluginIds !== undefined ? { pluginIds: options.onlyPluginIds } : {}), }); const finalManifestRegistry = options?.manifestRegistry ?? metadataSnapshot?.manifestRegistry; const installRecords = metadataSnapshot diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index c642fb6b2a51..e1938179d262 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -262,6 +262,7 @@ async function createSessionEntry( const persisted = await upsertAcpSessionMeta({ cfg: params.cfg, sessionKey: context.key, + agentId: context.agentId, mutate: () => meta, }); if (!persisted?.acp) { @@ -316,6 +317,7 @@ async function createSessionEntry( const matchingAcpMeta = acpInitial ? readAcpSessionMetaForEntry({ sessionKey: target.canonicalKey, + agentId: target.agentId, entry: matchingEntry, }) : undefined; @@ -510,6 +512,7 @@ async function createSessionEntry( await upsertAcpSessionMeta({ cfg: params.cfg, sessionKey: callbackContext.key, + agentId: callbackContext.agentId, mutate: () => null, }); } diff --git a/src/plugins/runtime/runtime-tasks.test.ts b/src/plugins/runtime/runtime-tasks.test.ts index 27d2236b0f1b..9765c6b801ba 100644 --- a/src/plugins/runtime/runtime-tasks.test.ts +++ b/src/plugins/runtime/runtime-tasks.test.ts @@ -2,6 +2,7 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getDetachedTaskLifecycleRuntime } from "../../tasks/detached-task-runtime.js"; +import { createTaskRecord } from "../../tasks/task-registry.js"; import { setDetachedTaskLifecycleRuntime } from "../../tasks/task-runtime.test-helpers.js"; import { getRuntimeTaskMocks, @@ -286,4 +287,87 @@ describe("runtime tasks", () => { }); expect(otherTaskRuns.get(child.task.taskId)).toBeUndefined(); }); + + it("isolates task runs for agents sharing a bare session key", async () => { + const runtimeTasks = createRuntimeTasks({ + managedTaskFlow: createRuntimeTaskFlow(), + }); + const opsTaskRuns = runtimeTasks.runs.bindSession({ + sessionKey: "global", + agentId: "ops", + }); + const researchTaskRuns = runtimeTasks.runs.bindSession({ + sessionKey: "global", + agentId: "research", + }); + const agentlessTaskRuns = runtimeTasks.runs.bindSession({ + sessionKey: "global", + }); + const opsTask = createTaskRecord({ + runtime: "acp", + ownerKey: "global", + scopeKind: "session", + requesterAgentId: "ops", + childSessionKey: "agent:ops:acp:child", + runId: "ops-global-run", + task: "Ops global task", + status: "running", + }); + const researchTask = createTaskRecord({ + runtime: "acp", + ownerKey: "global", + scopeKind: "session", + requesterAgentId: "research", + childSessionKey: "agent:research:acp:child", + runId: "research-global-run", + task: "Research global task", + status: "running", + }); + if (!opsTask || !researchTask) { + throw new Error("expected paired global tasks to be created"); + } + + expect(opsTaskRuns.get(opsTask.taskId)?.id).toBe(opsTask.taskId); + expect(opsTaskRuns.list().map((task) => task.id)).toEqual([opsTask.taskId]); + expect(opsTaskRuns.resolve("ops-global-run")?.id).toBe(opsTask.taskId); + + expect(researchTaskRuns.get(opsTask.taskId)).toBeUndefined(); + expect(researchTaskRuns.list().map((task) => task.id)).toEqual([researchTask.taskId]); + expect(researchTaskRuns.resolve("ops-global-run")).toBeUndefined(); + expect(agentlessTaskRuns.get(opsTask.taskId)).toBeUndefined(); + expect(agentlessTaskRuns.list()).toEqual([]); + expect(agentlessTaskRuns.resolve("ops-global-run")).toBeUndefined(); + + const researchCancel = await researchTaskRuns.cancel({ + taskId: opsTask.taskId, + cfg: {} as never, + }); + expect(researchCancel).toEqual({ + found: false, + cancelled: false, + reason: "Task not found.", + }); + const agentlessCancel = await agentlessTaskRuns.cancel({ + taskId: opsTask.taskId, + cfg: {} as never, + }); + expect(agentlessCancel).toEqual({ + found: false, + cancelled: false, + reason: "Task not found.", + }); + expect(runtimeTaskMocks.cancelSessionMock).not.toHaveBeenCalled(); + + const opsCancel = await opsTaskRuns.cancel({ + taskId: opsTask.taskId, + cfg: {} as never, + }); + expect(opsCancel.found).toBe(true); + expect(opsCancel.cancelled).toBe(true); + expect(runtimeTaskMocks.cancelSessionMock).toHaveBeenCalledWith({ + cfg: {}, + sessionKey: "agent:ops:acp:child", + reason: "task-cancel", + }); + }); }); diff --git a/src/plugins/runtime/runtime-tasks.ts b/src/plugins/runtime/runtime-tasks.ts index 5fc539947054..26b6eacd84a1 100644 --- a/src/plugins/runtime/runtime-tasks.ts +++ b/src/plugins/runtime/runtime-tasks.ts @@ -53,6 +53,7 @@ function mapCancelledTaskResult( function createBoundTaskRunsRuntime(params: { sessionKey: string; + agentId?: string; requesterOrigin?: import("../../tasks/task-registry.types.js").TaskDeliveryState["requesterOrigin"]; }): BoundTaskRunsRuntime { const ownerKey = assertSessionKey( @@ -66,18 +67,24 @@ function createBoundTaskRunsRuntime(params: { sessionKey: ownerKey, ...(requesterOrigin ? { requesterOrigin } : {}), get: (taskId) => { - const task = getTaskByIdForOwner({ taskId, callerOwnerKey: ownerKey }); + const task = getTaskByIdForOwner({ + taskId, + callerOwnerKey: ownerKey, + callerAgentId: params.agentId, + }); return task ? mapTaskRunDetail(task) : undefined; }, list: () => listTasksForRelatedSessionKeyForOwner({ relatedSessionKey: ownerKey, callerOwnerKey: ownerKey, + callerAgentId: params.agentId, }).map((task) => mapTaskRunView(task)), findLatest: () => { const task = findLatestTaskForRelatedSessionKeyForOwner({ relatedSessionKey: ownerKey, callerOwnerKey: ownerKey, + callerAgentId: params.agentId, }); return task ? mapTaskRunDetail(task) : undefined; }, @@ -85,6 +92,7 @@ function createBoundTaskRunsRuntime(params: { const task = resolveTaskForLookupTokenForOwner({ token, callerOwnerKey: ownerKey, + callerAgentId: params.agentId, }); return task ? mapTaskRunDetail(task) : undefined; }, @@ -92,6 +100,7 @@ function createBoundTaskRunsRuntime(params: { const task = getTaskByIdForOwner({ taskId, callerOwnerKey: ownerKey, + callerAgentId: params.agentId, }); if (!task) { return { @@ -174,6 +183,7 @@ function createRuntimeTaskRuns(): PluginRuntimeTaskRuns { bindSession: (params) => createBoundTaskRunsRuntime({ sessionKey: params.sessionKey, + agentId: params.agentId, requesterOrigin: params.requesterOrigin, }), fromToolContext: (ctx) => @@ -182,6 +192,7 @@ function createRuntimeTaskRuns(): PluginRuntimeTaskRuns { ctx.sessionKey, "Tasks runtime requires tool context with a sessionKey.", ), + agentId: ctx.agentId, requesterOrigin: ctx.deliveryContext, }), }; diff --git a/src/plugins/runtime/runtime-tasks.types.ts b/src/plugins/runtime/runtime-tasks.types.ts index 70ce9cd2bcbc..9753c546e156 100644 --- a/src/plugins/runtime/runtime-tasks.types.ts +++ b/src/plugins/runtime/runtime-tasks.types.ts @@ -26,10 +26,11 @@ export type BoundTaskRunsRuntime = { export type PluginRuntimeTaskRuns = { bindSession: (params: { sessionKey: string; + agentId?: string; requesterOrigin?: TaskDeliveryState["requesterOrigin"]; }) => BoundTaskRunsRuntime; fromToolContext: ( - ctx: Pick, + ctx: Pick, ) => BoundTaskRunsRuntime; }; diff --git a/src/routing/bindings.ts b/src/routing/bindings.ts index fb702ec040d8..f1b0e5229654 100644 --- a/src/routing/bindings.ts +++ b/src/routing/bindings.ts @@ -1,6 +1,6 @@ import { expectDefined } from "@openclaw/normalization-core"; // Routing binding helpers resolve configured channel and agent route bindings. -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../agents/agent-scope.js"; import { listRouteBindings } from "../config/bindings.js"; import type { AgentRouteBinding } from "../config/types.agents.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -40,7 +40,11 @@ export function resolveDefaultAgentBoundAccountId( if (!normalizedChannel) { return null; } - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const soleAgentId = tryResolveLegacyCompatibilityAgentId(cfg); + if (!soleAgentId) { + return null; + } + const defaultAgentId = normalizeAgentId(soleAgentId); for (const binding of listBindings(cfg)) { const resolved = resolveNormalizedRouteBindingMatch(binding); if ( diff --git a/src/routing/channel-route-targets.ts b/src/routing/channel-route-targets.ts index f7e6e982c4a9..c810a9c9ae0e 100644 --- a/src/routing/channel-route-targets.ts +++ b/src/routing/channel-route-targets.ts @@ -1,6 +1,7 @@ // Channel route target helpers normalize channel route targets for delivery. import { isRecord as hasRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { AgentSelectionRequiredError } from "../agents/agent-scope-config.js"; import { normalizeChatChannelId } from "../channels/ids.js"; import { listRouteBindings } from "../config/bindings.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -81,12 +82,14 @@ export function collectChannelRouteTargets(cfg: OpenClawConfig): ChannelRouteTar // route, so sample it to discover the effective agent target. const sampledAccountIds = accountIds.length > 0 ? accountIds : [DEFAULT_ACCOUNT_ID]; for (const accountId of sampledAccountIds) { - const route = resolveAgentRoute({ - cfg, - channel, - accountId, - }); - addTarget(byAgent, route.agentId, channel); + try { + const route = resolveAgentRoute({ cfg, channel, accountId }); + addTarget(byAgent, route.agentId, channel); + } catch (error) { + if (!(error instanceof AgentSelectionRequiredError)) { + throw error; + } + } } } diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 9833dbd7dd4c..4535ed2ad7bf 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -1,6 +1,11 @@ // Route resolution helpers map user targets to configured channel routes. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + AgentSelectionRequiredError, + listAgentEntries, + resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, +} from "../agents/agent-scope.js"; import type { ChatType } from "../channels/chat-type.js"; import { normalizeChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -111,7 +116,7 @@ export function buildAgentSessionKey(params: { type AgentLookupCache = { agentsRef: OpenClawConfig["agents"] | undefined; byNormalizedId: Map; - fallbackDefaultAgentId: string; + fallbackSoleAgentId?: string; }; const agentLookupCacheByCfg = new WeakMap(); @@ -134,7 +139,7 @@ function resolveAgentLookupCache(cfg: OpenClawConfig): AgentLookupCache { const next: AgentLookupCache = { agentsRef, byNormalizedId, - fallbackDefaultAgentId: sanitizeAgentId(resolveDefaultAgentId(cfg)), + fallbackSoleAgentId: tryResolveLegacyCompatibilityAgentId(cfg), }; agentLookupCacheByCfg.set(cfg, next); return next; @@ -144,20 +149,29 @@ export function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string): const lookup = resolveAgentLookupCache(cfg); const trimmed = (agentId ?? "").trim(); if (!trimmed) { - return lookup.fallbackDefaultAgentId; + return sanitizeAgentId( + lookup.fallbackSoleAgentId ?? + resolveDefaultAgentId(cfg, { + surface: "agent lookup", + hint: "Pass an explicit agent id instead of relying on an implicit route.", + }), + ); } const normalized = normalizeAgentId(trimmed); const resolved = lookup.byNormalizedId.get(normalized); if (resolved) { return resolved; } - if (trimmed === DEFAULT_AGENT_ID) { + if (normalized === DEFAULT_AGENT_ID) { return DEFAULT_AGENT_ID; } if (lookup.byNormalizedId.size === 0) { return sanitizeAgentId(trimmed); } - return lookup.fallbackDefaultAgentId; + throw new AgentSelectionRequiredError([...lookup.byNormalizedId.values()], { + surface: "route binding", + hint: `Update the binding agentId "${trimmed}" to a configured agent.`, + }); } type NormalizedPeerConstraint = @@ -785,7 +799,14 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR } } - return choose(resolveDefaultAgentId(input.cfg), "default"); + return choose( + tryResolveLegacyCompatibilityAgentId(input.cfg) ?? + resolveDefaultAgentId(input.cfg, { + surface: `${channel} account ${accountId} routing`, + hint: `Add a channel-wide binding for ${channel}:${accountId} or configure a sole agent.`, + }), + "default", + ); } /** @internal Resolves fallback precedence for an unknown direct peer. */ diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index c839125bf296..e6f2d2e2cd24 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -683,6 +683,7 @@ describe("secrets apply", () => { const secondStorePath = resolveAuthProfileDatabasePath(secondAgentDir); await writeJsonFile(fixture.configPath, { agents: { + ownership: "explicit", entries: { first: { agentDir: firstAgentDir }, second: { agentDir: secondAgentDir }, @@ -757,6 +758,7 @@ describe("secrets apply", () => { registerResolvedAgentDir({ agentId: "second", agentDir: secondAgentDir }); await writeJsonFile(fixture.configPath, { agents: { + ownership: "explicit", entries: { first: { agentDir: firstAgentDir }, second: { agentDir: secondAgentDir }, diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts index 99d59df3e8b8..e7825a1dcb9c 100644 --- a/src/secrets/channel-contract-api.external.test.ts +++ b/src/secrets/channel-contract-api.external.test.ts @@ -24,6 +24,13 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, })); +vi.mock("../config/io.plugin-metadata.js", () => ({ + resolveConfigWidePluginManifestRegistry: (...args: unknown[]) => { + const snapshot = loadPluginMetadataSnapshotMock(...args); + return snapshot.manifestRegistry ?? snapshot; + }, +})); + vi.mock("../plugins/public-surface-loader.js", () => ({ loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, })); diff --git a/src/secrets/channel-contract-api.fast-path.test.ts b/src/secrets/channel-contract-api.fast-path.test.ts index 501963c34bf1..eaa628ae92a0 100644 --- a/src/secrets/channel-contract-api.fast-path.test.ts +++ b/src/secrets/channel-contract-api.fast-path.test.ts @@ -28,6 +28,13 @@ const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, + resolvePluginMetadataSnapshot: (params: unknown) => { + const snapshot = loadPluginMetadataSnapshotMock(params); + return { + ...snapshot, + manifestRegistry: { plugins: snapshot.plugins, diagnostics: [] }, + }; + }, })); vi.mock("../plugins/public-surface-loader.js", () => ({ @@ -69,6 +76,10 @@ describe("channel contract api explicit fast path", () => { artifactBasename: "contract-api.js", }); expect(loadPluginMetadataSnapshotMock).toHaveBeenCalledTimes(1); - expect(loadPluginMetadataSnapshotMock.mock.calls[0]?.[0]).not.toHaveProperty("workspaceDir"); + expect(loadPluginMetadataSnapshotMock.mock.calls[0]?.[0]).toMatchObject({ + config: {}, + workspaceDir: expect.any(String), + allowWorkspaceScopedCurrent: true, + }); }); }); diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index 882a05243eeb..bdbf1b63de1a 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -2,16 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { - listAgentEntries, - resolveAgentWorkspaceDir, - resolveDefaultAgentId, -} from "../agents/agent-scope.js"; +import { resolveConfigWidePluginManifestRegistry } from "../config/io.plugin-metadata.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openRootFileSync } from "../infra/boundary-file-read.js"; import { shouldRejectHardlinkedPluginFiles } from "../plugins/hardlink-policy.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; -import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { createPluginModuleLoaderCache, getCachedPluginModuleLoader, @@ -161,18 +156,11 @@ function listChannelSecretContractRecords(params: { env: NodeJS.ProcessEnv; loadablePluginOrigins?: ReadonlyMap; }): PluginManifestRecord[] { - // Static target-registry compilation intentionally has no runtime config. - // External plugin discovery can proceed without a workspace scan in that case. - const workspaceDir = - listAgentEntries(params.config).length > 0 - ? resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config), params.env) - : undefined; - const snapshot = loadPluginMetadataSnapshot({ + const manifestRegistry = resolveConfigWidePluginManifestRegistry({ config: params.config, - ...(workspaceDir ? { workspaceDir } : {}), env: params.env, }); - return snapshot.plugins + return manifestRegistry.plugins .filter((record) => record.origin !== "bundled") .filter((record) => recordOwnsChannel(record, params.channelId)) .filter( diff --git a/src/secrets/runtime-config-collectors-plugins.test.ts b/src/secrets/runtime-config-collectors-plugins.test.ts index 9285e0a9d785..169c8dcde7f6 100644 --- a/src/secrets/runtime-config-collectors-plugins.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.test.ts @@ -10,6 +10,10 @@ const { loadPluginManifestRegistryForPluginRegistryMock } = vi.hoisted(() => ({ loadPluginManifestRegistryForPluginRegistryMock: vi.fn(), })); +vi.mock("../config/io.plugin-metadata.js", () => ({ + resolveConfigWidePluginManifestRegistry: () => loadPluginManifestRegistryForPluginRegistryMock(), +})); + vi.mock("../plugins/plugin-registry.js", () => ({ loadPluginManifestRegistryForPluginRegistry: loadPluginManifestRegistryForPluginRegistryMock, })); @@ -160,6 +164,53 @@ describe("collectPluginConfigAssignments", () => { expect(assignment.expected).toBe("string"); }); + it("collects contracts from a secondary agent workspace registry", () => { + loadPluginManifestRegistryForPluginRegistryMock.mockReturnValue({ + plugins: [ + { + id: "research-secret", + origin: "workspace", + configContracts: { + secretInputs: { + bundledDefaultEnabled: false, + paths: [{ path: "apiKey", expected: "string" }], + }, + }, + }, + ], + diagnostics: [], + }); + const config: OpenClawConfig = { + agents: { + ownership: "explicit", + entries: { + ops: { workspace: "/srv/ops" }, + research: { workspace: "/srv/research" }, + }, + }, + plugins: { + entries: { + "research-secret": { + enabled: true, + config: { apiKey: envRef("RESEARCH_API_KEY") }, + }, + }, + }, + }; + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["research-secret", "workspace"]]), + }); + + expect(context.assignments).toMatchObject([ + { path: "plugins.entries.research-secret.config.apiKey" }, + ]); + }); + it("collects from a supplied manifest registry without cold registry loading", () => { const config = createPluginConfig("prepared-plugin", { credentials: { token: envRef("PREPARED_TOKEN") }, diff --git a/src/secrets/runtime-config-collectors-plugins.ts b/src/secrets/runtime-config-collectors-plugins.ts index b8d0e46415bb..0ed29b4db393 100644 --- a/src/secrets/runtime-config-collectors-plugins.ts +++ b/src/secrets/runtime-config-collectors-plugins.ts @@ -1,6 +1,6 @@ /** Collects plugin config secret refs from runtime plugin metadata. */ import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveConfigWidePluginManifestRegistry } from "../config/io.plugin-metadata.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { collectPluginConfigContractMatches, @@ -48,11 +48,12 @@ export function collectPluginConfigAssignments(params: { } const normalizedConfig = normalizePluginsConfig(params.config.plugins); - const workspaceDir = resolveAgentWorkspaceDir( - params.config, - resolveDefaultAgentId(params.config), - params.context.env, - ); + const manifestRegistry = + params.context.manifestRegistry ?? + resolveConfigWidePluginManifestRegistry({ + config: params.config, + env: params.context.env, + }); const bundledLoadablePluginIds = [...(params.loadablePluginOrigins?.entries() ?? [])] .filter(([, origin]) => origin === "bundled") .map(([pluginId]) => pluginId); @@ -60,13 +61,12 @@ export function collectPluginConfigAssignments(params: { [ ...resolvePluginConfigContractsById({ config: params.config, - workspaceDir, env: params.context.env, fallbackToBundledMetadata: true, fallbackToBundledMetadataForResolvedBundled: true, fallbackBundledPluginIds: bundledLoadablePluginIds, pluginIds: Object.keys(entries), - manifestRegistry: params.context.manifestRegistry, + manifestRegistry, }).entries(), ].flatMap(([pluginId, metadata]) => { const secretInputs = metadata.configContracts.secretInputs; diff --git a/src/secrets/runtime-external-channel-audit.test.ts b/src/secrets/runtime-external-channel-audit.test.ts index 10f5c7ae0e49..d1bb82551d08 100644 --- a/src/secrets/runtime-external-channel-audit.test.ts +++ b/src/secrets/runtime-external-channel-audit.test.ts @@ -18,6 +18,13 @@ const { vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, + resolvePluginMetadataSnapshot: (params: unknown) => { + const snapshot = loadPluginMetadataSnapshotMock(params) as { plugins: PluginManifestRecord[] }; + return { + ...snapshot, + manifestRegistry: { plugins: snapshot.plugins, diagnostics: [] }, + }; + }, listPluginOriginsFromMetadataSnapshot: (snapshot: { plugins: Array<{ id: string; origin: PluginOrigin }>; }) => new Map(snapshot.plugins.map((record) => [record.id, record.origin])), diff --git a/src/secrets/runtime-external-channel-origin-discovery.test.ts b/src/secrets/runtime-external-channel-origin-discovery.test.ts index e0aef40785a1..85f8c190b6ec 100644 --- a/src/secrets/runtime-external-channel-origin-discovery.test.ts +++ b/src/secrets/runtime-external-channel-origin-discovery.test.ts @@ -8,6 +8,15 @@ const { loadPluginMetadataSnapshotMock, loadChannelSecretContractApiMock } = vi. vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, + resolvePluginMetadataSnapshot: (params: unknown) => { + const snapshot = loadPluginMetadataSnapshotMock(params) as { + plugins: Array<{ id: string; origin: string }>; + }; + return { + ...snapshot, + manifestRegistry: { plugins: snapshot.plugins, diagnostics: [] }, + }; + }, listPluginOriginsFromMetadataSnapshot: (snapshot: { plugins: Array<{ id: string; origin: string }>; }) => new Map(snapshot.plugins.map((record) => [record.id, record.origin])), diff --git a/src/secrets/runtime-fast-path.ts b/src/secrets/runtime-fast-path.ts index f8b2a2ee31aa..5c4f5eab258b 100644 --- a/src/secrets/runtime-fast-path.ts +++ b/src/secrets/runtime-fast-path.ts @@ -1,15 +1,13 @@ /** Detects when secrets runtime preparation can safely use a fast path. */ import { existsSync } from "node:fs"; +import path from "node:path"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { - listAgentIds, - resolveAgentDir, - resolveDefaultAgentDir, -} from "../agents/agent-scope-config.js"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope-config.js"; import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-profiles/runtime-snapshots.js"; -import { resolveSharedMainAuthAgentDir } from "../agents/auth-profiles/shared-main-dir.js"; import { resolveAuthProfileDatabasePath } from "../agents/auth-profiles/sqlite.js"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; +import { resolveLegacyInheritedAuthDir } from "../agents/legacy-inherited-auth-dir.js"; +import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; @@ -61,7 +59,8 @@ export function collectCandidateAgentDirs( env: NodeJS.ProcessEnv | Record = process.env, ): string[] { const dirs = new Set(); - dirs.add(resolveUserPath(resolveDefaultAgentDir(config, env), env)); + dirs.add(resolveUserPath(resolveAgentDir(config, "main", env), env)); + dirs.add(resolveUserPath(resolveLegacyInheritedAuthDir(config, env), env)); for (const agentId of listAgentIds(config)) { dirs.add(resolveUserPath(resolveAgentDir(config, agentId, env), env)); } @@ -105,7 +104,13 @@ function hasCandidateAuthProfileStoreSources(params: { agentDirs?: string[]; }): boolean { const candidateDirs = resolveCandidateAgentDirs(params); - const mainAgentDir = resolveSharedMainAuthAgentDir(params.env as NodeJS.ProcessEnv); + // The shipped no-argument auth store remains fixed at agents/main/agent. + const mainAgentDir = path.join( + resolveStateDir(params.env as NodeJS.ProcessEnv), + "agents", + "main", + "agent", + ); return ( candidateDirs.some((agentDir) => hasCandidateAuthProfileStoreSource(agentDir)) || hasCandidateAuthProfileStoreSource(mainAgentDir) diff --git a/src/secrets/runtime-manifest.runtime.ts b/src/secrets/runtime-manifest.runtime.ts index 1c74af018813..6e786bc463db 100644 --- a/src/secrets/runtime-manifest.runtime.ts +++ b/src/secrets/runtime-manifest.runtime.ts @@ -2,7 +2,5 @@ * Lazy runtime facade for plugin metadata snapshot reads used by secrets runtime. * Isolating it keeps tests able to mock manifest discovery without loading plugins. */ -export { - listPluginOriginsFromMetadataSnapshot, - loadPluginMetadataSnapshot, -} from "../plugins/plugin-metadata-snapshot.js"; +export { listPluginOriginsFromMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +export { resolveConfigWidePluginManifestRegistry } from "../config/io.plugin-metadata.js"; diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index da1f66bbde09..c890d1b05b89 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -38,26 +38,6 @@ vi.mock("../plugins/installed-plugin-index-records.js", () => ({ loadInstalledPluginIndexInstallRecordsSync: () => ({}), })); -vi.mock("../plugins/plugin-metadata-snapshot.js", () => { - const plugins = COVERAGE_WEB_PROVIDER_PLUGIN_IDS.search.map((id) => ({ - id, - origin: "bundled", - contracts: { - webSearchProviders: [id], - ...(COVERAGE_WEB_PROVIDER_PLUGIN_IDS.fetch.includes(id) ? { webFetchProviders: [id] } : {}), - }, - })); - const createSnapshot = () => ({ - index: { diagnostics: [], plugins: [] }, - manifestRegistry: { diagnostics: [], plugins }, - plugins, - }); - return { - loadPluginMetadataSnapshot: createSnapshot, - resolvePluginMetadataSnapshot: createSnapshot, - }; -}); - vi.mock("./channel-contract-api.js", () => ({ loadChannelSecretContractApi: ({ channelId }: { channelId: string }) => COVERAGE_CHANNEL_CONTRACTS.get(channelId), diff --git a/src/secrets/runtime.fast-path.test.ts b/src/secrets/runtime.fast-path.test.ts index a500e9cdcf5e..981558db10aa 100644 --- a/src/secrets/runtime.fast-path.test.ts +++ b/src/secrets/runtime.fast-path.test.ts @@ -27,7 +27,7 @@ const { resolveRuntimeWebToolsMock, runtimePrepareImportMock } = vi.hoisted(() = })); function explicitMainRoster() { - return { agents: { list: [{ id: "main", default: true }] } }; + return { agents: { list: [{ id: "main" }] } }; } vi.mock("./runtime-prepare.runtime.js", () => { @@ -240,7 +240,7 @@ describe("secrets runtime fast path", () => { const snapshot = prepareSecretsRuntimeFastPathSnapshot({ config: asConfig({ agents: { - list: [{ id: "default", agentDir, default: true }], + list: [{ id: "main", agentDir }], }, }), env, @@ -293,7 +293,7 @@ describe("secrets runtime fast path", () => { const fastPath = prepareSecretsRuntimeFastPathSnapshot({ config: asConfig({ agents: { - list: [{ id: "default", agentDir, default: true }], + list: [{ id: "main", agentDir }], }, }), env, @@ -339,7 +339,7 @@ describe("secrets runtime fast path", () => { }; const config = (port: number) => asConfig({ - agents: { list: [{ id: "default", agentDir, default: true }] }, + agents: { list: [{ id: "main", agentDir }] }, gateway: { port }, }); const initialSnapshot = await prepareSecretsRuntimeSnapshot({ @@ -393,7 +393,7 @@ describe("secrets runtime fast path", () => { }; const initial = await prepareSecretsRuntimeSnapshot({ config: asConfig({ - agents: { list: [{ id: "default", agentDir, default: true }] }, + agents: { list: [{ id: "main", agentDir }] }, }), agentDirs: [agentDir], loadAuthStore, @@ -430,7 +430,7 @@ describe("secrets runtime fast path", () => { }); const config = (port: number) => asConfig({ - agents: { list: [{ id: "default", agentDir, default: true }] }, + agents: { list: [{ id: "main", agentDir }] }, gateway: { port }, }); const initial = await prepareSecretsRuntimeSnapshot({ @@ -480,7 +480,7 @@ describe("secrets runtime fast path", () => { const fastPath = prepareSecretsRuntimeFastPathSnapshot({ config: asConfig({ agents: { - list: [{ id: "default", agentDir, default: true }], + list: [{ id: "main", agentDir }], }, }), env, diff --git a/src/secrets/runtime.loadable-plugin-origins.test.ts b/src/secrets/runtime.loadable-plugin-origins.test.ts index a6817702f879..15239b6e2f3a 100644 --- a/src/secrets/runtime.loadable-plugin-origins.test.ts +++ b/src/secrets/runtime.loadable-plugin-origins.test.ts @@ -21,7 +21,7 @@ const manifestMocks = vi.hoisted(() => ({ vi.mock("./runtime-manifest.runtime.js", () => ({ listPluginOriginsFromMetadataSnapshot: manifestMocks.listPluginOriginsFromMetadataSnapshot, - loadPluginMetadataSnapshot: manifestMocks.loadPluginMetadataSnapshot, + resolveConfigWidePluginManifestRegistry: manifestMocks.loadPluginMetadataSnapshot, })); const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); @@ -81,7 +81,6 @@ describe("prepareSecretsRuntimeSnapshot loadable plugin origins", () => { config: { plugins?: unknown; }; - workspaceDir: unknown; env: Record; }, ] @@ -96,7 +95,6 @@ describe("prepareSecretsRuntimeSnapshot loadable plugin origins", () => { }, }, }); - expect(typeof snapshotParams?.workspaceDir).toBe("string"); expect(snapshotParams?.env.HOME).toBe("/home/demo"); expect(snapshotParams?.env.DEMO_API_KEY).toBe("sk-demo"); expect(manifestMocks.listPluginOriginsFromMetadataSnapshot).toHaveBeenCalledWith(snapshot); @@ -201,7 +199,7 @@ describe("prepareSecretsRuntimeSnapshot loadable plugin origins", () => { expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("value:models/openai"); expect(manifestMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); expect(manifestMocks.listPluginOriginsFromMetadataSnapshot).toHaveBeenCalledWith( - pluginMetadataSnapshot, + pluginMetadataSnapshot.manifestRegistry, ); } finally { fs.rmSync(rootDir, { recursive: true, force: true }); diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 52f0ad4ca8cc..d9d0bbb578dd 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -1,7 +1,6 @@ /** Prepares secrets runtime snapshots from config, auth stores, plugins, and env. */ import { isDeepStrictEqual } from "node:util"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { clearRuntimeAuthProfileStoreSnapshots, loadAuthProfileStoreForSecretsRuntime, @@ -80,25 +79,10 @@ const loadRuntimeOwnerAssignmentHelpers = createLazyRuntimeModule( ); async function resolveLoadablePluginOrigins(params: { - config: OpenClawConfig; - env: NodeJS.ProcessEnv; - pluginMetadataSnapshot?: Pick; + plugins: Pick; }): Promise> { - const workspaceDir = resolveAgentWorkspaceDir( - params.config, - resolveDefaultAgentId(params.config), - params.env, - ); - const { listPluginOriginsFromMetadataSnapshot, loadPluginMetadataSnapshot } = - await loadRuntimeManifestHelpers(); - const snapshot = - params.pluginMetadataSnapshot ?? - loadPluginMetadataSnapshot({ - config: params.config, - workspaceDir, - env: params.env, - }); - return listPluginOriginsFromMetadataSnapshot(snapshot); + const { listPluginOriginsFromMetadataSnapshot } = await loadRuntimeManifestHelpers(); + return listPluginOriginsFromMetadataSnapshot(params.plugins); } function hasConfiguredPluginEntries(config: OpenClawConfig): boolean { @@ -255,18 +239,18 @@ export async function prepareSecretsRuntimeSnapshot(params: { } = await loadRuntimePrepareHelpers(); const { listSecretAssignmentOwners, resolveAndApplySecretAssignments } = await loadRuntimeOwnerAssignmentHelpers(); - const manifestRegistry = - params.manifestRegistry ?? params.pluginMetadataSnapshot?.manifestRegistry; + let manifestRegistry = params.manifestRegistry ?? params.pluginMetadataSnapshot?.manifestRegistry; + if (!manifestRegistry && shouldLoadPluginMetadataForSecrets(sourceConfig)) { + const { resolveConfigWidePluginManifestRegistry } = await loadRuntimeManifestHelpers(); + manifestRegistry = resolveConfigWidePluginManifestRegistry({ + config: sourceConfig, + env: runtimeEnv, + }); + } const loadablePluginOrigins = params.loadablePluginOrigins ?? - (shouldLoadPluginMetadataForSecrets(sourceConfig) - ? await resolveLoadablePluginOrigins({ - config: sourceConfig, - env: runtimeEnv, - pluginMetadataSnapshot: - params.pluginMetadataSnapshot ?? - (manifestRegistry ? { plugins: manifestRegistry.plugins } : undefined), - }) + (manifestRegistry + ? await resolveLoadablePluginOrigins({ plugins: manifestRegistry }) : new Map()); const context = createResolverContext({ sourceConfig, diff --git a/src/sessions/session-lifecycle-events.ts b/src/sessions/session-lifecycle-events.ts index 2ed0618ed2a6..e6835e9d4c4a 100644 --- a/src/sessions/session-lifecycle-events.ts +++ b/src/sessions/session-lifecycle-events.ts @@ -2,6 +2,7 @@ import { resolveGlobalSet, resolveGlobalSingleton } from "../shared/global-singleton.js"; export type SessionLifecycleEvent = { sessionKey: string; + agentId?: string; reason: string; parentSessionKey?: string; label?: string; diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index 9c71a98ed588..0a13b3d34785 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -25,6 +25,7 @@ export const FIRST_USE_STATE_INDEXES = [ // lazy ensures run; fold them into the next natural schema-version bump. export const LAZY_ADDITIVE_STATE_TABLES = [ ...FIRST_USE_STATE_TABLES, + "cron_store_epochs", "model_catalog_remote", "secret_store_entries", "projects", diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index ac017ab08e82..3e7fd2d9e236 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -478,6 +478,11 @@ export interface CronJobs { wake_mode: string; } +export interface CronStoreEpochs { + store_epoch: Generated; + store_key: string; +} + export interface CurrentConversationBindings { account_id: string; binding_id: string; @@ -1703,6 +1708,7 @@ export interface DB { cron_job_runtime_authorities: CronJobRuntimeAuthorities; cron_job_scratch: CronJobScratch; cron_jobs: CronJobs; + cron_store_epochs: CronStoreEpochs; current_conversation_bindings: CurrentConversationBindings; delivery_queue_entries: DeliveryQueueEntries; device_auth_tokens: DeviceAuthTokens; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 0a83a2a9ce3a..8e9dcd7c6438 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1477,6 +1477,11 @@ CREATE TABLE IF NOT EXISTS cron_jobs ( PRIMARY KEY (store_key, job_id) ) STRICT; +CREATE TABLE IF NOT EXISTS cron_store_epochs ( + store_key TEXT PRIMARY KEY, + store_epoch INTEGER NOT NULL DEFAULT 0 +) STRICT; + CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_updated ON cron_jobs(store_key, sort_order ASC, updated_at DESC, job_id); diff --git a/src/system-agent/inference-route.ts b/src/system-agent/inference-route.ts index ec6febd7eeed..0ecb4eb87d74 100644 --- a/src/system-agent/inference-route.ts +++ b/src/system-agent/inference-route.ts @@ -6,6 +6,7 @@ import { listAgentEntries, resolveDefaultAgentId, toAgentEntriesRecord, + tryResolveLegacyCompatibilityAgentId, } from "../agents/agent-scope-config.js"; import { cliBackendAcceptsAuthProfileForwarding, @@ -42,7 +43,13 @@ export function resolveSystemAgentTargetAgentId( if (configuredAgentId) { return normalizeAgentId(configuredAgentId); } - return normalizeAgentId(resolveDefaultAgentId(config)); + return normalizeAgentId( + tryResolveLegacyCompatibilityAgentId(config) ?? + resolveDefaultAgentId(config, { + surface: "system-agent consult routing", + hint: "Set agents.defaults.systemAgent.agentId or pass an explicit consult agent id.", + }), + ); } export type SystemAgentConfiguredRouteDeps = { @@ -276,16 +283,10 @@ export async function projectInferenceRoute( const { runConfig: _runConfig, ...routeWithoutConfig } = route; projectedRoute = routeWithoutConfig; } - const explicitDefaultIds = requestedAgentId - ? [routeAgentId] - : list.filter((entry) => entry.default).map((entry) => normalizeAgentId(entry.id)); return { route: projectedRoute, defaultSelection: { - explicitIds: explicitDefaultIds, - ...(!requestedAgentId && explicitDefaultIds.length === 0 && list[0]?.id - ? { fallbackId: normalizeAgentId(list[0].id) } - : {}), + explicitIds: [routeAgentId], }, auth: { profiles: authProfiles, diff --git a/src/talk/agent-consult-runtime.ts b/src/talk/agent-consult-runtime.ts index d767d06f8ff9..94478b6b815e 100644 --- a/src/talk/agent-consult-runtime.ts +++ b/src/talk/agent-consult-runtime.ts @@ -1,6 +1,6 @@ // Agent consult runtime starts agent consultation flows from talk sessions. import { randomUUID } from "node:crypto"; -import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js"; import { forkSessionEntryFromParent } from "../auto-reply/reply/session-fork.js"; import { resolveSessionWorkStartError } from "../config/sessions/lifecycle.js"; @@ -287,7 +287,12 @@ export async function consultRealtimeVoiceAgent(params: { }) => RealtimeVoiceAgentConsultRunRegistration | void; }): Promise { params.abortSignal?.throwIfAborted(); - const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg); + const agentId = + params.agentId ?? + resolveSessionAgentId({ + config: params.cfg, + sessionKey: params.sessionKey, + }); const agentDir = params.agentRuntime.resolveAgentDir(params.cfg, agentId); const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(params.cfg, agentId); const storePath = params.agentRuntime.session.resolveStorePath(params.cfg.session?.store, { diff --git a/src/talk/agent-target.ts b/src/talk/agent-target.ts index 007f659b6613..1e33711f4196 100644 --- a/src/talk/agent-target.ts +++ b/src/talk/agent-target.ts @@ -1,12 +1,22 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { + resolveDefaultAgentId, + tryResolveLegacyCompatibilityAgentId, +} from "../agents/agent-scope-config.js"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; /** Resolves the configured owner for Talk work that has no agent-scoped session key. */ export function resolveTalkTargetAgentId(config: OpenClawConfig): string { return normalizeAgentId( - normalizeOptionalString(config.talk?.agentId) ?? resolveDefaultAgentId(config), + normalizeOptionalString(config.talk?.agentId) ?? + tryResolveLegacyCompatibilityAgentId(config) ?? + resolveDefaultAgentId(config, { + surface: "Talk relay ownership", + hint: "Set talk.agentId to the agent that owns unscoped Talk sessions.", + }), ); } @@ -15,5 +25,9 @@ export function resolveTalkSessionAgentId( config: OpenClawConfig, sessionKey?: string | null, ): string { - return resolveAgentIdFromSessionKey(sessionKey, resolveTalkTargetAgentId(config)); + const normalizedSessionKey = sessionKey ?? undefined; + const persistedOwner = resolvePersistedSessionStoreOwnerForKey(config, normalizedSessionKey); + return persistedOwner.kind === "none" + ? resolveAgentIdFromSessionKey(normalizedSessionKey, resolveTalkTargetAgentId(config)) + : resolveSessionAgentId({ config, sessionKey: normalizedSessionKey }); } diff --git a/src/tasks/task-owner-access.test.ts b/src/tasks/task-owner-access.test.ts index 665eb7d3e0ed..1ceba2b36f98 100644 --- a/src/tasks/task-owner-access.test.ts +++ b/src/tasks/task-owner-access.test.ts @@ -125,6 +125,34 @@ describe("task owner access", () => { }); }); + it("rejects an agentless caller for a bare owner key", async () => { + await withTaskRegistryTempDir(() => { + const task = createTaskRecord({ + runtime: "acp", + ownerKey: "global", + scopeKind: "session", + requesterAgentId: "ops", + runId: "bare-owner-run", + task: "Agent-owned global task", + status: "queued", + }); + + expect( + getTaskByIdForOwner({ + taskId: task.taskId, + callerOwnerKey: "global", + }), + ).toBeUndefined(); + expect( + getTaskByIdForOwner({ + taskId: task.taskId, + callerOwnerKey: "global", + callerAgentId: "ops", + })?.taskId, + ).toBe(task.taskId); + }); + }); + it("does not expose system-owned tasks through owner-scoped readers", async () => { await withTaskRegistryTempDir(() => { const task = createTaskRecord({ diff --git a/src/tasks/task-owner-access.ts b/src/tasks/task-owner-access.ts index 9d6658106d19..bb470742a34e 100644 --- a/src/tasks/task-owner-access.ts +++ b/src/tasks/task-owner-access.ts @@ -1,5 +1,9 @@ // Normalizes task owner keys and checks requester access to task records. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; +import { getRuntimeConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { findTaskByRunId, getTaskById, @@ -11,38 +15,77 @@ import { import type { TaskNotifyPolicy, TaskRecord } from "./task-registry.types.js"; import { buildTaskStatusSnapshot } from "./task-status.js"; -function canOwnerAccessTask(task: TaskRecord, callerOwnerKey: string): boolean { +type TaskOwnerIdentity = { + callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; +}; + +function canOwnerAccessTask(task: TaskRecord, identity: TaskOwnerIdentity): boolean { + if ( + task.scopeKind !== "session" || + normalizeOptionalString(task.ownerKey) !== normalizeOptionalString(identity.callerOwnerKey) + ) { + return false; + } + const callerAgentId = + normalizeOptionalString(identity.callerAgentId) ?? + parseAgentSessionKey(identity.callerOwnerKey)?.agentId; + // Bare owner keys can collide across per-agent stores, so an unscoped caller + // without a trusted agent identity must fail closed. + if (!callerAgentId) { + return false; + } + let taskAgentId = task.requesterAgentId ?? parseAgentSessionKey(task.ownerKey)?.agentId; + if (!taskAgentId) { + try { + taskAgentId = resolveSessionAgentId({ + sessionKey: task.ownerKey, + config: identity.config ?? getRuntimeConfig(), + }); + } catch { + return false; + } + } return ( - task.scopeKind === "session" && - normalizeOptionalString(task.ownerKey) === normalizeOptionalString(callerOwnerKey) + Boolean(taskAgentId) && + normalizeOptionalString(taskAgentId) === normalizeOptionalString(callerAgentId) ); } export function getTaskByIdForOwner(params: { taskId: string; callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; }): TaskRecord | undefined { const task = getTaskById(params.taskId); - return task && canOwnerAccessTask(task, params.callerOwnerKey) ? task : undefined; + return task && canOwnerAccessTask(task, params) ? task : undefined; } export function findTaskByRunIdForOwner(params: { runId: string; callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; }): TaskRecord | undefined { const task = findTaskByRunId(params.runId); - return task && canOwnerAccessTask(task, params.callerOwnerKey) ? task : undefined; + return task && canOwnerAccessTask(task, params) ? task : undefined; } /** Update an owner-visible task's notification policy. */ export function updateTaskNotifyPolicyForOwner(params: { taskId: string; callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; notifyPolicy: TaskNotifyPolicy; }): TaskRecord | null { const task = getTaskByIdForOwner({ taskId: params.taskId, callerOwnerKey: params.callerOwnerKey, + callerAgentId: params.callerAgentId, + config: params.config, }); if (!task) { return null; @@ -57,12 +100,16 @@ export function updateTaskNotifyPolicyForOwner(params: { export function cancelTaskByIdForOwner(params: { taskId: string; callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; endedAt: number; terminalSummary?: string | null; }): TaskRecord | null { const task = getTaskByIdForOwner({ taskId: params.taskId, callerOwnerKey: params.callerOwnerKey, + callerAgentId: params.callerAgentId, + config: params.config, }); if (!task) { return null; @@ -78,20 +125,26 @@ export function cancelTaskByIdForOwner(params: { export function listTasksForRelatedSessionKeyForOwner(params: { relatedSessionKey: string; callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; }): TaskRecord[] { return listTasksForRelatedSessionKey(params.relatedSessionKey).filter((task) => - canOwnerAccessTask(task, params.callerOwnerKey), + canOwnerAccessTask(task, params), ); } export function buildTaskStatusSnapshotForRelatedSessionKeyForOwner(params: { relatedSessionKey: string; callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; }) { return buildTaskStatusSnapshot( listTasksForRelatedSessionKeyForOwner({ relatedSessionKey: params.relatedSessionKey, callerOwnerKey: params.callerOwnerKey, + callerAgentId: params.callerAgentId, + config: params.config, }), ); } @@ -99,6 +152,8 @@ export function buildTaskStatusSnapshotForRelatedSessionKeyForOwner(params: { export function findLatestTaskForRelatedSessionKeyForOwner(params: { relatedSessionKey: string; callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; }): TaskRecord | undefined { return listTasksForRelatedSessionKeyForOwner(params)[0]; } @@ -106,10 +161,14 @@ export function findLatestTaskForRelatedSessionKeyForOwner(params: { export function resolveTaskForLookupTokenForOwner(params: { token: string; callerOwnerKey: string; + callerAgentId?: string; + config?: OpenClawConfig; }): TaskRecord | undefined { const direct = getTaskByIdForOwner({ taskId: params.token, callerOwnerKey: params.callerOwnerKey, + callerAgentId: params.callerAgentId, + config: params.config, }); if (direct) { return direct; @@ -117,6 +176,8 @@ export function resolveTaskForLookupTokenForOwner(params: { const byRun = findTaskByRunIdForOwner({ runId: params.token, callerOwnerKey: params.callerOwnerKey, + callerAgentId: params.callerAgentId, + config: params.config, }); if (byRun) { return byRun; @@ -124,10 +185,12 @@ export function resolveTaskForLookupTokenForOwner(params: { const related = findLatestTaskForRelatedSessionKeyForOwner({ relatedSessionKey: params.token, callerOwnerKey: params.callerOwnerKey, + callerAgentId: params.callerAgentId, + config: params.config, }); if (related) { return related; } const raw = resolveTaskForLookupToken(params.token); - return raw && canOwnerAccessTask(raw, params.callerOwnerKey) ? raw : undefined; + return raw && canOwnerAccessTask(raw, params) ? raw : undefined; } diff --git a/src/tasks/task-registry-query.ts b/src/tasks/task-registry-query.ts index 62b871702a7b..fc9ec718431f 100644 --- a/src/tasks/task-registry-query.ts +++ b/src/tasks/task-registry-query.ts @@ -1,4 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { clearTaskActivity } from "./task-registry-activity.js"; import { isActiveTaskStatus, ensureLinkedTaskFlowRegistryReady } from "./task-registry-common.js"; @@ -41,16 +43,46 @@ export function listTaskRecordsUnsorted(): TaskRecord[] { return snapshotTaskRecords(tasks); } -function taskMatchesRelatedSession(task: TaskRecord, sessionKey: string | undefined): boolean { +function taskMatchesRelatedSession( + task: TaskRecord, + sessionKey: string | undefined, + sessionAgentId?: string, + cfg?: OpenClawConfig, +): boolean { if (!sessionKey) { return true; } - return [task.requesterSessionKey, task.childSessionKey, task.ownerKey].some( - (candidate) => normalizeOptionalString(candidate) === sessionKey, - ); + return [ + { key: task.requesterSessionKey, agentId: task.requesterAgentId }, + { key: task.childSessionKey, agentId: task.agentId }, + // ownerKey belongs to the requester. task.agentId is the executor/child + // candidate and must never adopt a colliding bare requester session. + { key: task.ownerKey, agentId: task.requesterAgentId }, + ].some((candidate) => { + if (normalizeOptionalString(candidate.key) !== sessionKey) { + return false; + } + if (!sessionAgentId) { + return true; + } + let candidateAgentId = + normalizeOptionalString(candidate.agentId) ?? parseAgentSessionKey(candidate.key)?.agentId; + if (!candidateAgentId && cfg && candidate.key) { + try { + candidateAgentId = resolveSessionAgentId({ config: cfg, sessionKey: candidate.key }); + } catch { + return false; + } + } + return candidateAgentId === sessionAgentId; + }); } -function taskMatchesAgent(task: TaskRecord, agentId: string | undefined): boolean { +function taskMatchesAgent( + task: TaskRecord, + agentId: string | undefined, + cfg?: OpenClawConfig, +): boolean { if (!agentId) { return true; } @@ -58,9 +90,24 @@ function taskMatchesAgent(task: TaskRecord, agentId: string | undefined): boolea if (explicitAgentId) { return explicitAgentId === agentId; } - return [task.requesterSessionKey, task.childSessionKey, task.ownerKey].some( - (candidate) => parseAgentSessionKey(candidate)?.agentId === agentId, - ); + const requesterAgentId = normalizeOptionalString(task.requesterAgentId); + if (requesterAgentId) { + return requesterAgentId === agentId; + } + return [task.requesterSessionKey, task.childSessionKey, task.ownerKey].some((candidate) => { + const parsedAgentId = parseAgentSessionKey(candidate)?.agentId; + if (parsedAgentId) { + return parsedAgentId === agentId; + } + if (!candidate || !cfg) { + return false; + } + try { + return resolveSessionAgentId({ config: cfg, sessionKey: candidate }) === agentId; + } catch { + return false; + } + }); } function taskUpdatedAt(task: TaskRecord): number { @@ -73,6 +120,8 @@ export function listTaskRecordPage(params: { statuses?: readonly TaskStatus[]; agentId?: string; sessionKey?: string; + sessionAgentId?: string; + cfg?: OpenClawConfig; }): { tasks: TaskRecord[]; hasMore: boolean } { ensureTaskRegistryReady(); const statuses = params.statuses ? new Set(params.statuses) : null; @@ -84,8 +133,8 @@ export function listTaskRecordPage(params: { .filter( (task) => (!statuses || statuses.has(task.status)) && - taskMatchesAgent(task, agentId) && - taskMatchesRelatedSession(task, sessionKey), + taskMatchesAgent(task, agentId, params.cfg) && + taskMatchesRelatedSession(task, sessionKey, params.sessionAgentId, params.cfg), ) .toSorted((left, right) => { const updatedDiff = taskUpdatedAt(right) - taskUpdatedAt(left); diff --git a/src/tasks/task-registry.maintenance.ts b/src/tasks/task-registry.maintenance.ts index f4cf04e959f6..90aa02650c58 100644 --- a/src/tasks/task-registry.maintenance.ts +++ b/src/tasks/task-registry.maintenance.ts @@ -562,6 +562,7 @@ function shouldCloseTerminalAcpSession(task: TaskRecord): boolean { } const acpEntry = taskRegistryMaintenanceRuntime.readAcpSessionEntry({ sessionKey, + agentId: task.agentId, clone: false, }); if (!acpEntry || acpEntry.storeReadFailed || !acpEntry.acp) { @@ -603,6 +604,7 @@ async function cleanupTerminalAcpSession(task: TaskRecord): Promise { } const acpEntry = taskRegistryMaintenanceRuntime.readAcpSessionEntry({ sessionKey, + agentId: task.agentId, clone: false, }); const closeAcpSession = taskRegistryMaintenanceRuntime.closeAcpSession; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 8e5854c8e27c..71385c3db310 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -14,7 +14,11 @@ import { resolveResponseUsageMode, } from "../auto-reply/thinking.js"; import { isChatStopCommandText } from "../gateway/chat-abort.js"; -import { agentSessionKeysMatchByRequestKey, normalizeAgentId } from "../routing/session-key.js"; +import { + agentSessionKeysMatchByRequestKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; import { formatTuiLevelCommandUsage, helpText, @@ -256,7 +260,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { try { const result = await client.patchSession({ key: selection.sessionKey, - ...(selection.sessionKey === "global" ? { agentId: selection.agentId } : {}), + ...(!parseAgentSessionKey(selection.sessionKey) ? { agentId: selection.agentId } : {}), ...patch, }); return isCurrentSessionSelection(selection) ? result : null; @@ -768,7 +772,9 @@ export function createCommandHandlers(context: CommandHandlerContext) { const result = await client.resetSession( resetSelection.sessionKey, "reset", - resetSelection.sessionKey === "global" ? { agentId: resetSelection.agentId } : undefined, + !parseAgentSessionKey(resetSelection.sessionKey) + ? { agentId: resetSelection.agentId } + : undefined, ); if (!isCurrentSessionSelection(resetSelection)) { return; @@ -880,7 +886,9 @@ export function createCommandHandlers(context: CommandHandlerContext) { tui.requestRender(); const sendResult = await client.sendChat({ sessionKey: sendSelection.sessionKey, - ...(sendSelection.sessionKey === "global" ? { agentId: sendSelection.agentId } : {}), + ...(!parseAgentSessionKey(sendSelection.sessionKey) + ? { agentId: sendSelection.agentId } + : {}), sessionId: sendSessionId, message: text, thinking: opts.thinking, diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 59e25f25dc3d..2137ea371063 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -120,7 +120,10 @@ describe("tui session actions", () => { agentNames: new Map(), initialSessionInput: "", initialSessionAgentId: null, - resolveSessionKey: vi.fn((raw?: string) => raw ?? "agent:main:main"), + resolveSessionSelection: vi.fn((raw?: string) => ({ + key: raw ?? "agent:main:main", + agentId: "main", + })), updateHeader: vi.fn(), updateFooter: vi.fn(), updateAutocompleteProvider: vi.fn(), @@ -160,6 +163,27 @@ describe("tui session actions", () => { expect(addSystem).toHaveBeenCalledWith("agents list failed: gateway unavailable"); }); + it("switches colliding global sessions as an owner-key pair", async () => { + const state = createBaseState({ + currentAgentId: "research", + currentSessionKey: "global", + }); + const loadHistory = vi.fn().mockResolvedValue({ messages: [] }); + const { setSession } = createTestSessionActions({ + client: { loadHistory, listSessions: vi.fn() } as unknown as TuiBackend, + state, + resolveSessionSelection: vi.fn(() => ({ key: "global", agentId: "ops" })), + }); + + await setSession("agent:ops:global"); + + expect(state.currentAgentId).toBe("ops"); + expect(state.currentSessionKey).toBe("global"); + expect(loadHistory).toHaveBeenCalledWith( + expect.objectContaining({ sessionKey: "global", agentId: "ops" }), + ); + }); + it("returns success after applying a normalized fresh agent roster", async () => { const state = createBaseState({ agents: [{ id: "cached", name: "Cached Agent" }], @@ -1660,7 +1684,10 @@ describe("tui session actions", () => { agentNames: new Map(), initialSessionInput: "", initialSessionAgentId: null, - resolveSessionKey: vi.fn(), + resolveSessionSelection: vi.fn((raw?: string) => ({ + key: raw ?? "agent:main:main", + agentId: "main", + })), updateHeader: vi.fn(), updateFooter: vi.fn(), updateAutocompleteProvider: vi.fn(), @@ -1944,7 +1971,10 @@ describe("tui session actions", () => { agentNames: new Map(), initialSessionInput: "", initialSessionAgentId: null, - resolveSessionKey: vi.fn((raw?: string) => raw ?? "agent:main:main"), + resolveSessionSelection: vi.fn((raw?: string) => ({ + key: raw ?? "agent:main:main", + agentId: "main", + })), updateHeader: vi.fn(), updateFooter: vi.fn(), updateAutocompleteProvider: vi.fn(), @@ -2139,6 +2169,10 @@ describe("tui session actions", () => { chatLog: Object.assign(chatLog, { dropPendingUser }), state, setActivityStatus, + resolveSessionSelection: vi.fn((raw?: string) => ({ + key: raw ?? state.currentSessionKey, + agentId: state.currentAgentId, + })), }); const pendingAbort = abortActive(); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 5bf5c755b159..fe47149ddd11 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -49,7 +49,7 @@ type SessionActionContext = { agentNames: Map; initialSessionInput: string; initialSessionAgentId: string | null; - resolveSessionKey: (raw?: string) => string; + resolveSessionSelection: (raw?: string) => { key: string; agentId: string }; updateHeader: () => void; updateFooter: () => void; updateAutocompleteProvider: () => void; @@ -70,7 +70,7 @@ export function createSessionActions(context: SessionActionContext) { agentNames, initialSessionInput, initialSessionAgentId, - resolveSessionKey, + resolveSessionSelection, updateHeader, updateFooter, updateAutocompleteProvider, @@ -126,9 +126,10 @@ export function createSessionActions(context: SessionActionContext) { state.currentAgentId = state.agents[0]?.id ?? normalizeAgentId(result.defaultId ?? state.currentAgentId); } - const nextSessionKey = resolveSessionKey(initialSessionInput); - if (nextSessionKey !== state.currentSessionKey) { - state.currentSessionKey = nextSessionKey; + const nextSelection = resolveSessionSelection(initialSessionInput); + state.currentAgentId = nextSelection.agentId; + if (nextSelection.key !== state.currentSessionKey) { + state.currentSessionKey = nextSelection.key; } state.initialSessionApplied = true; } else if (!state.agents.some((agent) => agent.id === state.currentAgentId)) { @@ -417,7 +418,7 @@ export function createSessionActions(context: SessionActionContext) { try { const history = await client.loadHistory({ sessionKey: selection.sessionKey, - ...(selection.sessionKey === "global" ? { agentId: selection.agentId } : {}), + ...(!parseAgentSessionKey(selection.sessionKey) ? { agentId: selection.agentId } : {}), limit: opts.historyLimit ?? 200, }); if (!isCurrentLoad()) { @@ -594,10 +595,10 @@ export function createSessionActions(context: SessionActionContext) { const setSession = async (rawKey: string) => { const previousSelection = captureSessionSelection(); - const nextKey = resolveSessionKey(rawKey); + const nextSelection = resolveSessionSelection(rawKey); + const nextKey = nextSelection.key; const selectionChanged = !( - normalizeAgentId(parseAgentSessionKey(nextKey)?.agentId ?? previousSelection.agentId) === - previousSelection.agentId && + nextSelection.agentId === previousSelection.agentId && agentSessionKeysMatchByRequestKey(nextKey, previousSelection.sessionKey) ); if (selectionChanged) { @@ -609,7 +610,7 @@ export function createSessionActions(context: SessionActionContext) { scope: readTuiSessionProjectionScope(state), }); } - updateAgentFromSessionKey(nextKey); + state.currentAgentId = nextSelection.agentId; state.currentSessionKey = nextKey; state.activeChatRunId = null; submit.clearPendingSubmit(state); @@ -662,7 +663,7 @@ export function createSessionActions(context: SessionActionContext) { // ids may no longer exist in local UI state. const result = await client.abortChat({ sessionKey: selection.sessionKey, - ...(selection.sessionKey === "global" ? { agentId: selection.agentId } : {}), + ...(!parseAgentSessionKey(selection.sessionKey) ? { agentId: selection.agentId } : {}), }); if (!isCurrentSessionSelection(selection)) { return; diff --git a/src/tui/tui.local-auth.test.ts b/src/tui/tui.local-auth.test.ts new file mode 100644 index 000000000000..ee5940ca8917 --- /dev/null +++ b/src/tui/tui.local-auth.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { resolveCodexCliBin, resolveLocalAuthSpawnInvocation } from "./tui.js"; + +describe("resolveCodexCliBin", () => { + it("returns null or a valid Codex executable path", async () => { + const result = await resolveCodexCliBin(); + if (result === null) { + expect(result).toBeNull(); + return; + } + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).toContain("codex"); + }); +}); + +describe("resolveLocalAuthSpawnInvocation", () => { + it("wraps Windows cmd shims through cmd.exe", () => { + expect( + resolveLocalAuthSpawnInvocation({ + command: "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd", + args: ["login"], + platform: "win32", + }), + ).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd login"], + options: { windowsHide: true, windowsVerbatimArguments: true }, + }); + }); + + it("wraps spaced Windows bat shim paths with outer command-line quoting", () => { + expect( + resolveLocalAuthSpawnInvocation({ + command: "C:\\Program Files\\Codex\\codex.bat", + args: ["login"], + platform: "win32", + }), + ).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", '""C:\\Program Files\\Codex\\codex.bat" login"'], + options: { windowsHide: true, windowsVerbatimArguments: true }, + }); + }); + + it("keeps direct execution for non-wrapper commands", () => { + expect( + resolveLocalAuthSpawnInvocation({ + command: "/usr/local/bin/codex", + args: ["login"], + platform: "linux", + }), + ).toStrictEqual({ command: "/usr/local/bin/codex", args: ["login"], options: {} }); + expect( + resolveLocalAuthSpawnInvocation({ + command: "C:\\tools\\codex.exe", + args: ["login"], + platform: "win32", + }), + ).toStrictEqual({ command: "C:\\tools\\codex.exe", args: ["login"], options: {} }); + }); +}); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 3791d6c0d567..dd84610192ed 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -3,7 +3,9 @@ import { EventEmitter } from "node:events"; import path from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { AgentSelectionRequiredError } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/config.js"; +import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { withEnv } from "../test-utils/env.js"; import { getSlashCommands, parseCommand } from "./commands.js"; @@ -16,18 +18,17 @@ import { installTuiTerminalLossExitHandler, isIgnorableTuiStopError, isTuiTerminalLossError, - resolveCodexCliBin, resolveCtrlCAction, resolveFinalAssistantText, resolveGatewayDisconnectState, resolveInitialTuiAgentId, resolveTuiToolsToggleActivityStatus, isTuiBusyActivityStatus, - resolveLocalAuthSpawnInvocation, resolveTuiCtrlCAction, resolveTuiLocalAuthCliInvocation, resolveTuiShutdownHardExitMs, resolveTuiSessionKey, + resolveTuiSessionSelection, scheduleProcessExitAfterTuiReturn, stopTuiSafely, } from "./tui.js"; @@ -304,6 +305,7 @@ describe("resolveTuiSessionKey", () => { describe("resolveInitialTuiAgentId", () => { const cfg: OpenClawConfig = { agents: { + ownership: "explicit", list: [ { id: "main", workspace: "/tmp/openclaw" }, { id: "ops", workspace: "/tmp/openclaw/projects/ops" }, @@ -368,6 +370,95 @@ describe("resolveInitialTuiAgentId", () => { cwdSpy.mockRestore(); } }); + + it("falls back to a retained legacy owner", () => { + const retained = retainLegacyDefaultAgentId(structuredClone(cfg), "ops"); + + expect(resolveInitialTuiAgentId({ cfg: retained, cwd: "/var/tmp/unrelated" })).toBe("ops"); + }); + + it("keeps an ownerless explicit fleet selection-required", () => { + expect(() => resolveInitialTuiAgentId({ cfg, cwd: "/var/tmp/unrelated" })).toThrow( + AgentSelectionRequiredError, + ); + }); + + it("uses the persisted fixed-store owner for an unscoped global session", () => { + const restartConfig: OpenClawConfig = { + session: { scope: "global", store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { main: {}, ops: {} }, + }, + }; + + expect( + resolveInitialTuiAgentId({ + cfg: restartConfig, + initialSessionInput: "global", + cwd: "/tmp/openclaw", + }), + ).toBe("ops"); + expect(resolveInitialTuiAgentId({ cfg: restartConfig, cwd: "/tmp/openclaw" })).toBe("ops"); + }); + + it("uses the persisted fixed-store owner for any bare initial session key", () => { + const restartConfig: OpenClawConfig = { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { main: {}, ops: {} }, + }, + }; + + expect( + resolveInitialTuiAgentId({ + cfg: restartConfig, + initialSessionInput: "incident-42", + cwd: "/tmp/openclaw", + }), + ).toBe("ops"); + }); +}); + +describe("resolveTuiSessionSelection", () => { + it("keeps a fixed-store bare key with its persisted owner", () => { + const cfg: OpenClawConfig = { + session: { store: "/tmp/shared.sqlite" }, + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + list: [{ id: "ops" }, { id: "research" }], + }, + }; + + expect( + resolveTuiSessionSelection({ + raw: "incident-42", + cfg, + sessionScope: "per-sender", + currentAgentId: "research", + sessionMainKey: "main", + }), + ).toEqual({ key: "incident-42", agentId: "ops" }); + }); + + it("carries an explicit owner while unwrapping global storage", () => { + const cfg: OpenClawConfig = { + agents: { ownership: "explicit", list: [{ id: "ops" }, { id: "research" }] }, + }; + expect( + resolveTuiSessionSelection({ + raw: "agent:ops:global", + cfg, + sessionScope: "per-sender", + currentAgentId: "research", + sessionMainKey: "main", + }), + ).toEqual({ key: "global", agentId: "ops" }); + }); }); describe("resolveGatewayDisconnectState", () => { @@ -1001,71 +1092,3 @@ describe("TUI shutdown safety", () => { clearInterval(lingeringHandle); }); }); - -describe("resolveCodexCliBin", () => { - it("returns a string path when codex CLI is installed", async () => { - const result = await resolveCodexCliBin(); - // In this test environment codex is installed; verify it returns a non-empty path - if (result !== null) { - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - expect(result).toContain("codex"); - } - }); - - it("returns null or a valid path (never throws)", async () => { - const result = await resolveCodexCliBin(); - if (result === null) { - expect(result).toBeNull(); - } else { - expect(typeof result).toBe("string"); - } - }); -}); - -describe("resolveLocalAuthSpawnInvocation", () => { - it("wraps Windows cmd shims through cmd.exe", () => { - expect( - resolveLocalAuthSpawnInvocation({ - command: "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd", - args: ["login"], - platform: "win32", - }), - ).toEqual({ - command: "C:\\Windows\\System32\\cmd.exe", - args: ["/d", "/s", "/c", "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd login"], - options: { windowsHide: true, windowsVerbatimArguments: true }, - }); - }); - - it("wraps spaced Windows bat shim paths with outer command-line quoting", () => { - expect( - resolveLocalAuthSpawnInvocation({ - command: "C:\\Program Files\\Codex\\codex.bat", - args: ["login"], - platform: "win32", - }), - ).toEqual({ - command: "C:\\Windows\\System32\\cmd.exe", - args: ["/d", "/s", "/c", '""C:\\Program Files\\Codex\\codex.bat" login"'], - options: { windowsHide: true, windowsVerbatimArguments: true }, - }); - }); - - it("keeps direct execution for non-wrapper commands", () => { - expect( - resolveLocalAuthSpawnInvocation({ - command: "/usr/local/bin/codex", - args: ["login"], - platform: "linux", - }), - ).toStrictEqual({ command: "/usr/local/bin/codex", args: ["login"], options: {} }); - expect( - resolveLocalAuthSpawnInvocation({ - command: "C:\\tools\\codex.exe", - args: ["login"], - platform: "win32", - }), - ).toStrictEqual({ command: "C:\\tools\\codex.exe", args: ["login"], options: {} }); - }); -}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index f3d10382a025..2895ee7892fa 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -12,11 +12,18 @@ import { } from "@earendil-works/pi-tui"; import { classifyGatewayConnectFailure } from "../../packages/gateway-protocol/src/connect-error-details.js"; import type { CommandEntry } from "../../packages/gateway-protocol/src/index.js"; -import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + resolveAgentIdByWorkspacePath, + resolveDefaultAgentId, + resolveSessionAgentId, + tryResolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.shared.js"; import { formatCliCommand } from "../cli/command-format.js"; import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { resolveCanonicalMainSessionKey } from "../config/sessions/main-session-key.js"; +import { resolvePersistedSessionStoreOwnerForKey } from "../config/sessions/session-store-owner.js"; import type { EmbeddedStateSignalProcess } from "../infra/embedded-state-lock.js"; import { resolveExecutableFromPathEnv } from "../infra/executable-path.js"; import type { GatewayLockIdentity, GatewayLockOptions } from "../infra/gateway-lock.js"; @@ -204,17 +211,74 @@ export function resolveTuiSessionKey(params: { }); } +export function resolveTuiSessionSelection(params: { + raw?: string; + cfg: OpenClawConfig; + sessionScope: SessionScope; + currentAgentId: string; + sessionMainKey: string; +}): { key: string; agentId: string } { + const trimmed = (params.raw ?? "").trim(); + const parsed = parseAgentSessionKey(trimmed); + const persistedOwner = trimmed + ? resolvePersistedSessionStoreOwnerForKey(params.cfg, trimmed) + : undefined; + const agentId = parsed?.agentId + ? normalizeAgentId(parsed.agentId) + : persistedOwner?.kind === "configured" + ? persistedOwner.agentId + : trimmed + ? resolveSessionAgentId({ + config: params.cfg, + sessionKey: trimmed, + fallbackAgentId: params.currentAgentId, + }) + : params.currentAgentId; + const mainKey = normalizeMainKey(params.sessionMainKey); + const keepDurableBareKey = + !parsed && + persistedOwner?.kind === "configured" && + trimmed !== "global" && + trimmed !== "unknown" && + trimmed.toLowerCase() !== "main" && + trimmed.toLowerCase() !== mainKey; + return { + key: keepDurableBareKey + ? trimmed + : resolveTuiSessionKey({ + raw: trimmed, + sessionScope: params.sessionScope, + currentAgentId: agentId, + sessionMainKey: params.sessionMainKey, + }), + agentId, + }; +} + export function resolveInitialTuiAgentId(params: { cfg: OpenClawConfig; - fallbackAgentId: string; + fallbackAgentId?: string; initialSessionInput?: string; agentId?: string; cwd?: string; }) { + const initialSessionInput = (params.initialSessionInput ?? "").trim(); const explicitAgentId = resolveExplicitInitialTuiAgentId(params); if (explicitAgentId) { return explicitAgentId; } + const effectiveUnscopedSessionKey = initialSessionInput + ? initialSessionInput + : params.cfg.session?.scope === "global" + ? "global" + : undefined; + if (effectiveUnscopedSessionKey) { + return resolveSessionAgentId({ + config: params.cfg, + sessionKey: effectiveUnscopedSessionKey, + fallbackAgentId: params.fallbackAgentId, + }); + } const cwd = params.cwd ?? tryProcessCwd(); const inferredFromWorkspace = cwd ? resolveAgentIdByWorkspacePath(params.cfg, cwd) : null; @@ -222,7 +286,14 @@ export function resolveInitialTuiAgentId(params: { return inferredFromWorkspace; } - return normalizeAgentId(params.fallbackAgentId); + return normalizeAgentId( + params.fallbackAgentId ?? + tryResolveLegacyCompatibilityAgentId(params.cfg) ?? + resolveDefaultAgentId(params.cfg, { + surface: "TUI startup", + hint: "Pass an agent-scoped --session key.", + }), + ); } function resolveExplicitInitialTuiAgentId(params: { @@ -656,17 +727,14 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise { const initialSessionInput = (opts.session ?? "").trim(); const sessionScope = (config.session?.scope ?? "per-sender") as SessionScope; const sessionMainKey = normalizeMainKey(config.session?.mainKey); - const agentDefaultId = resolveDefaultAgentId(config); - const initialSessionAgentId = resolveExplicitInitialTuiAgentId({ - initialSessionInput, - agentId: opts.agentId, - }); + const configuredDefaultAgentId = tryResolveDefaultAgentId(config); let currentAgentId = resolveInitialTuiAgentId({ cfg: config, - fallbackAgentId: agentDefaultId, + fallbackAgentId: configuredDefaultAgentId, initialSessionInput, agentId: opts.agentId, }); + const agentDefaultId = configuredDefaultAgentId ?? currentAgentId; const agentNames = new Map(); let currentSessionKey = ""; let rememberedSessionApplied = false; @@ -928,16 +996,17 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise { return name ? `${id} (${name})` : id; }; - const resolveSessionKey = (raw?: string) => { - return resolveTuiSessionKey({ + const resolveSessionSelection = (raw?: string) => { + return resolveTuiSessionSelection({ raw, + cfg: config, sessionScope: state.sessionScope, currentAgentId: state.currentAgentId, sessionMainKey: state.sessionMainKey, }); }; - currentSessionKey = resolveSessionKey(initialSessionInput); + currentSessionKey = resolveSessionSelection(initialSessionInput).key; const buildLastSessionScopeKeyFor = (sessionKey = currentSessionKey) => { const parsed = parseAgentSessionKey(sessionKey); @@ -973,12 +1042,13 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise { ) { return; } - const rememberedKey = remembered ? resolveSessionKey(remembered) : null; + const rememberedSelection = remembered ? resolveSessionSelection(remembered) : null; + const rememberedKey = rememberedSelection?.key ?? null; if (!rememberedKey || rememberedKey === currentSessionKey) { rememberedSessionApplied = true; return; } - const rememberedAgent = parseAgentSessionKey(rememberedKey)?.agentId; + const rememberedAgent = rememberedSelection?.agentId; if (rememberedAgent && normalizeAgentId(rememberedAgent) !== state.currentAgentId) { rememberedSessionApplied = true; return; @@ -1312,6 +1382,12 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise { }, }; + const initialSessionAgentId = (() => { + if (!initialSessionInput) { + return null; + } + return currentAgentId; + })(); const sessionActions = createSessionActions({ client, chatLog, @@ -1322,7 +1398,7 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise { agentNames, initialSessionInput, initialSessionAgentId, - resolveSessionKey, + resolveSessionSelection, updateHeader, updateFooter, updateAutocompleteProvider, diff --git a/test/non-isolated-runner.test.ts b/test/non-isolated-runner.test.ts index ee411e76ddc4..52fd8b0c4e54 100644 --- a/test/non-isolated-runner.test.ts +++ b/test/non-isolated-runner.test.ts @@ -79,6 +79,7 @@ it("applies vi.mock factories after a sibling file fails during collection", asy await write( "vitest.config.ts", [ + `import { sharedVitestConfig } from ${JSON.stringify(path.join(repoRoot, "test", "vitest", "vitest.shared.config.ts"))};`, 'import { defineConfig } from "vitest/config";', 'import { BaseSequencer } from "vitest/node";', "// Alphabetical order keeps a-crash collected before b-mock regardless of", @@ -90,6 +91,7 @@ it("applies vi.mock factories after a sibling file fails during collection", asy "}", "export default defineConfig({", ` cacheDir: ${JSON.stringify(path.join(root, ".vite"))},`, + " resolve: sharedVitestConfig.resolve,", " test: {", " isolate: false,", " fileParallelism: false,", @@ -240,6 +242,7 @@ it("clears named plugin runtime slots between files", async () => { await write( "vitest.config.ts", [ + `import { sharedVitestConfig } from ${JSON.stringify(path.join(repoRoot, "test", "vitest", "vitest.shared.config.ts"))};`, 'import { defineConfig } from "vitest/config";', 'import { BaseSequencer } from "vitest/node";', "class AlphabeticalSequencer extends BaseSequencer {", @@ -249,6 +252,7 @@ it("clears named plugin runtime slots between files", async () => { "}", "export default defineConfig({", ` cacheDir: ${JSON.stringify(path.join(root, ".vite"))},`, + " resolve: sharedVitestConfig.resolve,", " test: {", " isolate: false,", " fileParallelism: false,", diff --git a/ui/src/lib/session-viewer-presence.test.ts b/ui/src/lib/session-viewer-presence.test.ts index 06279fd7ebcf..2c67f3b1f9ee 100644 --- a/ui/src/lib/session-viewer-presence.test.ts +++ b/ui/src/lib/session-viewer-presence.test.ts @@ -110,6 +110,26 @@ describe("session viewer presence store", () => { expect(harness.unsubscribe).toHaveBeenCalledOnce(); }); + it("threads the selected owner for a bare global viewer identity", async () => { + const harness = createGatewayHarness(); + harness.setSnapshot({ + ...harness.gateway.snapshot, + assistantAgentId: "work", + hello: createHello("global"), + }); + const store = sessionViewerPresenceForGateway(harness.gateway); + const owner = {}; + store.watch(owner, ["global"]); + await flushSync(); + + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + agentId: "work", + sessionKeys: ["global"], + }); + store.unwatch(owner); + await flushSync(); + }); + it("declares empty while hidden and restores the set when visible", async () => { const harness = createGatewayHarness(); const store = sessionViewerPresenceForGateway(harness.gateway); diff --git a/ui/src/lib/session-viewer-presence.ts b/ui/src/lib/session-viewer-presence.ts index c946405bc213..16c1f514e2b6 100644 --- a/ui/src/lib/session-viewer-presence.ts +++ b/ui/src/lib/session-viewer-presence.ts @@ -120,7 +120,10 @@ function createStore(gateway: ApplicationGateway): SessionViewerPresenceStore { typeof document !== "undefined" && document.visibilityState === "hidden" ? [] : visibleSessionKeys(); - const signature = JSON.stringify(sessionKeys); + const agentId = sessionKeys.some((key) => !key.startsWith("agent:")) + ? snapshot.assistantAgentId + : undefined; + const signature = JSON.stringify({ agentId, sessionKeys }); if (snapshot.hello === lastHello && signature === lastSignature) { if ( !isActive() && @@ -140,7 +143,10 @@ function createStore(gateway: ApplicationGateway): SessionViewerPresenceStore { snapshot.hello === lastHello && signature === lastSignature; retry.cancel(); - const request = client.request(SESSION_VIEWERS_SET_METHOD, { sessionKeys }); + const request = client.request(SESSION_VIEWERS_SET_METHOD, { + ...(agentId ? { agentId } : {}), + sessionKeys, + }); void request .then(() => { if (isCurrentRequest()) { diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index cdf6e9a51e5e..3227e3826967 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -248,12 +248,15 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { const sessionKey = state.sessionKey; this.requestSessionRail("open"); if (!state.connected || !state.client) { - this.sessionCompanionThreads.setDraft(sessionKey, question); + this.sessionCompanionThreads.setDraft(sessionKey, question, state.assistantAgentId); return; } const client = state.client; - await this.sessionCompanionThreads.submit(sessionKey, question, (key, value) => - requestSessionCompanionAnswer(client, key, value), + await this.sessionCompanionThreads.submit( + sessionKey, + question, + (key, value) => requestSessionCompanionAnswer(client, key, value, state.assistantAgentId), + state.assistantAgentId, ); }; @@ -262,7 +265,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { if (!sessionKey) { return; } - this.sessionCompanionThreads.setDraft(sessionKey, question); + this.sessionCompanionThreads.setDraft(sessionKey, question, this.state?.assistantAgentId); this.requestSessionRail("open"); }; @@ -271,14 +274,16 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { if (!state?.connected || !state.client || !sessionKey || parseCatalogSessionKey(sessionKey)) { return; } - const hydrationKey = `${this.connectionGeneration}\0${sessionKey}`; + const hydrationKey = `${this.connectionGeneration}\0${state.assistantAgentId ?? ""}\0${sessionKey}`; if (this.sessionCompanionHydrationKey === hydrationKey) { return; } this.sessionCompanionHydrationKey = hydrationKey; this.ensureSessionRail(); - void this.sessionCompanionThreads.hydrate(sessionKey, (key) => - requestSessionCompanionState(state.client!, key), + void this.sessionCompanionThreads.hydrate( + sessionKey, + (key) => requestSessionCompanionState(state.client!, key, state.assistantAgentId), + state.assistantAgentId, ); } @@ -288,7 +293,11 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { return; } await this.sessionCompanionThreads - .reset(state.sessionKey, (key) => resetSessionCompanion(state.client!, key)) + .reset( + state.sessionKey, + (key) => resetSessionCompanion(state.client!, key, state.assistantAgentId), + state.assistantAgentId, + ) .catch(() => undefined); }; protected resetConfirmation: diff --git a/ui/src/pages/chat/chat-pane-board.ts b/ui/src/pages/chat/chat-pane-board.ts index 946d026a505d..ee75e6a4a7b0 100644 --- a/ui/src/pages/chat/chat-pane-board.ts +++ b/ui/src/pages/chat/chat-pane-board.ts @@ -156,10 +156,7 @@ export abstract class ChatPaneBoard extends ChatPaneHistory { } protected resolveBoardProvider(): BoardProvider { - const sessionKey = resolveSessionKey( - this.state?.sessionKey ?? this.sessionKey, - this.context?.gateway.snapshot.hello, - ); + const sessionKey = this.resolveBoardSessionKey(); if (this.boardProvider) { this.releaseBoardProviderLease(); return this.boardProvider; diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 8a9e0a8448e0..ba202759aa81 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -28,6 +28,7 @@ import { renderBoardViewSwitch } from "./board-session-surface.ts"; import { resolveChatPanePlacement } from "./chat-pane-placement.ts"; import { ChatPaneSessionMenu } from "./chat-pane-session-menu.ts"; import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; +import { resolveChatAgentId } from "./chat-state-route.ts"; import { renderBackgroundTasksToggle } from "./components/chat-background-tasks-render.ts"; import type { BackgroundTasksProps } from "./components/chat-background-tasks.types.ts"; import { isChatRunWorking } from "./components/chat-composer.ts"; @@ -520,6 +521,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { try { const info = await state.client.request("session.discussion.info", { sessionKey, + agentId: resolveChatAgentId(state), }); // A reconnect supersedes in-flight probes; a stale result must not // overwrite the new source's cache (e.g. an old "none" hiding the action). @@ -570,6 +572,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { } return await state.client.request("session.discussion.info", { sessionKey: key, + agentId: resolveChatAgentId(state), }); }, openDiscussion: async (key) => { @@ -578,6 +581,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { } return await state.client.request("session.discussion.open", { sessionKey: key, + agentId: resolveChatAgentId(state), }); }, onStateChange: (key, discussionState, openUrl) => { diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index c5ccb817002d..3da5927d3519 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -297,13 +297,13 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { observerLastReadAt: selectedSession?.lastReadAt, sessionRailCompanion: catalogKey ? undefined - : this.sessionCompanionThreads.view(state.sessionKey), + : this.sessionCompanionThreads.view(state.sessionKey, state.assistantAgentId), ...this.sessionRailCommandProps(state.sessionKey), sessionRailMode: this.selectedSessionRailMode(state.sessionKey), sessionRailDocked: !catalogKey && chatMainWidth >= SESSION_RAIL_SIDE_MIN_PANE_WIDTH, onSessionRailSubmit: (question) => void this.submitSessionCompanionQuestion(question), onSessionRailDraftChange: (draft) => - this.sessionCompanionThreads.setDraft(state.sessionKey, draft), + this.sessionCompanionThreads.setDraft(state.sessionKey, draft, state.assistantAgentId), onSessionRailClear: () => void this.clearSessionCompanion(), onSessionRailModeChange: (mode) => { if (state.sessionKey !== this.sessionRailModeSessionKey || mode !== this.sessionRailMode) { diff --git a/ui/src/pages/chat/chat-session-companion.ts b/ui/src/pages/chat/chat-session-companion.ts index af178a4e2465..3c0b4682952a 100644 --- a/ui/src/pages/chat/chat-session-companion.ts +++ b/ui/src/pages/chat/chat-session-companion.ts @@ -78,6 +78,10 @@ function createThread(): MutableCompanionThread { }; } +function companionThreadKey(sessionKey: string, agentId?: string | null): string { + return `${agentId?.trim() ?? ""}\0${sessionKey.trim()}`; +} + /** Pane-owned ephemeral companion threads, keyed by the exact selected session. */ export class ChatSessionCompanionThreads { private readonly threads = new Map(); @@ -86,12 +90,12 @@ export class ChatSessionCompanionThreads { constructor(private readonly notify: () => void = () => {}) {} - view(sessionKey: string): ChatSessionCompanionThread { - return this.get(sessionKey); + view(sessionKey: string, agentId?: string | null): ChatSessionCompanionThread { + return this.get(sessionKey, agentId); } - setDraft(sessionKey: string, draft: string): void { - const thread = this.get(sessionKey); + setDraft(sessionKey: string, draft: string, agentId?: string | null): void { + const thread = this.get(sessionKey, agentId); if (thread.draft === draft) { return; } @@ -103,17 +107,19 @@ export class ChatSessionCompanionThreads { async hydrate( sessionKey: string, load: (sessionKey: string) => Promise, + agentId?: string | null, ): Promise { - const key = sessionKey.trim(); - if (!key) { + const targetSessionKey = sessionKey.trim(); + if (!targetSessionKey) { return; } - const thread = this.get(key); + const key = companionThreadKey(targetSessionKey, agentId); + const thread = this.get(targetSessionKey, agentId); const revision = thread.revision; const token = Symbol(key); this.hydrationTokens.set(key, token); try { - const result = await load(key); + const result = await load(targetSessionKey); if (this.hydrationTokens.get(key) !== token || thread.revision !== revision) { return; } @@ -151,13 +157,15 @@ export class ChatSessionCompanionThreads { sessionKey: string, question: string, ask: (sessionKey: string, question: string) => Promise, + agentId?: string | null, ): Promise { - const key = sessionKey.trim(); + const targetSessionKey = sessionKey.trim(); const normalized = question.trim(); - if (!key || !normalized) { + if (!targetSessionKey || !normalized) { return; } - const thread = this.get(key); + const key = companionThreadKey(targetSessionKey, agentId); + const thread = this.get(targetSessionKey, agentId); if (thread.pendingQuestion) { return; } @@ -173,7 +181,7 @@ export class ChatSessionCompanionThreads { this.submissionTokens.set(key, token); this.notify(); try { - const result = await ask(key, normalized); + const result = await ask(targetSessionKey, normalized); if (this.submissionTokens.get(key) !== token) { return; } @@ -215,20 +223,22 @@ export class ChatSessionCompanionThreads { async reset( sessionKey: string, clear: (sessionKey: string) => Promise, + agentId?: string | null, ): Promise { - const key = sessionKey.trim(); - if (!key) { + const targetSessionKey = sessionKey.trim(); + if (!targetSessionKey) { return; } - await clear(key); + const key = companionThreadKey(targetSessionKey, agentId); + await clear(targetSessionKey); this.hydrationTokens.delete(key); this.submissionTokens.delete(key); this.threads.set(key, createThread()); this.notify(); } - private get(sessionKey: string): MutableCompanionThread { - const key = sessionKey.trim(); + private get(sessionKey: string, agentId?: string | null): MutableCompanionThread { + const key = companionThreadKey(sessionKey, agentId); let thread = this.threads.get(key); if (!thread) { thread = createThread(); @@ -242,10 +252,11 @@ export function requestSessionCompanionAnswer( client: Pick, sessionKey: string, question: string, + agentId?: string | null, ): Promise { return client.request( "sessions.companion.ask", - { sessionKey, question }, + { sessionKey, ...(agentId ? { agentId } : {}), question }, { timeoutMs: COMPANION_ASK_TIMEOUT_MS }, ); } @@ -253,13 +264,21 @@ export function requestSessionCompanionAnswer( export function requestSessionCompanionState( client: Pick, sessionKey: string, + agentId?: string | null, ): Promise { - return client.request("sessions.companion.state", { sessionKey }); + return client.request("sessions.companion.state", { + sessionKey, + ...(agentId ? { agentId } : {}), + }); } export function resetSessionCompanion( client: Pick, sessionKey: string, + agentId?: string | null, ): Promise { - return client.request("sessions.companion.reset", { sessionKey }); + return client.request("sessions.companion.reset", { + sessionKey, + ...(agentId ? { agentId } : {}), + }); } diff --git a/ui/src/pages/chat/chat-session-rail.test.ts b/ui/src/pages/chat/chat-session-rail.test.ts index e804b761952d..7e72ff858d95 100644 --- a/ui/src/pages/chat/chat-session-rail.test.ts +++ b/ui/src/pages/chat/chat-session-rail.test.ts @@ -217,18 +217,18 @@ describe("ChatSessionCompanionThreads", () => { }); const client = { request: request as GatewayBrowserClient["request"] }; - await requestSessionCompanionAnswer(client, "one", "Question"); - await requestSessionCompanionState(client, "one"); - await resetSessionCompanion(client, "one"); + await requestSessionCompanionAnswer(client, "one", "Question", "work"); + await requestSessionCompanionState(client, "one", "work"); + await resetSessionCompanion(client, "one", "work"); expect(request.mock.calls).toEqual([ [ "sessions.companion.ask", - { sessionKey: "one", question: "Question" }, + { sessionKey: "one", agentId: "work", question: "Question" }, { timeoutMs: 70_000 }, ], - ["sessions.companion.state", { sessionKey: "one" }], - ["sessions.companion.reset", { sessionKey: "one" }], + ["sessions.companion.state", { sessionKey: "one", agentId: "work" }], + ["sessions.companion.reset", { sessionKey: "one", agentId: "work" }], ]); }); @@ -251,6 +251,15 @@ describe("ChatSessionCompanionThreads", () => { expect(threads.view("two").exchanges[0]?.answer).toBe("Answer for two"); }); + it("keeps matching bare session keys isolated by agent", () => { + const threads = new ChatSessionCompanionThreads(); + threads.setDraft("global", "main draft", "main"); + threads.setDraft("global", "work draft", "work"); + + expect(threads.view("global", "main").draft).toBe("main draft"); + expect(threads.view("global", "work").draft).toBe("work draft"); + }); + it("moves a composer submission through pending to a timestamped answer", async () => { let resolveAnswer!: (value: { answer: string; ts: number }) => void; const threads = new ChatSessionCompanionThreads(); diff --git a/ui/src/pages/workboard/view-card-details.ts b/ui/src/pages/workboard/view-card-details.ts index bc2311c46ef6..c065f91f8afc 100644 --- a/ui/src/pages/workboard/view-card-details.ts +++ b/ui/src/pages/workboard/view-card-details.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts"; import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; +import { normalizeAgentId, parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { addWorkboardCardComment, getWorkboardDependencyState, @@ -43,6 +44,13 @@ function ensureWorkboardCardDashboardElement(): Promise { ); } +function boardSessionKeyForCard(sessionKey: string, agentId?: string): string { + if (!agentId || parseAgentSessionKey(sessionKey)) { + return sessionKey; + } + return `agent:${normalizeAgentId(agentId)}:${sessionKey}`; +} + export function openCardDetails(state: WorkboardUiState, card: WorkboardCard) { state.detailCardId = card.id; state.detailCommentBody = ""; @@ -318,7 +326,7 @@ export function renderCardDetailsPanel(props: WorkboardProps) { ${linkedSessionKey ? html`