diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0d7c352d0..268f67570 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,7 +64,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Exact Gateway wizard terminal-restart compatibility and bounded retry policy | `GatewayWizardRestartRecoveryPolicy` | authoritative | | Managed-local automatic repair eligibility and orchestration | `ManagedLocalGatewayAutoRepairMonitor` + `ManagedLocalGatewayRepairCoordinator` | authoritative | | Capability UI metadata | `NodeCapabilityUiCatalog` (planned) | planned | -| Capability registration/gating | `NodeCapabilityRegistrationPolicy` (planned) | planned | +| Capability registration/gating | `NodeCapabilityRegistry` | authoritative | | Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned | | Gateway connect envelope | `ConnectEnvelopeBuilder` (planned) | planned | | Gateway request tracking | `PendingRequestRegistry` (planned) | planned | @@ -146,6 +146,7 @@ leading and trailing pipe. Columns, in order: | functional-chat-default-mount | closed | src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs | mounting the FunctionalUI chat tree as the default ChatPage or ChatWindow surface | ReactorChatHostExtensions and OpenClawReactorChatRoot | legacy FunctionalUI chat files may remain for focused compatibility coverage only | ChatPage and ChatWindow mount the Reactor root directly into their existing ChatHost Borders; no FunctionalUI component mounts or nests Reactor on the default path | review-only: user explicitly deferred new tests for this migration; required build and existing shared/tray suites still run | review-only | when legacy FunctionalUI chat surfaces are removed | | settings-store | authoritative | src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | hand-rolled save/echo suppression flags for two-way settings binding | ISettingsStore | PermissionsPage and other surfaces may read SettingsManager directly until migrated | a save originating from Update does not echo Changed to the caller and external saves are republished on the UI thread | SettingsStoreTests.Update_DoesNotEchoChangedToSelf | behavioral | when all settings surfaces read and write through ISettingsStore | | settings-page-vm | authoritative | src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | settings load, persist, echo-guard, and auto-save wiring | SettingsPageViewModel | code-behind keeps gateway-uninstall, gateway-info and uptime timer, saved-indicator visual, and app-info population | each settings control persists its field through the store preserving mutate-save-notify order and does not re-persist on external change | SettingsPageViewModelTests.ExternalChange_ReloadsWithoutRePersisting | behavioral | when the Settings page holds no settings persistence logic in code-behind | +| node-capability-registry | authoritative | src/OpenClaw.Tray.WinUI/Services/NodeService.cs | mutable capability-list storage, Codex access-mode and executable-availability advertisement policy, and shared MCP/Gateway snapshot publication | NodeCapabilityRegistry | NodeService constructs capabilities, wires UI handlers, applies device permissions, and hosts MCP lifecycle | Off advertises no Codex commands; available ReadOnly and ReadAndSteer advertise exactly the two read commands; unavailable Codex advertises none; MCP and Gateway consume one immutable shared snapshot | NodeCapabilityRegistryTests.NodeService_DoesNotOwnCapabilityRegistryStorageOrRegistration | source-shape | when NodeService no longer constructs or wires any node capability | | exec-reusable-binding | authoritative | src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs | deriving durable allowlist identities and Allow Always patterns from multi-segment shell resolution | ExecReusableCommandBinder | ExecCommandResolver.Resolve stays the singular resolution used by the state machine and prompt display | at most one identity may be durably authorized per request and it is a fully qualified existing `.exe` image whose arguments are pinned by the generated rule | ExecReusableCommandBinderTests.MultiElementCarrierTail_Binds | behavioral | - | | exec-multi-segment-allowlist-closed | closed | src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs | ResolveForAllowlist and ResolveAllowAlwaysPatterns feeding allowlist matching or Allow Always patterns | ExecReusableCommandBinder | the two methods remain compiled with their historical tests until removed but have no production callers | the approval pipeline derives AllowlistResolutions and AllowAlwaysPatterns only from ExecReusableCommandBinder.TryBind | ExecApprovalV2NormalizationPipelineOwnershipTests.Normalizer_DerivesDurableIdentity_OnlyFromReusableBinder | source-shape | when ResolveForAllowlist and ResolveAllowAlwaysPatterns are deleted | | canonical-cmd-carrier | authoritative | src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | recognizing the cmd.exe /d /s /c carrier and extracting its command payload | CanonicalCmdCarrier | MxcConfigBuilder keeps cmd command-mode switch detection and command-line construction | the approvals binder and the MXC command-line builder agree on which argv shapes are the canonical cmd carrier and what payload they carry | CanonicalCmdCarrierTests.BinderAndMxcBuilder_AgreeOnCarrierRecognition | behavioral | - | diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index e2f8f0219..e941ba8a1 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -32,6 +32,18 @@ Short version: run required tests, collect a closeout proof pass with `.\run-app Every new Windows node call must be exposed through local MCP and `winnode`: register the capability, update `McpToolBridge.CommandDescriptions`, update `src/OpenClaw.WinNode.Cli/skill.md`, add focused tests, and prove discovery/invocation with `winnode` or raw MCP JSON-RPC. +### Codex App Server catalog permission modes + +Codex session access is opt-in and may be changed only through the interactive tray Settings UI. Local MCP and gateway configuration commands cannot change it. + +| Setting | Advertised commands | Stage 0 behavior | +|---------|---------------------|------------------| +| Off | None | Codex catalog access is disabled. | +| Read only | `codex.appServer.threads.list.v1`, `codex.appServer.threads.history.list.v1`, `codex.appServer.thread.turns.list.v1` | Lists bounded non-archived interactive thread metadata, separately lists explicitly selected archived/history metadata, and reads bounded transcript pages after a fresh eligibility check. | +| Read and steer | The same three read commands | Owner control is unavailable in Stage 0. No resume, steer, interrupt, or other write command is advertised. | + +All three commands reject unknown fields and enforce bounded limits, cursors, text, pagination, and aggregate response bytes. `codex.appServer.threads.list.v1` always sends `archived:false`; `codex.appServer.threads.history.list.v1` requires an explicit `archived` boolean and returns projected metadata only. Transcript bodies are returned only as successful command payloads. Audit and error output uses stable command/outcome summaries and must not contain transcript bodies or private App Server failure details. + ### 1. Settings Toggle - Verify the toggle appears in Settings under "ADVANCED" - Verify it saves and persists across app restarts diff --git a/docs/superpowers/plans/2026-08-11-codex-catalog-permission-hardening.md b/docs/superpowers/plans/2026-08-11-codex-catalog-permission-hardening.md new file mode 100644 index 000000000..aba2fd308 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-codex-catalog-permission-hardening.md @@ -0,0 +1,48 @@ +# Codex Catalog Permission Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the three Task 8 Important Codex catalog permission findings without widening the catalog surface. + +**Architecture:** Registry-owned cancellation revokes in-flight capabilities; raw App Server surfaces are internal; Codex permission persistence is transactional from the ViewModel's perspective. + +**Tech Stack:** .NET 10, C#, xUnit, existing Node capability/MCP/Gateway transports. + +## Global Constraints + +- Keep exactly `codex.appServer.threads.list.v1` and `codex.appServer.thread.turns.list.v1`. +- Do not enable ReadAndSteer or modify `allowWriteControls`. +- Follow RED/GREEN for every production behavior change. + +### Task 1: Revoke active catalog executions + +**Files:** `NodeCapabilityRegistry.cs`, `NodeCapabilityRegistryTests.cs`. + +- [ ] Write a held-execution test that revokes ReadOnly and observes cancellation/no success delivery. +- [ ] Run the focused registry test and observe the missing generation cancellation RED. +- [ ] Add one registry-owned cancellation generation, cancel it before publishing a Codex-free/replacement snapshot, and link it in `DeferredCodexSessionCapability.ExecuteAsync`. +- [ ] Run the focused registry test GREEN. + +### Task 2: Close raw policy bypasses + +**Files:** `CodexAppServerClient.cs`, `CodexExecutableResolver.cs`, `CodexSessionCatalogService.cs`, assembly friendship configuration, focused source/API tests. + +- [ ] Write a source/API contract test requiring raw resolver, client connection, and raw list methods to be internal. +- [ ] Run it RED against the current public declarations. +- [ ] Internalize the raw surface, retaining only required Tray and test friend assemblies. +- [ ] Run Shared/Tray focused tests GREEN. + +### Task 3: Make access revocation persistence fail closed + +**Files:** `SettingsManager.cs`, `SettingsStore.cs`, `SettingsPageViewModel.cs`, settings tests. + +- [ ] Write a failing test that injects a save failure while changing ReadOnly to Off and asserts no runtime refresh or durable-mode mismatch. +- [ ] Run it RED. +- [ ] Add a narrow success result/rollback path for the Codex permission update; preserve safe two-way binding behavior. +- [ ] Run settings tests GREEN and add the local `app.settings.set` denial regression. + +### Task 4: Re-review and closeout + +- [ ] Run focused tests, the required build, and all three required suites. +- [ ] Dispatch scoped security and code-quality re-reviews, fix any Critical/Important issues with RED/GREEN loops. +- [ ] Update Task 8 reports and attempt interactive proof only if the security gates are clean. diff --git a/docs/superpowers/plans/2026-08-12-codex-interrupt-continuation.md b/docs/superpowers/plans/2026-08-12-codex-interrupt-continuation.md new file mode 100644 index 000000000..87bb66e14 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-codex-interrupt-continuation.md @@ -0,0 +1,319 @@ +# Codex Interrupt and Continuation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an authorized owner send a bounded text instruction to a linked Codex task, interrupting an exact OpenClaw-owned active turn before starting one replacement turn or durably queueing the instruction when another runtime owns the turn. + +**Architecture:** The native OpenClaw Codex plugin owns all writable App Server state and reuses its persistent binding, approval, and lifecycle machinery. Workboard stores a private ordered instruction queue and canonical Codex binding references through supported APIs. The Windows Companion remains a read-only catalog bridge and receives regression tests only. + +**Tech Stack:** TypeScript, OpenClaw Codex and Workboard extensions, App Server JSON-RPC, SQLite migrations through Workboard, Vitest, .NET/xUnit regression verification for the Companion. + +## Global Constraints + +- Do not implement `turn/steer`; the user operation is interrupt-then-new-turn or idle continuation. +- Preserve exactly the Companion's approved read-only catalog surface, except for the separately specified historical read command from the reconciliation plan. +- Never control a turn actively owned by Codex Desktop or another runner through a competing App Server process. +- Only the owner Telegram identity or an authenticated Control UI operator with `operator.admin` may mutate Codex execution. +- Every interrupt targets the exact captured thread ID and turn ID and revalidates binding and authorization generations. +- Never fall back from interrupt failure or ambiguous transport state to starting another turn. +- Accept bounded non-empty text only; no files, images, audio, skills, mentions, arbitrary metadata, caller-selected runtime configuration, or generic protocol passthrough. +- Instruction and transcript bodies never appear in logs, telemetry, audit summaries, Workboard events, or errors. +- A Control UI instruction creates no routine Telegram alert; a Telegram instruction replies only through its originating Telegram route. +- All persistent project artifacts remain on `E:` and Telegram Desktop remains untouched. + +--- + +### Task 1: Add private Codex binding and queued-instruction persistence to Workboard + +**Repository:** `E:\OpenClaw\worktrees\openclaw-codex-session-access` + +**Files:** +- Modify: `packages/workboard-contract/src/index.ts` +- Modify: `extensions/workboard/src/sqlite-store.ts` +- Modify: `extensions/workboard/src/store-inputs.ts` +- Create: `extensions/workboard/src/codex-instructions.ts` +- Create: `extensions/workboard/src/codex-instructions.test.ts` +- Modify: `extensions/workboard/src/gateway.ts` +- Modify: `extensions/workboard/src/gateway.test.ts` + +**Interfaces:** +- Adds private persistence records `WorkboardCodexBinding` and `WorkboardCodexInstruction` without projecting instruction text into ordinary card/event/list results. +- Produces ordered operations `enqueue`, `peek`, `claim`, `consume`, `releaseIndeterminate`, and `listBinding` with per-card/per-thread serialization. +- Each instruction contains UUID, card ID, thread UUID, private text payload, actor identity, origin route descriptor, timestamp, idempotency key, state, and consumption reference. At-rest protection follows the existing Gateway state-directory permission and backup policy; the body is never projected through ordinary Workboard APIs. + +- [ ] **Step 1: Write migration, ordering, redaction, and restart RED tests** + +Cover schema upgrade from the current version, duplicate idempotency, FIFO order, compare-and-set claim, crash/reopen persistence, body absence from card/events/log projections, and concurrent consumers. + +- [ ] **Step 2: Run RED** + +```powershell +cd E:\OpenClaw\worktrees\openclaw-codex-session-access +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/workboard/src/codex-instructions.test.ts extensions/workboard/src/gateway.test.ts extensions/workboard/src/sqlite-store-policy.test.ts +``` + +- [ ] **Step 3: Implement the private queue and scoped Gateway methods** + +Register read/write methods under `operator.admin`; public responses expose instruction ID/state/timestamps but never text. Bindings use canonical `codex://thread/` identity and do not repurpose `taskId`, `sessionKey`, or `runId`. + +- [ ] **Step 4: Run GREEN and type checks** + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/workboard/src/codex-instructions.test.ts extensions/workboard/src/gateway.test.ts extensions/workboard/src/sqlite-store-policy.test.ts +pnpm tsgo:extensions +``` + +- [ ] **Step 5: Commit** + +```powershell +git add packages/workboard-contract/src/index.ts extensions/workboard/src +git commit -m "feat(workboard): persist codex task instructions" +``` + +### Task 2: Build the native owned-turn controller + +**Repository:** `E:\OpenClaw\worktrees\openclaw-codex-session-access` + +**Files:** +- Create: `extensions/codex/src/task-control.ts` +- Create: `extensions/codex/src/task-control.test.ts` +- Modify: `extensions/codex/src/app-server/attempt-client-cleanup.ts` +- Modify: `extensions/codex/src/app-server/attempt-client-cleanup.test.ts` +- Modify: `extensions/codex/src/app-server/run-attempt-turn-request.ts` +- Modify: `extensions/codex/src/app-server/run-attempt.steering.test.ts` +- Modify: `extensions/codex/src/app-server/session-binding.ts` +- Modify: `extensions/codex/src/app-server/session-binding.test.ts` + +**Interfaces:** +- Produces `CodexTaskController.sendInstruction({ cardId, instructionId, actor, origin }, signal)`. +- Produces explicit outcomes `started`, `queued_external_owner`, `indeterminate_interrupt`, `authorization_revoked`, and `binding_changed`. +- Replaces public use of best-effort interruption with an authoritative exact-turn interrupt operation that distinguishes terminal interruption from timeout/indeterminate transport. + +- [ ] **Step 1: Write exact-order RED tests** + +Prove the controller holds `bindingStore.withLease`, captures thread/turn/binding/auth generations, sends one exact interrupt, waits for terminal `interrupted`, revalidates, then sends one `turn/start`. Prove changed IDs, revocation, timeout, disconnect, and ambiguous responses never start a turn. + +- [ ] **Step 2: Run RED** + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/task-control.test.ts extensions/codex/src/app-server/attempt-client-cleanup.test.ts extensions/codex/src/app-server/run-attempt.steering.test.ts extensions/codex/src/app-server/session-binding.test.ts +``` + +- [ ] **Step 3: Implement the minimum controller** + +Reuse the persistent App Server client, approval bridge, lifecycle controller, and turn-start path already owned by `run-attempt`. Do not launch a second App Server and do not call `codex.cli.session.resume` or `codex.terminal.resume.v1`. + +- [ ] **Step 4: Run GREEN** + +Run the Step 2 command. Expected: all selected tests pass. + +- [ ] **Step 5: Commit** + +```powershell +git add extensions/codex/src/task-control.ts extensions/codex/src/task-control.test.ts extensions/codex/src/app-server +git commit -m "feat(codex): interrupt owned turns before continuation" +``` + +### Task 3: Support idle adoption through the native App Server runtime + +**Repository:** `E:\OpenClaw\worktrees\openclaw-codex-session-access` + +**Files:** +- Create: `extensions/codex/src/task-adoption.ts` +- Create: `extensions/codex/src/task-adoption.test.ts` +- Modify: `extensions/codex/src/task-control.ts` +- Modify: `extensions/codex/src/task-control.test.ts` +- Modify: `extensions/codex/src/session-catalog-node-continue.ts` +- Modify: `extensions/codex/src/session-catalog.test.ts` + +**Interfaces:** +- `adoptIdleCodexTask` verifies current catalog status, external ownership, canonical project root, and admin authorization before establishing an OpenClaw-native persistent App Server binding. +- Node CLI resume and terminal resume remain excluded from bounded task control. + +- [ ] **Step 1: Write idle/external-owner RED tests** + +Cover idle adoption/start, stale/notLoaded revalidation, active external owner queue-only behavior, concurrent adoption deduplication, project-root mismatch, approval roundtrip, and restart-visible binding. + +- [ ] **Step 2: Run RED** + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/task-adoption.test.ts extensions/codex/src/task-control.test.ts extensions/codex/src/session-catalog.test.ts +``` + +- [ ] **Step 3: Implement native adoption** + +Create the binding through the same App Server runtime used by normal OpenClaw-native attempts. If the installed platform cannot establish a supported persistent binding for the source thread, return `queued_external_owner` and leave the instruction durable; never fall back to CLI resume. + +- [ ] **Step 4: Run GREEN** + +Run the Step 2 command. Expected: all selected tests pass. + +- [ ] **Step 5: Commit** + +```powershell +git add extensions/codex/src/task-adoption.ts extensions/codex/src/task-adoption.test.ts extensions/codex/src/task-control.ts extensions/codex/src/task-control.test.ts extensions/codex/src/session-catalog-node-continue.ts extensions/codex/src/session-catalog.test.ts +git commit -m "feat(codex): adopt idle linked tasks safely" +``` + +### Task 4: Expose one authorized task-level send operation + +**Repository:** `E:\OpenClaw\worktrees\openclaw-codex-session-access` + +**Files:** +- Create: `extensions/codex/src/task-control-gateway.ts` +- Create: `extensions/codex/src/task-control-gateway.test.ts` +- Modify: `extensions/codex/index.ts` +- Modify: `extensions/codex/src/command-authorization.ts` +- Modify: `extensions/codex/src/command-authorization.test.ts` + +**Interfaces:** +- Gateway method `codex.task.sendInstruction` consumes `{ cardId, text, idempotencyKey }` and server-derived actor/origin context. +- Text is normalized, non-empty, and bounded to an exact constant defined in `task-control-gateway.ts`. +- Authorization requires owner identity for Telegram or `operator.admin` for Control UI; route metadata comes from trusted Gateway context, never caller JSON. + +- [ ] **Step 1: Write authorization and origin RED tests** + +Cover owner Telegram, non-owner rejection, Control UI admin, device-only rejection, forged origin fields, unknown fields, whitespace-only text, oversize text, duplicate idempotency, and sanitized errors. + +- [ ] **Step 2: Run RED** + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/task-control-gateway.test.ts extensions/codex/src/command-authorization.test.ts +``` + +- [ ] **Step 3: Implement and register the operation** + +Enqueue first, then invoke the controller. The direct response reports only outcome, card ID, instruction ID, and safe next action. Do not register a Windows node command or a generic Codex method tool. + +- [ ] **Step 4: Run GREEN and type checks** + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/task-control-gateway.test.ts extensions/codex/src/command-authorization.test.ts +pnpm tsgo:extensions +``` + +- [ ] **Step 5: Commit** + +```powershell +git add extensions/codex/index.ts extensions/codex/src/task-control-gateway.ts extensions/codex/src/task-control-gateway.test.ts extensions/codex/src/command-authorization.ts extensions/codex/src/command-authorization.test.ts +git commit -m "feat(codex): add authorized task instruction operation" +``` + +### Task 5: Reconcile queued instructions after ownership changes + +**Repository:** `E:\OpenClaw\worktrees\openclaw-codex-session-access` + +**Files:** +- Create: `extensions/codex/src/task-instruction-reconciler.ts` +- Create: `extensions/codex/src/task-instruction-reconciler.test.ts` +- Modify: `extensions/codex/index.ts` +- Modify: `extensions/codex/src/task-control.ts` +- Modify: `extensions/codex/src/task-control.test.ts` + +**Interfaces:** +- `CodexTaskInstructionReconciler` coalesces wakeups, claims one FIFO instruction per thread, rechecks ownership, delegates to `CodexTaskController`, and consumes only after accepted `turn/start`. +- Indeterminate outcomes remain durable and require state readback before retry. + +- [ ] **Step 1: Write restart and race RED tests** + +Cover Gateway restart, external active-to-idle transition, duplicate wakeups, lease loss, instruction added during scan, indeterminate interrupt, consumption after accepted start, and authorization revoked while queued. + +- [ ] **Step 2: Run RED** + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/task-instruction-reconciler.test.ts extensions/codex/src/task-control.test.ts +``` + +- [ ] **Step 3: Implement coalesced reconciliation** + +Use bounded backoff and explicit state readback. Do not generate routine Telegram messages; any blocked-age event goes through existing notification policy rather than direct channel delivery. + +- [ ] **Step 4: Run GREEN** + +Run the Step 2 command. Expected: all selected tests pass. + +- [ ] **Step 5: Commit** + +```powershell +git add extensions/codex/index.ts extensions/codex/src/task-instruction-reconciler.ts extensions/codex/src/task-instruction-reconciler.test.ts extensions/codex/src/task-control.ts extensions/codex/src/task-control.test.ts +git commit -m "feat(codex): reconcile queued task instructions" +``` + +### Task 6: Lock the Windows Companion read-only boundary + +**Repository:** `E:\OpenClaw\worktrees\windows-codex-session-access` + +**Files:** +- Modify: `tests/OpenClaw.Shared.Tests/CodexCatalogPolicySurfaceTests.cs` +- Modify: `tests/OpenClaw.Tray.Tests/NodeCapabilityRegistryTests.cs` +- Modify: `docs/WINDOWS_NODE_TESTING.md` +- Modify: `docs/ARCHITECTURE.md` + +**Interfaces:** +- Produces regression proof that the Companion exposes only the approved catalog commands from the reconciliation plan and no resume/start/interrupt/steer/generic passthrough command. + +- [ ] **Step 1: Write/extend the failing source-policy assertion** + +Make the test enumerate the effective Codex command snapshot and explicitly reject strings matching `turn.start`, `turn.interrupt`, `turn.steer`, `thread.resume`, `codex.cli.session.resume`, and generic App Server invoke names. + +- [ ] **Step 2: Run the focused tests** + +```powershell +dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore --filter "FullyQualifiedName~CodexCatalogPolicySurfaceTests" +dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore --filter "FullyQualifiedName~NodeCapabilityRegistryTests" +``` + +Expected: the assertions pass without production write-command changes; if they fail, remove the unintended surface before continuing. + +- [ ] **Step 3: Update the architecture/testing documentation** + +Describe OpenClaw-owned write execution, external-active queueing, and the Companion prohibition. + +- [ ] **Step 4: Run `git diff --check` and commit** + +```powershell +git diff --check +git add tests/OpenClaw.Shared.Tests/CodexCatalogPolicySurfaceTests.cs tests/OpenClaw.Tray.Tests/NodeCapabilityRegistryTests.cs docs/WINDOWS_NODE_TESTING.md docs/ARCHITECTURE.md +git commit -m "test: lock codex task control out of companion" +``` + +### Task 7: Full verification, reviews, and disposable end-to-end proof + +**Repositories:** native OpenClaw and Windows Companion worktrees. + +- [ ] **Step 1: Run native focused and full verification** + +```powershell +cd E:\OpenClaw\worktrees\openclaw-codex-session-access +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/workboard/src/codex-instructions.test.ts extensions/codex/src/task-control.test.ts extensions/codex/src/task-adoption.test.ts extensions/codex/src/task-control-gateway.test.ts extensions/codex/src/task-instruction-reconciler.test.ts +pnpm format:check +pnpm lint +pnpm build +pnpm test:extensions +``` + +- [ ] **Step 2: Run Companion verification** + +```powershell +cd E:\OpenClaw\worktrees\windows-codex-session-access +.\build.ps1 +dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore +dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore +dotnet test .\tests\OpenClaw.WinNode.Cli.Tests\OpenClaw.WinNode.Cli.Tests.csproj --no-restore +``` + +- [ ] **Step 3: Dispatch security and quality reviews** + +Review exact-turn ownership, authorization generation, interrupt/start ordering, indeterminate outcomes, instruction confidentiality, queue durability, route-derived notification behavior, cross-plugin trust, and Companion command invariants. Fix every Critical or Important finding through focused RED/GREEN and scoped re-review. + +- [ ] **Step 4: Run disposable live proofs** + +Using only disposable Workboard cards and Codex threads: + +1. send from Control UI to an OpenClaw-owned active turn and prove exact interrupt then one new turn, with no Telegram delivery event; +2. send from Telegram to an idle linked task and prove one resumed turn and one Telegram response; +3. send to a Desktop-owned active task and prove durable queueing with zero Companion write invocations; +4. let the external task become idle and prove one queued instruction is consumed after safe adoption; +5. restart Gateway between enqueue and adoption and prove exactly-once behavior. + +Record only IDs, state transitions, counts, timestamps, and hashes under `E:\OpenClaw\personal-work-system\evidence`; never retain prompts or transcript bodies. diff --git a/docs/superpowers/plans/2026-08-12-codex-workboard-deep-reconciliation.md b/docs/superpowers/plans/2026-08-12-codex-workboard-deep-reconciliation.md new file mode 100644 index 000000000..b2fcbe6ec --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-codex-workboard-deep-reconciliation.md @@ -0,0 +1,440 @@ +# Codex Workboard Deep Reconciliation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a resumable, E-drive-hosted reconciliation system that scans historical and current Codex work plus project evidence, groups it into canonical Workboard objectives, and continuously projects safe lifecycle changes. + +**Architecture:** A personal `codex-workboard-reconciler` OpenClaw plugin owns discovery, classification, checkpoints, and orchestration. It uses a narrow supported Workboard reconciliation facade and bounded Codex catalog commands; it never imports Workboard internals or writes either SQLite store directly. Historical archived discovery is added as a separate Windows node command so the existing two read commands retain their exact semantics and security contract. + +**Tech Stack:** TypeScript, OpenClaw plugin SDK, Vitest, SQLite-backed plugin state through supported SDK APIs, .NET 10/C#/xUnit for the Windows node catalog extension, Git CLI read-only porcelain/log commands. + +## Global Constraints + +- Workboard remains the sole authoritative task ledger. +- All persistent implementation, state, reports, and evidence created by this project remain on `E:`. +- Never write Workboard SQLite or Codex private stores directly. +- Preserve the semantics of `codex.appServer.threads.list.v1` and `codex.appServer.thread.turns.list.v1`. +- Historical discovery must be bounded, paginated, resumable, and separately authorized. +- High-confidence matches may link automatically; ambiguous matches go to `triage` and are never silently merged. +- Idle, `notLoaded`, process exit, or missing sessions never imply `review` or `done`. +- Reconciliation never overrides manual `blocked`, `review`, or `done` state. +- Routine scanning and reconciliation send no Telegram notification. +- Do not traverse `C:`, secrets, build caches, dependency vendors, binary assets, `.git` object storage, or private application databases. +- Do not log transcript bodies, file contents, secrets, or command arguments. +- Telegram Desktop remains outside the system boundary. + +--- + +### Task 1: Add the Workboard reconciliation contract and paginated facade + +**Repository:** `E:\OpenClaw\worktrees\openclaw-codex-session-access` + +**Files:** +- Modify: `packages/workboard-contract/src/index.ts` +- Create: `extensions/workboard/src/reconciliation.ts` +- Create: `extensions/workboard/src/reconciliation.test.ts` +- Modify: `extensions/workboard/src/gateway.ts` +- Modify: `extensions/workboard/src/gateway.test.ts` +- Modify: `extensions/workboard/runtime-api.ts` + +**Interfaces:** +- Produces `WorkboardExternalExecutionLink`, `WorkboardReconciliationObservation`, `WorkboardReconciliationPage`, and `WorkboardReconciliationApplyResult` contract types. +- Produces Gateway RPC methods `workboard.reconciliation.list` under `operator.read` and `workboard.reconciliation.apply` under `operator.write`. +- `list` consumes `{ cursor?: string; limit?: number; tenant?: string; boardId?: string; terminal?: boolean }` with `limit` in `1..100` and returns stable ID-ordered pagination. +- `apply` consumes one idempotent external observation with `sourceUrl`, `tenant`, `idempotencyKey`, `sourceUpdatedAt`, proposed card/link fields, and expected Workboard revision. + +- [ ] **Step 1: Write failing contract and Gateway tests** + +Add tests proving pagination is stable, limits reject `0` and `101`, duplicate idempotency keys return the existing result, stale `sourceUpdatedAt` is a no-op, and `blocked`/`review`/`done` cannot be changed by reconciliation. + +- [ ] **Step 2: Run the focused tests and record RED** + +Run: + +```powershell +cd E:\OpenClaw\worktrees\openclaw-codex-session-access +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/workboard/src/reconciliation.test.ts extensions/workboard/src/gateway.test.ts +``` + +Expected: failure because the reconciliation contracts and RPC methods do not exist. + +- [ ] **Step 3: Implement the minimum facade** + +Implement stable opaque cursors, bounds, compare-and-set behavior, idempotent create/link/update, and status policy in `reconciliation.ts`. Route all mutations through `WorkboardStore`; do not expose `WorkboardStore` to other plugins and do not open SQLite from the reconciler. + +- [ ] **Step 4: Run focused tests and type checks GREEN** + +Run: + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/workboard/src/reconciliation.test.ts extensions/workboard/src/gateway.test.ts +pnpm tsgo:extensions +``` + +Expected: all selected tests and extension type checks pass. + +- [ ] **Step 5: Commit** + +```powershell +git add packages/workboard-contract/src/index.ts extensions/workboard/runtime-api.ts extensions/workboard/src/reconciliation.ts extensions/workboard/src/reconciliation.test.ts extensions/workboard/src/gateway.ts extensions/workboard/src/gateway.test.ts +git commit -m "feat(workboard): add reconciliation facade" +``` + +### Task 2: Add a separately authorized archived Codex catalog command + +**Repository:** `E:\OpenClaw\worktrees\windows-codex-session-access` + +**Files:** +- Modify: `src/OpenClaw.Shared/Capabilities/CodexSessionCapability.cs` +- Modify: `src/OpenClaw.Shared/Codex/CodexSessionCatalogService.cs` +- Modify: `src/OpenClaw.Shared/Codex/CodexAppServerProtocol.cs` +- Modify: `src/OpenClaw.Shared/Mcp/McpToolBridge.cs` +- Modify: `src/OpenClaw.WinNode.Cli/skill.md` +- Modify: `src/OpenClaw.Tray.WinUI/Services/NodeCapabilityRegistry.cs` +- Modify: `tests/OpenClaw.Shared.Tests/CodexSessionCapabilityTests.cs` +- Modify: `tests/OpenClaw.Shared.Tests/CodexCatalogPolicySurfaceTests.cs` +- Modify: `tests/OpenClaw.Tray.Tests/NodeCapabilityRegistryTests.cs` +- Modify: `tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs` +- Modify: `tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs` +- Modify: `docs/WINDOWS_NODE_TESTING.md` + +**Interfaces:** +- Produces `codex.appServer.threads.history.list.v1` with `{ cursor?, limit?, searchTerm?, archived }`, where `archived` is required and `limit` is `1..100`. +- The command returns the same bounded projected metadata envelope as the existing list command. +- It never returns transcript bodies and does not alter existing v1 list eligibility or defaults. +- Advertisement requires `ReadOnly` or `ReadAndSteer`, Codex executable availability, Gateway allowlisting, and node command-surface reapproval. + +- [ ] **Step 1: Write failing policy and behavior tests** + +Add tests proving the original commands still force `archived:false`, the new command requires an explicit boolean, rejects unknown fields, returns only projected metadata, respects page/byte budgets, and is canceled/revoked by the registry generation. + +- [ ] **Step 2: Run focused tests and record RED** + +Run: + +```powershell +dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore --filter "FullyQualifiedName~CodexSessionCapabilityTests|FullyQualifiedName~CodexCatalogPolicySurfaceTests" +dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore --filter "FullyQualifiedName~NodeCapabilityRegistryTests" +``` + +Expected: failure because the history command is absent. + +- [ ] **Step 3: Implement the bounded command without weakening v1** + +Add a distinct command handler and protocol parameter builder. Preserve field projection, launch-time trust validation, response budgets, cancellation, final delivery authorization, and sanitized errors. Add the canonical MCP description and update the CLI skill so drift tests cover the command. + +- [ ] **Step 4: Run focused tests GREEN** + +Run the two commands from Step 2. Expected: all selected tests pass. + +- [ ] **Step 5: Commit** + +```powershell +git add src/OpenClaw.Shared src/OpenClaw.Tray.WinUI/Services/NodeCapabilityRegistry.cs tests/OpenClaw.Shared.Tests tests/OpenClaw.Tray.Tests docs/WINDOWS_NODE_TESTING.md +git commit -m "feat: add bounded codex history catalog" +``` + +### Task 3: Teach the native Codex catalog policy about the history command + +**Repository:** `E:\OpenClaw\worktrees\openclaw-codex-session-access` + +**Files:** +- Modify: `extensions/codex/src/session-catalog-parsing.ts` +- Modify: `extensions/codex/src/session-catalog-types.ts` +- Modify: `extensions/codex/src/session-catalog.ts` +- Modify: `extensions/codex/src/session-catalog.test.ts` + +**Interfaces:** +- Produces `CODEX_APP_SERVER_THREADS_HISTORY_LIST_COMMAND`. +- Produces an internal reconciler-only history enumeration function that invokes the history command only when the caller holds `operator.admin` and supplies an explicit `archived` partition. +- Does not add archived sessions to the ordinary Control UI session catalog. + +- [ ] **Step 1: Write failing policy tests** + +Prove ordinary catalog listing never invokes the history command; the internal history path requires `operator.admin`, paginates both archived partitions explicitly, rejects cursor loops, and fails closed if the node does not advertise the exact command. + +- [ ] **Step 2: Run RED** + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/session-catalog.test.ts +``` + +- [ ] **Step 3: Implement the internal history path** + +Keep it out of `SessionCatalogProvider.list`; expose it only through the plugin-private reconciliation registration used in Task 8. + +- [ ] **Step 4: Run GREEN and type checks** + +```powershell +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/session-catalog.test.ts +pnpm tsgo:extensions +``` + +- [ ] **Step 5: Commit** + +```powershell +git add extensions/codex/src/session-catalog-parsing.ts extensions/codex/src/session-catalog-types.ts extensions/codex/src/session-catalog.ts extensions/codex/src/session-catalog.test.ts +git commit -m "feat(codex): add guarded history catalog path" +``` + +### Task 4: Scaffold the E-drive personal reconciliation plugin + +**Repository:** `E:\OpenClaw\personal-work-system` + +**Files:** +- Create: `plugins/codex-workboard-reconciler/openclaw.plugin.json` +- Create: `plugins/codex-workboard-reconciler/package.json` +- Create: `plugins/codex-workboard-reconciler/tsconfig.json` +- Create: `plugins/codex-workboard-reconciler/index.ts` +- Create: `plugins/codex-workboard-reconciler/src/config.ts` +- Create: `plugins/codex-workboard-reconciler/src/config.test.ts` +- Modify: `README.md` + +**Interfaces:** +- Plugin ID: `codex-workboard-reconciler`. +- Config includes `projectRoots`, `excludedDirectoryNames`, `maxFileBytes`, `historyPageSize`, `batchSize`, `minimumAutoLinkConfidence`, `activeCadenceSeconds`, and `staleAfterSuccessfulScans`. +- Defaults include only explicitly approved `E:` roots and reject any resolved path outside them. + +- [ ] **Step 1: Scaffold the package and write failing manifest/config tests** + +Create the manifest, package metadata, TypeScript config, and test first. Test canonical path resolution, rejection of `C:`, traversal, reparse escapes, invalid bounds, and unknown config keys. Point development dependencies at the exact native OpenClaw worktree version; do not fetch a floating OpenClaw release. + +- [ ] **Step 2: Run RED** + +```powershell +cd E:\OpenClaw\personal-work-system +pnpm --dir plugins/codex-workboard-reconciler exec vitest run src/config.test.ts +``` + +- [ ] **Step 3: Scaffold and implement config parsing** + +Follow the OpenClaw plugin manifest convention, not the Codex `.codex-plugin` marketplace format. Register no scanner service until configuration validates. + +- [ ] **Step 4: Run GREEN and validate plugin loading** + +```powershell +pnpm --dir plugins/codex-workboard-reconciler exec vitest run src/config.test.ts +wsl.exe -d OpenClawGateway -- openclaw plugins inspect codex-workboard-reconciler --runtime --json +``` + +Expected: tests pass and runtime inspection reports the plugin loaded without secrets. + +- [ ] **Step 5: Commit** + +```powershell +git add plugins/codex-workboard-reconciler README.md +git commit -m "feat: scaffold codex workboard reconciler" +``` + +### Task 5: Implement checkpointed source discovery + +**Repository:** `E:\OpenClaw\personal-work-system` + +**Files:** +- Create: `plugins/codex-workboard-reconciler/src/state.ts` +- Create: `plugins/codex-workboard-reconciler/src/state.test.ts` +- Create: `plugins/codex-workboard-reconciler/src/codex-source.ts` +- Create: `plugins/codex-workboard-reconciler/src/codex-source.test.ts` +- Create: `plugins/codex-workboard-reconciler/src/project-source.ts` +- Create: `plugins/codex-workboard-reconciler/src/project-source.test.ts` + +**Interfaces:** +- `ReconciliationStateStore.load/saveCheckpoint/withLease` persists versioned cursor, source hash, route, scan generation, timestamps, and failure counts only. +- `CodexSource.scanBatch(checkpoint, signal)` returns bounded thread metadata and lazily fetches transcript evidence only on classifier request. +- `ProjectSource.scanBatch(checkpoint, signal)` returns allowlisted text metadata, repository identity, porcelain status, current branch, and bounded recent commit summaries. + +- [ ] **Step 1: Write failing source and restart tests** + +Cover lease exclusion, atomic checkpoint commit, crash/resume, cursor-loop rejection, source-hash no-op, excluded paths, symlink/reparse escape, file-size limits, and Git command argv allowlisting. + +- [ ] **Step 2: Run RED** + +```powershell +pnpm --dir plugins/codex-workboard-reconciler exec vitest run src/state.test.ts src/codex-source.test.ts src/project-source.test.ts +``` + +- [ ] **Step 3: Implement minimal bounded readers and state** + +Store state below the plugin's E-backed OpenClaw state root. Do not store transcript or file bodies after classification; persist hashes and references only. + +- [ ] **Step 4: Run GREEN** + +Run the Step 2 command. Expected: all selected tests pass. + +- [ ] **Step 5: Commit** + +```powershell +git add plugins/codex-workboard-reconciler/src +git commit -m "feat: add resumable reconciliation sources" +``` + +### Task 6: Implement objective classification and conservative deduplication + +**Repository:** `E:\OpenClaw\personal-work-system` + +**Files:** +- Create: `plugins/codex-workboard-reconciler/src/classifier.ts` +- Create: `plugins/codex-workboard-reconciler/src/classifier.test.ts` +- Create: `plugins/codex-workboard-reconciler/src/evidence.ts` +- Create: `plugins/codex-workboard-reconciler/src/evidence.test.ts` + +**Interfaces:** +- `classifyObjective(input): ObjectiveDecision` returns `create`, `link`, `triage`, or `ignore` with a bounded rationale code and confidence in `0..1`. +- Automatic linking requires confidence at or above configured threshold plus compatible canonical project identity. +- Triage preserves candidate card IDs and evidence references without merging. + +- [ ] **Step 1: Write table-driven RED tests** + +Include related chats on one branch, distinct objectives in one project, title-only ambiguity, speculative ideas, duplicate status chats, renamed repository continuity, and conflicting project roots. + +- [ ] **Step 2: Run RED** + +```powershell +pnpm --dir plugins/codex-workboard-reconciler exec vitest run src/classifier.test.ts src/evidence.test.ts +``` + +- [ ] **Step 3: Implement deterministic feature extraction and decision policy** + +Keep model-assisted summarization behind an injected interface; deterministic safety gates decide whether automatic mutation is allowed. + +- [ ] **Step 4: Run GREEN** + +Run the Step 2 command. Expected: all selected tests pass. + +- [ ] **Step 5: Commit** + +```powershell +git add plugins/codex-workboard-reconciler/src/classifier.ts plugins/codex-workboard-reconciler/src/classifier.test.ts plugins/codex-workboard-reconciler/src/evidence.ts plugins/codex-workboard-reconciler/src/evidence.test.ts +git commit -m "feat: classify canonical workboard objectives" +``` + +### Task 7: Apply reconciliation safely to Workboard + +**Repository:** `E:\OpenClaw\personal-work-system` + +**Files:** +- Create: `plugins/codex-workboard-reconciler/src/workboard-client.ts` +- Create: `plugins/codex-workboard-reconciler/src/workboard-client.test.ts` +- Create: `plugins/codex-workboard-reconciler/src/reconciler.ts` +- Create: `plugins/codex-workboard-reconciler/src/reconciler.test.ts` + +**Interfaces:** +- `WorkboardReconciliationClient.list/apply` uses only `api.runtime.gateway.request(...)` against the Task 1 RPC facade; the Gateway supplies the plugin runtime's authenticated context and the plugin never reads or stores an operator token. +- `Reconciler.runBatch(mode, signal)` supports `onboarding` and `continuous`, applies one lease, and commits a checkpoint only after Workboard acknowledges idempotent mutations. + +- [ ] **Step 1: Write failing orchestration tests** + +Prove one objective creates one card with multiple `codex://thread/` links; ambiguous work becomes triage; active may move eligible status to `running`; idle never completes; stale manual state wins; missing nodes preserve card state; repeated batches are no-ops; and no notification API is called. + +- [ ] **Step 2: Run RED** + +```powershell +pnpm --dir plugins/codex-workboard-reconciler exec vitest run src/workboard-client.test.ts src/reconciler.test.ts +``` + +- [ ] **Step 3: Implement the client and reconciler** + +Use compare-and-set revisions and `sourceUpdatedAt`. Mark a link stale only after the configured count of successful full scans; dependency failure does not increment missing-source evidence. + +- [ ] **Step 4: Run GREEN** + +Run the Step 2 command. Expected: all selected tests pass. + +- [ ] **Step 5: Commit** + +```powershell +git add plugins/codex-workboard-reconciler/src/workboard-client.ts plugins/codex-workboard-reconciler/src/workboard-client.test.ts plugins/codex-workboard-reconciler/src/reconciler.ts plugins/codex-workboard-reconciler/src/reconciler.test.ts +git commit -m "feat: reconcile codex evidence into workboard" +``` + +### Task 8: Register manual onboarding and continuous service controls + +**Repositories:** `E:\OpenClaw\worktrees\openclaw-codex-session-access`, then `E:\OpenClaw\personal-work-system` + +**Files:** +- Modify: `extensions/codex/index.ts` +- Modify: `extensions/codex/src/session-catalog.ts` +- Modify: `extensions/codex/src/session-catalog.test.ts` +- Modify: `plugins/codex-workboard-reconciler/index.ts` +- Create: `plugins/codex-workboard-reconciler/src/service.ts` +- Create: `plugins/codex-workboard-reconciler/src/service.test.ts` +- Create: `runbooks/codex-workboard-reconciliation.md` +- Modify: `README.md` + +**Interfaces:** +- The Codex plugin registers a private reconciliation provider for bounded history enumeration and transcript-on-demand; it is not a public node command passthrough. +- The personal plugin registers `Sync now`, `Pause`, `Resume`, and status operations, plus a coalesced service cadence no faster than 60 seconds while linked work is active. +- Status returns counts, phase, checkpoint, and triage totals only. + +- [ ] **Step 1: Write failing registration/service tests** + +Cover admin scope, single-flight coalescing, pause persistence, restart resume, exponential backoff with jitter bounds, no routine notification, and transcript reads only after explicit classifier demand. + +- [ ] **Step 2: Run RED in each repository** + +```powershell +cd E:\OpenClaw\worktrees\openclaw-codex-session-access +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/session-catalog.test.ts +cd E:\OpenClaw\personal-work-system +pnpm --dir plugins/codex-workboard-reconciler exec vitest run src/service.test.ts +``` + +- [ ] **Step 3: Implement registration, controls, and runbook** + +Document start, pause, status, recovery, E-drive paths, excluded-data policy, and the fact that Telegram Desktop is unused. + +- [ ] **Step 4: Run GREEN and commit each repository** + +```powershell +cd E:\OpenClaw\worktrees\openclaw-codex-session-access +node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts extensions/codex/src/session-catalog.test.ts +git add extensions/codex +git commit -m "feat(codex): register reconciliation source" + +cd E:\OpenClaw\personal-work-system +pnpm --dir plugins/codex-workboard-reconciler exec vitest run src/service.test.ts +git add plugins/codex-workboard-reconciler runbooks/codex-workboard-reconciliation.md README.md +git commit -m "feat: operate codex workboard reconciliation" +``` + +### Task 9: Full verification, security review, and disposable live proof + +**Repositories:** all three repositories above. + +- [ ] **Step 1: Run full Windows Companion verification** + +```powershell +cd E:\OpenClaw\worktrees\windows-codex-session-access +.\build.ps1 +dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore +dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore +dotnet test .\tests\OpenClaw.WinNode.Cli.Tests\OpenClaw.WinNode.Cli.Tests.csproj --no-restore +git diff --check HEAD~1 HEAD +``` + +- [ ] **Step 2: Run native OpenClaw verification** + +```powershell +cd E:\OpenClaw\worktrees\openclaw-codex-session-access +pnpm format:check +pnpm lint +pnpm build +pnpm test:extensions +``` + +- [ ] **Step 3: Run personal plugin verification** + +```powershell +cd E:\OpenClaw\personal-work-system +pnpm --dir plugins/codex-workboard-reconciler exec vitest run +wsl.exe -d OpenClawGateway -- openclaw config validate +wsl.exe -d OpenClawGateway -- openclaw plugins inspect codex-workboard-reconciler --runtime --json +``` + +- [ ] **Step 4: Dispatch final security and quality reviews** + +Review the three-repository change set for authorization, stale-write races, path escapes, content leakage, direct-store access, cursor exhaustion, duplicate creation, status authority, notification silence, and rollback behavior. Fix every Critical or Important finding with a focused RED/GREEN loop and scoped re-review. + +- [ ] **Step 5: Run a disposable live proof** + +Use one disposable test repository on `E:` and two disposable Codex chats describing one objective. Interrupt and resume the onboarding scanner, then prove one canonical Workboard card contains two source links, Control UI refreshes, repeated sync is a no-op, no Telegram notification is emitted, and no source transcript/file body appears in logs. Remove only disposable proof data after recording redacted counts and hashes under `E:\OpenClaw\personal-work-system\evidence`. diff --git a/docs/superpowers/specs/2026-08-11-codex-catalog-permission-hardening-design.md b/docs/superpowers/specs/2026-08-11-codex-catalog-permission-hardening-design.md new file mode 100644 index 000000000..6dda3a890 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-codex-catalog-permission-hardening-design.md @@ -0,0 +1,27 @@ +# Codex Catalog Permission Hardening Design + +## Goal + +Make Codex catalog revocation fail closed at dispatch time and persistence time, while preventing production callers from bypassing the interactive permission boundary. + +## Design + +`NodeCapabilityRegistry` owns a revocable access generation for every advertised Codex capability. A generated Codex capability links each execution to that generation's cancellation token. Rebuilding or refreshing to a mode that removes/replaces Codex first cancels the prior generation, then atomically publishes the replacement snapshot. Transport code already propagates cancellation and will not deliver a normal result after cancellation. + +Raw App Server construction and read methods become internal to `OpenClaw.Shared`. The Tray assembly and test assemblies retain explicit friend access, while production access remains through `NodeCapabilityRegistry` and `CodexSessionCapability` only. + +`SettingsManager.UpdateAndSave` reports persistence success. `SettingsStore` and `SettingsPageViewModel` preserve the UI-safe no-throw setter behavior but do not refresh the runtime catalog or claim a save when the Codex setting change was not durably persisted. The in-memory setting is restored to its previous value on a failed Codex access save. + +## Tests + +- Hold a deferred Codex operation, revoke access, and prove cancellation prevents completion/delivery. +- Compile/source-contract tests prove raw resolver/client/list APIs are internal and only the Tray/test assemblies have friendship. +- Force a Codex access persistence failure and prove the stored and runtime values remain ReadOnly, no refresh occurs, and the setter remains safe for two-way binding. +- Add a behavioral local settings API denial assertion for `CodexSessionAccess`. + +## Constraints + +- Preserve exactly the two bounded read commands. +- Never enable ReadAndSteer or change Gateway `allowWriteControls`. +- No caller-selected executable, environment, or Codex home override. +- Keep existing non-security settings save behavior unchanged unless the tests require a narrow Codex-access change. diff --git a/docs/superpowers/specs/2026-08-12-codex-interrupt-continuation-design.md b/docs/superpowers/specs/2026-08-12-codex-interrupt-continuation-design.md new file mode 100644 index 000000000..1e02b6667 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-codex-interrupt-continuation-design.md @@ -0,0 +1,119 @@ +# Codex Interrupt and Continuation Design + +**Date:** 2026-08-12 + +**Status:** Approved design baseline + +**Scope:** Owner-authorized interruption and continuation of Codex work through OpenClaw + +## 1. Purpose + +OpenClaw must let the owner send a message to a linked Codex task from either Control UI or Telegram. The same user action behaves according to the task's actual execution state: + +- if an OpenClaw-owned Codex turn is running, interrupt that exact turn, wait for the terminal interrupted state, and submit the message as a new turn; +- if the linked Codex thread is idle or stale, resume it and submit the message directly; +- if Codex Desktop or another runner owns an active turn, record the message as a queued Workboard instruction and apply it only after the thread becomes safely adoptable. + +This phase does not provide in-place `turn/steer` behavior. + +## 2. Architectural Boundary + +OpenClaw's native Codex runtime owns all writable execution. The Windows Companion continues to expose only these read commands: + +- `codex.appServer.threads.list.v1` +- `codex.appServer.thread.turns.list.v1` + +The Companion must not advertise `turn/start`, `turn/steer`, `turn/interrupt`, `thread/resume`, or a generic App Server passthrough. Its catalog client is short-lived and does not own the approval and event stream required for safe writable turns. + +New work should be launched through an OpenClaw-native Codex binding. Historical idle threads may be adopted by that runtime through supported Codex interfaces. An active thread owned by Codex Desktop or another runner is never controlled through a competing App Server process. + +## 3. Identity and Ownership + +Each controllable execution record contains: + +- immutable Workboard card ID; +- immutable Codex thread UUID; +- OpenClaw session or ACP binding ID; +- current runtime owner identity; +- active turn ID when known; +- last observed lifecycle state and source timestamp; +- queued instruction ID, actor, source interface, timestamp, and idempotency key when applicable. + +Codex thread IDs are external execution identifiers. They do not replace Workboard card IDs, Gateway session keys, or Workboard run IDs. + +Only the owner Telegram identity or an authenticated Control UI operator with the required administrative scope may request interruption or continuation. Device possession alone is insufficient. + +## 4. State-Aware Message Operation + +The public operation is conceptually `send instruction to linked task`, not a raw Codex protocol call. + +### 4.1 OpenClaw-owned active turn + +1. Resolve the Workboard link and active native Codex binding. +2. Capture the exact thread ID, turn ID, binding generation, and authorization generation. +3. Send `turn/interrupt` for that exact turn. +4. Wait for the authoritative terminal event showing that turn is interrupted. +5. Revalidate binding, owner authorization, queued-instruction state, and thread ownership. +6. Start one new turn containing the bounded text instruction. +7. Mark the instruction consumed only after the new turn is accepted. + +The operation must never convert an interrupt failure into a speculative new turn. + +### 4.2 Idle or stale linked thread + +1. Resolve the canonical Codex thread UUID and verify no active owner conflict. +2. Resume or adopt the thread through the native Codex runtime. +3. Establish a persistent binding capable of processing approvals and lifecycle events. +4. Start one new turn with the instruction. +5. Link the new execution run to the existing Workboard card. + +### 4.3 Externally owned active turn + +The instruction is durably queued on the Workboard execution record. The user receives a concise status that the instruction is waiting for the current external turn to finish. Reconciliation applies it once, in order, after ownership is safe. It does not interrupt the external process, start a competing turn, or repeatedly notify Telegram. + +## 5. Ordering, Idempotency, and Failure Handling + +- Each instruction has a caller-independent idempotency key and may be consumed once. +- Instructions for one Codex thread are serialized in creation order. +- Binding and authorization generations are revalidated immediately before interruption and immediately before starting the replacement turn. +- Revocation prevents dispatch and result delivery that have not crossed their final authorization boundary. +- An already accepted Codex interruption cannot be undone; the audit record reports that fact without including message content. +- Ambiguous transport outcomes are not retried automatically. Reconciliation first reads authoritative turn state. +- Process restart preserves queued instructions and consumption state. +- Failure messages contain stable outcome codes and resumable next actions, not prompts, transcript text, tokens, private App Server errors, or filesystem contents. + +## 6. Interface and Notification Behavior + +Control UI and Telegram invoke the same task-level operation. The originating interface receives the direct response. + +- A Control UI instruction does not generate a routine Telegram alert. +- A Telegram instruction replies in Telegram. +- Routine interrupt, resume, queue, and start transitions update Workboard silently. +- A notification is permitted only when user action is required, an instruction remains blocked beyond policy, or significant work reaches review/completion under the existing notification rules. + +## 7. Security Constraints + +- Preserve the Companion's two-command read-only catalog surface. +- Do not rely on `allowWriteControls` as the sole security boundary; enforce authorization behaviorally at dispatch. +- Never accept caller-selected executables, environments, Codex homes, models, providers, working directories, sandboxes, or approval policies through this operation. +- Accept bounded non-empty text only in the first release; no files, images, audio, skills, mentions, or arbitrary metadata. +- Do not log instruction or transcript bodies. +- Require explicit Gateway reapproval for any changed native command surface. +- Never use private Desktop IPC, UI automation, process-memory access, or direct mutation of Codex internal stores. + +## 8. Acceptance Tests + +1. A message to an OpenClaw-owned running task interrupts the exact active turn and starts exactly one replacement turn. +2. A changed turn ID or binding generation prevents interruption and prevents a new turn. +3. A message to an idle linked thread resumes it and starts exactly one turn. +4. A message to a Desktop-owned active thread queues without issuing any write request to the Companion or Codex Desktop. +5. A queued instruction is consumed once after the external thread becomes adoptable, including across Gateway restart. +6. An ambiguous interrupt response does not cause an automatic retry or replacement turn. +7. Authorization revocation before dispatch or delivery fails closed. +8. Control UI-originated work produces no routine Telegram notification; Telegram-originated work replies to Telegram. +9. Logs, audit records, and error payloads contain no instruction or transcript content. +10. The Companion still advertises exactly the two existing read commands. + +## 9. Delivery Boundary + +This specification enables interruption and continuation only through OpenClaw-owned native Codex bindings. Shared control of a turn actively owned by Codex Desktop remains blocked until Codex provides a supported same-owner multi-client transport with complete approval and event handling. diff --git a/docs/superpowers/specs/2026-08-12-codex-workboard-deep-reconciliation-design.md b/docs/superpowers/specs/2026-08-12-codex-workboard-deep-reconciliation-design.md new file mode 100644 index 000000000..e1aabd54c --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-codex-workboard-deep-reconciliation-design.md @@ -0,0 +1,159 @@ +# Codex Workboard Deep Reconciliation Design + +**Date:** 2026-08-12 + +**Status:** Approved design baseline + +**Scope:** Historical portfolio onboarding and continuous Codex-to-Workboard reconciliation + +## 1. Purpose + +OpenClaw must build and maintain a useful Workboard view from both current and historical Codex work and the actual project folders on `E:`. The result must scale beyond fifty projects without turning every chat into a separate task or making task state dependent on prompt memory. + +Workboard remains the sole authoritative task ledger. Codex chats and project repositories are evidence sources and execution links, not competing task databases. + +## 2. Two Operating Modes + +### 2.1 Portfolio onboarding scan + +A one-time, resumable deep scan processes: + +- all discoverable current and historical Codex thread metadata; +- bounded transcript pages needed to establish intent, decisions, blockers, and unfinished work; +- configured project roots on `E:`; +- project README files, plans, specifications, task documents, and other allowlisted text metadata; +- Git repository status, branches, recent bounded history, and tracked project structure. + +The scan runs in bounded batches with durable checkpoints. It can stop and resume without duplicating cards or repeating completed analysis. + +### 2.2 Continuous reconciliation + +After onboarding, a lightweight reconciler processes new and changed Codex threads and linked active projects. It uses metadata first and reads transcript or project evidence only when classification requires it. It coalesces runs, applies exponential backoff when dependencies are unavailable, and performs idempotent no-op updates when nothing changed. + +## 3. Canonical Task Model + +One Workboard card represents one distinct actionable objective. Related Codex chats become execution records linked to that card. + +The canonical link identity is: + +```text +codex://thread/ +``` + +The deterministic import key is: + +```text +codex-thread: +``` + +Codex thread IDs must not replace Workboard `taskId`, `sessionKey`, or `runId` fields. Mutable Windows node IDs and host routes belong in reconciler routing state, not canonical identity. + +Each generated or updated card records: + +- project and board assignment; +- concise objective and current summary; +- status and priority when evidence supports them; +- linked Codex thread IDs and execution runs; +- source paths and evidence references; +- confidence, classification rationale code, and last source timestamp; +- idempotency and reconciliation generation; +- blockers, completion criteria, and artifacts when supported by evidence. + +## 4. Classification and Deduplication + +The processing pass identifies overarching objectives using both conversation evidence and project evidence. It compares normalized intent, project root, referenced plans/issues, linked artifacts, Git branch, time proximity, and existing Workboard links. + +- High-confidence distinct objectives create cards automatically. +- High-confidence matches attach the Codex chat as another execution record to the existing card. +- Ambiguous matches create or update a `triage` candidate with proposed relationships; they are not silently merged. +- Speculative ideas, transient observations, duplicate status chats, and implementation details remain notes or evidence unless they form a distinct actionable objective. +- A merge operation preserves both source histories and is reversible through the Workboard audit trail. + +The system never claims semantic certainty solely from a chat title. + +## 5. Project Discovery and Scope + +Configured project roots, initially under `E:\Work` and other explicitly approved `E:` locations, are scanned through allowlisted readers. The scanner does not traverse `C:`, secrets, build caches, dependency vendors, binary assets, `.git` object storage, or private application databases. + +Each discovered repository receives a stable project identity derived from its approved canonical root and repository identity. Project renames and moves are reconciled without creating duplicate projects when repository evidence proves continuity. + +Inactive and completed projects remain searchable but are excluded from normal focus views. The active project set is configurable and not hard-coded to the current project count. + +## 6. Status Reconciliation + +- A verified active linked Codex execution may move an eligible `ready` or `scheduled` card to `running`. +- Codex `idle`, `notLoaded`, process exit, or conversation completion never implies `review` or `done`. +- Reconciliation never overrides manual `blocked`, `review`, or `done` state. +- Completion requires Workboard criteria plus recorded evidence. +- An older source observation cannot overwrite a newer Workboard transition. +- Missing or unreachable Codex sessions do not delete, archive, block, or complete cards. +- After repeated successful full scans or an elapsed stale threshold, a missing execution link may be marked stale while preserving card state. +- Manual Workboard changes remain authoritative unless the user explicitly requests reclassification. + +## 7. Reconciler Placement and Data Flow + +A personal OpenClaw plugin stored on `E:` owns scanning and reconciliation. It runs beside Workboard in the Gateway and uses supported interfaces only: + +- Gateway/node commands for Codex catalog and bounded transcript reads; +- Workboard RPC or agent tools for card reads and mutations; +- allowlisted filesystem and Git readers for project evidence. + +It never writes Workboard SQLite or Codex private stores directly. Its private state contains only checkpoints, source hashes, routing data, confidence decisions, timestamps, and failure counters. This state supports replay but is not a second task ledger. + +Workboard mutations emit the existing `plugin.workboard.changed` event, allowing Control UI to refresh through its native coalescing path. + +## 8. Performance and Scale + +- Historical discovery and analysis are paginated and checkpointed. +- Transcript bodies are fetched only when needed and are bounded by page, text, operation, and aggregate-byte limits. +- Project files are filtered by path, type, size, ignore rules, and source hash. +- Git inspection uses bounded history and porcelain/status interfaces; it does not scan object contents. +- Only active or recently changed links participate in frequent reconciliation. +- Background sync begins with a manual `Sync now` operation plus a conservative cadence no faster than once per minute while linked work is active. +- Node or Gateway failure triggers bounded exponential backoff with jitter. +- Before large historical import is exposed as a routine UI action, Workboard reads used by the scanner must be filtered or paginated rather than loading the entire archive repeatedly. + +## 9. Notifications and User Experience + +Routine discovery, card creation, linking, status projection, and successful reconciliation are silent. They update Workboard and Telegram's compact on-demand status without creating alerts. + +Notification events are reserved for prolonged scanner failure, a conflict requiring owner judgment, blocked work, review-ready work, important completion, or existing reminder policy. No acknowledgement, confirmation, dismissal, or snoozing is required for reminders. + +The onboarding UI reports batch progress, checkpoints, counts, and triage totals without exposing transcript bodies. The user can pause and resume the scan. Pausing does not discard completed work. + +## 10. Reliability and Security + +- Every card mutation has an idempotency key and records source, actor, prior state, new state, timestamp, and result. +- A single lease prevents overlapping onboarding or reconciliation writers. +- Crash recovery resumes from the last committed checkpoint. +- Source hashes and timestamps prevent stale overwrite. +- No secret values, transcript bodies, full file contents, or command arguments appear in logs or telemetry. +- Telegram Desktop remains outside the system boundary. +- Persistent implementation, state, reports, and evidence created by this project remain on `E:`; app-owned profile metadata is tolerated but not used as project storage. +- Direct SQLite writes, Codex-store mutation, private IPC, and unbounded filesystem traversal are prohibited. + +## 11. Acceptance Tests + +1. A bounded historical scan resumes after interruption without duplicate cards or links. +2. Current and historical chats representing one objective produce one canonical card with multiple execution records. +3. Distinct actionable objectives produce distinct cards. +4. Ambiguous grouping produces a triage item and does not merge cards. +5. Project README, plan, Git status/history, and Codex evidence contribute to classification without scanning excluded paths. +6. Repeated reconciliation is an idempotent no-op. +7. A verified active execution may move an eligible card to `running`; idle does not mark it complete. +8. Newer manual `blocked`, `review`, or `done` state wins over stale source observations. +9. Node disconnection preserves task state and later recovery repairs the projection. +10. Control UI refreshes through `plugin.workboard.changed`. +11. Routine scanning and synchronization send no Telegram notification. +12. Gateway restart preserves links, checkpoints, and queued work. +13. No direct Workboard or Codex SQLite mutation occurs. +14. All persistent scanner artifacts and evidence reside on `E:`. + +## 12. Delivery Order + +Implementation proceeds in two independently testable plans: + +1. build the scanner, canonical link model, classification pipeline, resumable onboarding, and continuous read-side reconciliation; +2. integrate the approved Codex interruption/continuation operation with canonical Workboard cards and execution records. + +The read-side reconciliation plan may ship while writable Codex control remains disabled. This prevents the portfolio onboarding work from weakening the execution security boundary. diff --git a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs index a38bd8e3c..e3d3a9f6e 100644 --- a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs +++ b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs @@ -1,3 +1,5 @@ +using OpenClaw.Shared.Codex; + namespace OpenClaw.Connection; /// @@ -22,4 +24,5 @@ public sealed record ConnectionSettingsSnapshot( bool NodeSttEnabled, bool NodeTtsEnabled, bool NodeSystemRunEnabled, + CodexSessionAccessMode CodexSessionAccess, string? FullSettingsJson); diff --git a/src/OpenClaw.Connection/SettingsChangeImpact.cs b/src/OpenClaw.Connection/SettingsChangeImpact.cs index 17f00c21c..804d8ba9a 100644 --- a/src/OpenClaw.Connection/SettingsChangeImpact.cs +++ b/src/OpenClaw.Connection/SettingsChangeImpact.cs @@ -62,7 +62,8 @@ public static SettingsChangeImpact Classify(ConnectionSettingsSnapshot? prev, Co prev.NodeBrowserProxyEnabled != next.NodeBrowserProxyEnabled || prev.NodeSttEnabled != next.NodeSttEnabled || prev.NodeTtsEnabled != next.NodeTtsEnabled || - prev.NodeSystemRunEnabled != next.NodeSystemRunEnabled) + prev.NodeSystemRunEnabled != next.NodeSystemRunEnabled || + prev.CodexSessionAccess != next.CodexSessionAccess) return SettingsChangeImpact.CapabilityReload; // Check if anything else changed (UI-only changes) diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs index 5faac7ccc..3095d019d 100644 --- a/src/OpenClaw.Shared/Capabilities/AppCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/AppCapability.cs @@ -148,6 +148,8 @@ private NodeInvokeResponse HandleSettingsSet(NodeInvokeRequest request) return Error("Missing required arg: name"); if (value == null) return Error("Missing required arg: value"); + if (string.Equals(name, "CodexSessionAccess", StringComparison.OrdinalIgnoreCase)) + return Error($"Setting '{name}' is not accessible"); if (SettingsSetHandler == null) return Error("Settings handler not registered"); diff --git a/src/OpenClaw.Shared/Capabilities/CodexSessionCapability.cs b/src/OpenClaw.Shared/Capabilities/CodexSessionCapability.cs new file mode 100644 index 000000000..49ea31c4e --- /dev/null +++ b/src/OpenClaw.Shared/Capabilities/CodexSessionCapability.cs @@ -0,0 +1,71 @@ +using OpenClaw.Shared.Codex; + +namespace OpenClaw.Shared.Capabilities; + +internal sealed class CodexSessionCapability : NodeCapabilityBase +{ + public const string ThreadsListCommand = "codex.appServer.threads.list.v1"; + public const string ThreadsHistoryListCommand = "codex.appServer.threads.history.list.v1"; + public const string ThreadTurnsListCommand = "codex.appServer.thread.turns.list.v1"; + + private static readonly string[] CommandNames = + [ + ThreadsListCommand, + ThreadsHistoryListCommand, + ThreadTurnsListCommand, + ]; + + private readonly CodexSessionCatalogService _catalog; + + internal CodexSessionCapability(IOpenClawLogger logger, CodexSessionCatalogService catalog) + : base(logger) + { + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + } + + public override string Category => "codex-app-server-threads"; + + public override IReadOnlyList Commands => CommandNames; + + public override Task ExecuteAsync(NodeInvokeRequest request) => + ExecuteAsync(request, CancellationToken.None); + + public override async Task ExecuteAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + try + { + var payload = request.Command switch + { + ThreadsListCommand => await _catalog.ListThreadsAsync( + request.Args, + cancellationToken).ConfigureAwait(false), + ThreadsHistoryListCommand => await _catalog.ListThreadHistoryAsync( + request.Args, + cancellationToken).ConfigureAwait(false), + ThreadTurnsListCommand => await _catalog.ListThreadTurnsAsync( + request.Args, + cancellationToken).ConfigureAwait(false), + _ => default, + }; + return payload.ValueKind == System.Text.Json.JsonValueKind.Undefined + ? Error($"Unknown command: {request.Command}") + : Success(payload); + } + catch (CodexSessionCatalogValidationException exception) + { + return Error(exception.Message); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return Error(request.Command == ThreadTurnsListCommand + ? "Codex app-server transcript is unavailable" + : "Codex app-server catalog is unavailable"); + } + } +} diff --git a/src/OpenClaw.Shared/Codex/CodexAppServerClient.cs b/src/OpenClaw.Shared/Codex/CodexAppServerClient.cs new file mode 100644 index 000000000..6ec72fd94 --- /dev/null +++ b/src/OpenClaw.Shared/Codex/CodexAppServerClient.cs @@ -0,0 +1,903 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.Shared.Codex; + +internal sealed class CodexAppServerClient : IAsyncDisposable +{ + private readonly CodexLaunchPlan _launchPlan; + private readonly ICodexAppServerProcessFactory _processFactory; + private readonly CodexAppServerLimits _limits; + private readonly BoundedByteRing _standardError; + private readonly SemaphoreSlim _restartGate = new(1, 1); + private readonly object _stateGate = new(); + private CodexAppServerSession? _session; + private Task? _disposeTask; + private long _nextRequestId; + private bool _disposeRequested; + private bool _disposed; + + private CodexAppServerClient( + CodexLaunchPlan launchPlan, + ICodexAppServerProcessFactory processFactory, + CodexAppServerLimits limits) + { + _launchPlan = launchPlan; + _processFactory = processFactory; + _limits = limits; + _standardError = new BoundedByteRing(limits.MaxStandardErrorBytes); + } + + internal string StandardErrorSnapshot => _standardError.GetUtf8Tail(); + + internal static Task ConnectAsync( + CodexLaunchPlan launchPlan, + CancellationToken cancellationToken = default) => + ConnectAsync( + launchPlan, + new CodexAppServerProcessFactory(), + cancellationToken); + + internal static Task ConnectAsync( + CodexLaunchPlan launchPlan, + ICodexAppServerProcessFactory processFactory, + CancellationToken cancellationToken) => + ConnectAsync( + launchPlan, + processFactory, + CodexAppServerLimits.Default, + cancellationToken); + + internal static Task ConnectCatalogAsync( + CodexLaunchPlan launchPlan, + CancellationToken cancellationToken = default) => + ConnectCatalogAsync( + launchPlan, + new CodexAppServerProcessFactory(), + cancellationToken); + + internal static Task ConnectCatalogAsync( + CodexLaunchPlan launchPlan, + ICodexAppServerProcessFactory processFactory, + CancellationToken cancellationToken) => + ConnectAsync( + launchPlan, + processFactory, + CodexAppServerLimits.Catalog, + cancellationToken); + + internal static async Task ConnectAsync( + CodexLaunchPlan launchPlan, + ICodexAppServerProcessFactory processFactory, + CodexAppServerLimits limits, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(launchPlan); + ArgumentNullException.ThrowIfNull(processFactory); + ArgumentNullException.ThrowIfNull(limits); + + var client = new CodexAppServerClient(launchPlan, processFactory, limits); + try + { + client._session = await client.StartInitializedSessionAsync(cancellationToken) + .ConfigureAwait(false); + return client; + } + catch + { + await client.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + internal Task ListThreadsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default) => + ExecuteReadAsync(CodexAppServerProtocol.ThreadListMethod, parameters, cancellationToken); + + internal Task ListThreadTurnsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default) => + ExecuteReadAsync(CodexAppServerProtocol.ThreadTurnsListMethod, parameters, cancellationToken); + + private async Task ExecuteReadAsync( + string method, + JsonElement parameters, + CancellationToken cancellationToken) + { + if (parameters.ValueKind != JsonValueKind.Object) + throw new ArgumentException("App Server read parameters must be a JSON object.", nameof(parameters)); + + var session = GetActiveSession(); + try + { + return await SendOnceAsync(session, method, parameters, cancellationToken) + .ConfigureAwait(false); + } + catch (CodexAppServerTransportException exception) when (!exception.ResponseBytesObserved) + { + session = await RestartAfterFailureAsync(session, cancellationToken).ConfigureAwait(false); + return await SendOnceAsync(session, method, parameters, cancellationToken) + .ConfigureAwait(false); + } + } + + private Task SendOnceAsync( + CodexAppServerSession session, + string method, + JsonElement parameters, + CancellationToken cancellationToken) + { + var id = Interlocked.Increment(ref _nextRequestId); + return session.SendRequestAsync( + id, + CodexAppServerProtocol.CreateRequest(id, method, parameters), + cancellationToken); + } + + private async Task RestartAfterFailureAsync( + CodexAppServerSession failedSession, + CancellationToken cancellationToken) + { + await _restartGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + lock (_stateGate) + { + if (_session is not null && !ReferenceEquals(_session, failedSession)) + return _session; + } + + await failedSession.DisposeAsync().ConfigureAwait(false); + lock (_stateGate) + { + if (ReferenceEquals(_session, failedSession)) + _session = null; + } + + var replacement = await StartInitializedSessionAsync(cancellationToken).ConfigureAwait(false); + var publish = false; + lock (_stateGate) + { + if (!_disposeRequested) + { + _session = replacement; + publish = true; + } + } + + if (!publish) + { + await replacement.DisposeAsync().ConfigureAwait(false); + throw new ObjectDisposedException(nameof(CodexAppServerClient)); + } + return replacement; + } + finally + { + _restartGate.Release(); + } + } + + private async Task StartInitializedSessionAsync( + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + var process = _processFactory.Start(_launchPlan); + var session = new CodexAppServerSession(process, _limits, _standardError); + session.StartDrains(); + try + { + var initializeId = Interlocked.Increment(ref _nextRequestId); + _ = await session.SendRequestAsync( + initializeId, + CodexAppServerProtocol.CreateInitializeRequest(initializeId), + cancellationToken) + .ConfigureAwait(false); + await session.SendNotificationAsync( + CodexAppServerProtocol.CreateInitializedNotification(), + cancellationToken) + .ConfigureAwait(false); + return session; + } + catch + { + await session.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private CodexAppServerSession GetActiveSession() + { + lock (_stateGate) + { + ThrowIfDisposed(); + return _session ?? throw new ObjectDisposedException(nameof(CodexAppServerClient)); + } + } + + private void ThrowIfDisposed() + { + if (_disposeRequested || _disposed) + throw new ObjectDisposedException(nameof(CodexAppServerClient)); + } + + public ValueTask DisposeAsync() + { + lock (_stateGate) + { + if (_disposeTask is not null) + return new ValueTask(_disposeTask); + _disposeRequested = true; + _disposeTask = DisposeCoreAsync(); + return new ValueTask(_disposeTask); + } + } + + private async Task DisposeCoreAsync() + { + await _restartGate.WaitAsync().ConfigureAwait(false); + try + { + CodexAppServerSession? session; + lock (_stateGate) + { + session = _session; + _session = null; + _disposed = true; + } + + if (session is not null) + await session.DisposeAsync().ConfigureAwait(false); + } + finally + { + _restartGate.Release(); + } + } +} + +internal interface ICodexAppServerProcessFactory +{ + ICodexAppServerProcess Start(CodexLaunchPlan launchPlan); +} + +internal sealed class CodexAppServerProcessFactory : ICodexAppServerProcessFactory +{ + public ICodexAppServerProcess Start(CodexLaunchPlan launchPlan) + { + if (!launchPlan.IsTrustedForLaunch()) + { + throw new CodexAppServerTransportException( + "Codex App Server executable is no longer trusted.", + responseBytesObserved: false); + } + var startInfo = launchPlan.CreateProcessStartInfo(); + startInfo.CreateNoWindow = true; + var process = Process.Start(startInfo) + ?? throw new CodexAppServerTransportException( + "Codex App Server process did not start.", + responseBytesObserved: false); + return new CodexAppServerProcess(process); + } +} + +internal interface ICodexAppServerProcess : IDisposable +{ + Stream StandardInput { get; } + + Stream StandardOutput { get; } + + Stream StandardError { get; } + + bool HasExited { get; } + + void CloseStandardInput(); + + void KillProcessTree(); + + Task WaitForExitAsync(CancellationToken cancellationToken); +} + +internal sealed class CodexAppServerProcess : ICodexAppServerProcess +{ + private readonly Process _process; + + public CodexAppServerProcess(Process process) + { + _process = process; + } + + public Stream StandardInput => _process.StandardInput.BaseStream; + + public Stream StandardOutput => _process.StandardOutput.BaseStream; + + public Stream StandardError => _process.StandardError.BaseStream; + + public bool HasExited => _process.HasExited; + + public void CloseStandardInput() => _process.StandardInput.Close(); + + public void KillProcessTree() => _process.Kill(entireProcessTree: true); + + public Task WaitForExitAsync(CancellationToken cancellationToken) => + _process.WaitForExitAsync(cancellationToken); + + public void Dispose() => _process.Dispose(); +} + +internal sealed class CodexAppServerSession : IAsyncDisposable +{ + private static readonly byte[] NewLine = [(byte)'\n']; + private readonly ICodexAppServerProcess _process; + private readonly CodexAppServerLimits _limits; + private readonly BoundedByteRing _standardError; + private readonly ConcurrentDictionary _pending = new(); + private readonly SemaphoreSlim _writeGate = new(1, 1); + private readonly CancellationTokenSource _lifetime = new(); + private readonly object _failureGate = new(); + private readonly object _disposeGate = new(); + private Task? _stdoutDrain; + private Task? _stderrDrain; + private Exception? _failure; + private CodexAppServerCleanupException? _killFailure; + private long _highestIssuedId; + private long _unmatchedOperationBytes; + private Task? _disposeTask; + + public CodexAppServerSession( + ICodexAppServerProcess process, + CodexAppServerLimits limits, + BoundedByteRing standardError) + { + _process = process; + _limits = limits; + _standardError = standardError; + } + + public void StartDrains() + { + _stdoutDrain = DrainStandardOutputAsync(); + _stderrDrain = DrainStandardErrorAsync(); + } + + public async Task SendRequestAsync( + long id, + byte[] request, + CancellationToken cancellationToken) + { + ThrowIfFailed(); + var pending = new PendingRequest(); + if (!_pending.TryAdd(id, pending)) + throw new CodexAppServerProtocolException($"Duplicate client request id {id}."); + InterlockedExtensions.Max(ref _highestIssuedId, id); + try + { + var writeAttempt = new WriteAttempt(); + using var deadline = new CancellationTokenSource(_limits.RequestTimeout); + using var operation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + deadline.Token, + _lifetime.Token); + try + { + await WriteLineAsync(request, writeAttempt, operation.Token).ConfigureAwait(false); + var idleTimeout = WaitForIdleTimeoutAsync(pending, operation.Token); + var completed = await Task.WhenAny(pending.Completion.Task, idleTimeout) + .ConfigureAwait(false); + + if (pending.Completion.Task.IsCompleted) + return await pending.Completion.Task.ConfigureAwait(false); + + await completed.ConfigureAwait(false); + FailForAbandonedRequest("Codex App Server request exceeded the idle timeout."); + throw new CodexAppServerTimeoutException(CodexAppServerTimeoutKind.Idle); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + FailForAbandonedRequest("Codex App Server request deadline expired."); + throw new CodexAppServerTimeoutException(CodexAppServerTimeoutKind.Request); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (writeAttempt.FrameStarted || pending.ResponseBytesObserved) + FailForAbandonedRequest("Codex App Server request was canceled after framing began."); + throw; + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + ThrowIfFailed(); + throw; + } + catch (Exception exception) when (exception is IOException or ObjectDisposedException) + { + var transport = new CodexAppServerTransportException( + "Failed to write to Codex App Server.", + responseBytesObserved: pending.ResponseBytesObserved, + exception); + Fail(transport); + throw transport; + } + } + finally + { + _pending.TryRemove(id, out _); + } + } + + public async Task SendNotificationAsync( + byte[] notification, + CancellationToken cancellationToken) + { + var writeAttempt = new WriteAttempt(); + using var deadline = new CancellationTokenSource(_limits.RequestTimeout); + using var operation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + deadline.Token, + _lifetime.Token); + try + { + await WriteLineAsync(notification, writeAttempt, operation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + FailForAbandonedRequest("Codex App Server notification deadline expired."); + throw new CodexAppServerTimeoutException(CodexAppServerTimeoutKind.Request); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (writeAttempt.FrameStarted) + FailForAbandonedRequest("Codex App Server notification was canceled after framing began."); + throw; + } + } + + private async Task WriteLineAsync( + byte[] message, + WriteAttempt attempt, + CancellationToken cancellationToken) + { + await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfFailed(); + attempt.FrameStarted = true; + await _process.StandardInput.WriteAsync(message, cancellationToken) + .ConfigureAwait(false); + await _process.StandardInput.WriteAsync(NewLine, cancellationToken) + .ConfigureAwait(false); + await _process.StandardInput.FlushAsync(cancellationToken) + .ConfigureAwait(false); + } + finally + { + _writeGate.Release(); + } + } + + private void FailForAbandonedRequest(string message) => + Fail(new CodexAppServerTransportException( + message, + responseBytesObserved: false)); + + private async Task WaitForIdleTimeoutAsync( + PendingRequest pending, + CancellationToken cancellationToken) + { + while (true) + { + var remaining = _limits.IdleTimeout - pending.ElapsedSinceActivity; + if (remaining <= TimeSpan.Zero) + return; + await Task.Delay(remaining, cancellationToken).ConfigureAwait(false); + } + } + + private async Task DrainStandardOutputAsync() + { + var readBuffer = new byte[4_096]; + var lineBuffer = new byte[_limits.MaxLineBytes + 1]; + var lineLength = 0; + try + { + while (true) + { + var read = await _process.StandardOutput + .ReadAsync(readBuffer, _lifetime.Token) + .ConfigureAwait(false); + if (read == 0) + { + Fail(new CodexAppServerTransportException( + "Codex App Server closed stdout.", + responseBytesObserved: lineLength > 0 || _unmatchedOperationBytes > 0)); + return; + } + + ObserveOutputActivity(); + for (var index = 0; index < read; index++) + { + var value = readBuffer[index]; + if (value != (byte)'\n') + { + if (lineLength >= _limits.MaxLineBytes) + throw new CodexAppServerProtocolException("Codex App Server JSONL line limit exceeded."); + lineBuffer[lineLength++] = value; + continue; + } + + var contentLength = lineLength > 0 && lineBuffer[lineLength - 1] == (byte)'\r' + ? lineLength - 1 + : lineLength; + if (contentLength == 0) + throw new CodexAppServerProtocolException("Malformed empty App Server JSONL message."); + HandleMessage( + CodexAppServerProtocol.ParseMessage(lineBuffer.AsSpan(0, contentLength)), + contentLength, + lineLength + 1); + lineLength = 0; + } + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (Exception exception) + { + Fail(exception is CodexAppServerException + ? exception + : new CodexAppServerTransportException( + "Failed to read Codex App Server stdout.", + responseBytesObserved: lineLength > 0 || _unmatchedOperationBytes > 0, + exception)); + } + } + + private void ObserveOutputActivity() + { + foreach (var pending in _pending.Values) + pending.ObserveActivity(); + } + + private void HandleMessage( + CodexAppServerMessage message, + int responseBytes, + int operationBytes) + { + if (message.Kind == CodexAppServerMessageKind.Notification) + { + ObserveUnmatchedOperationBytes(operationBytes); + return; + } + + if (message.Kind == CodexAppServerMessageKind.ServerRequest) + { + ObserveUnmatchedOperationBytes(operationBytes); + _ = RefuseServerRequestAsync(message.Id!.Value); + return; + } + + if (responseBytes > _limits.MaxResponseBytes) + throw new CodexAppServerProtocolException("Codex App Server response byte limit exceeded."); + + var id = message.Id!.Value; + if (!_pending.TryGetValue(id, out var pending)) + { + var description = id <= Volatile.Read(ref _highestIssuedId) + ? "duplicate response id" + : "unknown response id"; + throw new CodexAppServerProtocolException($"Codex App Server sent {description} {id}."); + } + + pending.ObserveBytes(_unmatchedOperationBytes + operationBytes); + _unmatchedOperationBytes = 0; + if (pending.OperationBytes > _limits.MaxOperationBytes) + throw new CodexAppServerProtocolException("Codex App Server operation byte limit exceeded."); + if (!_pending.TryRemove(id, out pending)) + throw new CodexAppServerProtocolException($"Codex App Server sent duplicate response id {id}."); + + if (message.Kind == CodexAppServerMessageKind.Error) + { + pending.Completion.TrySetException(new CodexAppServerRemoteException( + message.ErrorCode!.Value, + message.ErrorMessage!, + message.ErrorData)); + return; + } + + pending.Completion.TrySetResult(message.Result!.Value); + } + + private void ObserveUnmatchedOperationBytes(int count) + { + if (_pending.IsEmpty) + { + _unmatchedOperationBytes = 0; + return; + } + + _unmatchedOperationBytes += count; + if (_unmatchedOperationBytes > _limits.MaxOperationBytes) + throw new CodexAppServerProtocolException("Codex App Server operation byte limit exceeded."); + } + + private async Task RefuseServerRequestAsync(long id) + { + try + { + await WriteLineAsync( + CodexAppServerProtocol.CreateServerRequestRefusal(id), + new WriteAttempt(), + _lifetime.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (Exception exception) + { + Fail(new CodexAppServerTransportException( + "Failed to refuse Codex App Server request.", + responseBytesObserved: true, + exception)); + } + } + + private async Task DrainStandardErrorAsync() + { + var buffer = new byte[1_024]; + try + { + while (true) + { + var read = await _process.StandardError + .ReadAsync(buffer, _lifetime.Token) + .ConfigureAwait(false); + if (read == 0) + return; + _standardError.Append(buffer.AsSpan(0, read)); + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (IOException) + { + } + } + + private void Fail(Exception exception) + { + lock (_failureGate) + { + if (_failure is not null) + return; + _failure = exception; + } + + foreach (var pair in _pending.ToArray()) + { + if (!_pending.TryRemove(pair.Key, out var pending)) + continue; + var pendingException = exception is CodexAppServerTransportException transport + ? new CodexAppServerTransportException( + transport.Message, + pending.ResponseBytesObserved || transport.ResponseBytesObserved, + transport.InnerException) + : exception; + pending.Completion.TrySetException(pendingException); + } + + _lifetime.Cancel(); + RequestProcessTreeKill(); + } + + private void ThrowIfFailed() + { + Exception? failure; + lock (_failureGate) + failure = _failure; + if (failure is not null) + throw failure; + } + + private void RequestProcessTreeKill() + { + try + { + if (!_process.HasExited) + _process.KillProcessTree(); + } + catch (Exception exception) when (exception is InvalidOperationException + or System.ComponentModel.Win32Exception + or NotSupportedException) + { + lock (_failureGate) + { + _killFailure ??= new CodexAppServerCleanupException( + "Failed to kill the Codex App Server process tree.", + exception); + } + } + } + + public ValueTask DisposeAsync() + { + lock (_disposeGate) + { + _disposeTask ??= DisposeCoreAsync(); + return new ValueTask(_disposeTask); + } + } + + private async Task DisposeCoreAsync() + { + _lifetime.Cancel(); + CodexAppServerCleanupException? cleanupFailure = null; + + using (var writerDeadline = new CancellationTokenSource(_limits.CleanupTimeout)) + { + try + { + await _writeGate.WaitAsync(writerDeadline.Token).ConfigureAwait(false); + _writeGate.Release(); + } + catch (OperationCanceledException) when (writerDeadline.IsCancellationRequested) + { + cleanupFailure = new CodexAppServerCleanupException( + "Codex App Server stdin writer did not stop by the cleanup deadline."); + } + } + try + { + _process.CloseStandardInput(); + } + catch (Exception exception) when (exception is InvalidOperationException or IOException) + { + cleanupFailure ??= new CodexAppServerCleanupException( + "Failed to close Codex App Server stdin.", + exception); + } + RequestProcessTreeKill(); + + lock (_failureGate) + cleanupFailure ??= _killFailure; + + using var exitDeadline = new CancellationTokenSource(_limits.CleanupTimeout); + try + { + await _process.WaitForExitAsync(exitDeadline.Token).ConfigureAwait(false); + if (!_process.HasExited) + { + cleanupFailure ??= new CodexAppServerCleanupException( + "Codex App Server process did not exit by the cleanup deadline."); + } + } + catch (OperationCanceledException) when (exitDeadline.IsCancellationRequested) + { + cleanupFailure ??= new CodexAppServerCleanupException( + "Codex App Server process did not exit by the cleanup deadline."); + } + catch (Exception exception) when (exception is InvalidOperationException + or System.ComponentModel.Win32Exception) + { + cleanupFailure ??= new CodexAppServerCleanupException( + "Failed while waiting for the Codex App Server process to exit.", + exception); + } + + var drains = new[] { _stdoutDrain, _stderrDrain }.Where(task => task is not null).Cast(); + try + { + await Task.WhenAll(drains).WaitAsync(_limits.CleanupTimeout).ConfigureAwait(false); + } + catch (Exception exception) when (exception is OperationCanceledException or TimeoutException) + { + cleanupFailure ??= new CodexAppServerCleanupException( + "Codex App Server output drains did not stop by the cleanup deadline.", + exception); + } + + foreach (var pending in _pending.Values) + pending.Completion.TrySetException(new ObjectDisposedException(nameof(CodexAppServerClient))); + _pending.Clear(); + _lifetime.Dispose(); + _process.Dispose(); + + if (cleanupFailure is not null) + throw cleanupFailure; + } + + private sealed class WriteAttempt + { + public bool FrameStarted { get; set; } + } + + private sealed class PendingRequest + { + private long _lastActivity = Stopwatch.GetTimestamp(); + private long _operationBytes; + + public TaskCompletionSource Completion { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool ResponseBytesObserved => Volatile.Read(ref _operationBytes) > 0; + + public long OperationBytes => Volatile.Read(ref _operationBytes); + + public TimeSpan ElapsedSinceActivity => + Stopwatch.GetElapsedTime(Volatile.Read(ref _lastActivity)); + + public void ObserveBytes(long count) + { + Interlocked.Add(ref _operationBytes, count); + ObserveActivity(); + } + + public void ObserveActivity() + { + Volatile.Write(ref _lastActivity, Stopwatch.GetTimestamp()); + } + } +} + +internal sealed class BoundedByteRing +{ + private readonly byte[] _buffer; + private readonly object _gate = new(); + private int _start; + private int _count; + + public BoundedByteRing(int capacity) + { + _buffer = new byte[capacity]; + } + + public void Append(ReadOnlySpan bytes) + { + lock (_gate) + { + foreach (var value in bytes) + { + if (_count < _buffer.Length) + { + _buffer[(_start + _count) % _buffer.Length] = value; + _count++; + } + else + { + _buffer[_start] = value; + _start = (_start + 1) % _buffer.Length; + } + } + } + } + + public string GetUtf8Tail() + { + lock (_gate) + { + var bytes = new byte[_count]; + for (var index = 0; index < _count; index++) + bytes[index] = _buffer[(_start + index) % _buffer.Length]; + return Encoding.UTF8.GetString(bytes); + } + } +} + +internal static class InterlockedExtensions +{ + public static void Max(ref long location, long value) + { + var current = Volatile.Read(ref location); + while (current < value) + { + var observed = Interlocked.CompareExchange(ref location, value, current); + if (observed == current) + return; + current = observed; + } + } +} diff --git a/src/OpenClaw.Shared/Codex/CodexAppServerProtocol.cs b/src/OpenClaw.Shared/Codex/CodexAppServerProtocol.cs new file mode 100644 index 000000000..1764d4981 --- /dev/null +++ b/src/OpenClaw.Shared/Codex/CodexAppServerProtocol.cs @@ -0,0 +1,363 @@ +using System.Text.Json; + +namespace OpenClaw.Shared.Codex; + +public static class CodexAppServerProtocol +{ + public const string ThreadListMethod = "thread/list"; + public const string ThreadTurnsListMethod = "thread/turns/list"; + + internal const string InitializeMethod = "initialize"; + internal const string InitializedMethod = "initialized"; + + internal static byte[] CreateInitializeRequest(long id) => + JsonSerializer.SerializeToUtf8Bytes(new + { + id, + method = InitializeMethod, + @params = new + { + clientInfo = new + { + name = "openclaw-windows-node", + title = "OpenClaw Windows Node", + version = "1", + }, + capabilities = new + { + experimentalApi = true, + requestAttestation = false, + mcpServerOpenaiFormElicitation = false, + }, + }, + }); + + internal static byte[] CreateInitializedNotification() => + JsonSerializer.SerializeToUtf8Bytes(new { method = InitializedMethod }); + + internal static byte[] CreateRequest(long id, string method, JsonElement parameters) => + JsonSerializer.SerializeToUtf8Bytes(new + { + id, + method, + @params = parameters, + }); + + internal static JsonElement CreateThreadListParameters( + string? cursor, + int limit, + string? cwd, + bool archived, + bool? useStateDbOnly = true) => + JsonSerializer.SerializeToElement(new Dictionary + { + ["cursor"] = cursor, + ["limit"] = limit, + ["modelProviders"] = Array.Empty(), + ["sortKey"] = "updated_at", + ["sortDirection"] = "desc", + ["archived"] = archived, + ["useStateDbOnly"] = useStateDbOnly, + ["cwd"] = cwd, + }.Where(entry => entry.Value is not null) + .ToDictionary(entry => entry.Key, entry => entry.Value)); + + internal static byte[] CreateServerRequestRefusal(long id) => + JsonSerializer.SerializeToUtf8Bytes(new + { + id, + error = new + { + code = -32601, + message = "OpenClaw read-only client refuses server requests.", + }, + }); + + internal static CodexAppServerMessage ParseMessage(ReadOnlySpan utf8Json) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(utf8Json.ToArray()); + } + catch (JsonException exception) + { + throw new CodexAppServerProtocolException("Malformed App Server JSONL message.", exception); + } + + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + throw new CodexAppServerProtocolException("Malformed App Server message: expected an object."); + + var hasId = root.TryGetProperty("id", out var idElement); + var hasMethod = root.TryGetProperty("method", out var methodElement); + var hasResult = root.TryGetProperty("result", out var resultElement); + var hasError = root.TryGetProperty("error", out var errorElement); + + if (hasMethod) + { + RejectUnknownFields( + root, + hasId + ? ["id", "method", "params", "trace"] + : ["method", "params", "emittedAtMs"]); + if (methodElement.ValueKind != JsonValueKind.String) + throw new CodexAppServerProtocolException("Malformed App Server message: method must be a string."); + + var method = methodElement.GetString()!; + if (!hasId) + { + if (root.TryGetProperty("emittedAtMs", out var emittedAtMs) + && (emittedAtMs.ValueKind != JsonValueKind.Number + || !emittedAtMs.TryGetInt64(out _))) + { + throw new CodexAppServerProtocolException( + "Malformed App Server message: emittedAtMs must be an integer."); + } + + return CodexAppServerMessage.ForNotification(method); + } + + return CodexAppServerMessage.ForServerRequest(ReadNumericId(idElement), method); + } + + if (!hasId || hasResult == hasError) + throw new CodexAppServerProtocolException("Malformed App Server response envelope."); + + var id = ReadNumericId(idElement); + if (hasResult) + { + RejectUnknownFields(root, ["id", "result"]); + return CodexAppServerMessage.ForResult(id, resultElement.Clone()); + } + + RejectUnknownFields(root, ["id", "error"]); + if (errorElement.ValueKind != JsonValueKind.Object) + throw new CodexAppServerProtocolException("Malformed App Server error response."); + + RejectUnknownFields(errorElement, ["code", "message", "data"]); + if (!errorElement.TryGetProperty("code", out var codeElement) + || !codeElement.TryGetInt64(out var code) + || !errorElement.TryGetProperty("message", out var errorMessageElement) + || errorMessageElement.ValueKind != JsonValueKind.String) + { + throw new CodexAppServerProtocolException("Malformed App Server error response."); + } + + return CodexAppServerMessage.ForError( + id, + code, + errorMessageElement.GetString()!, + errorElement.TryGetProperty("data", out var data) ? data.Clone() : null); + } + } + + private static long ReadNumericId(JsonElement id) + { + if (id.ValueKind != JsonValueKind.Number || !id.TryGetInt64(out var value)) + throw new CodexAppServerProtocolException("App Server response id must be numeric."); + return value; + } + + private static void RejectUnknownFields(JsonElement value, IReadOnlyCollection allowed) + { + foreach (var property in value.EnumerateObject()) + { + if (!allowed.Contains(property.Name, StringComparer.Ordinal)) + throw new CodexAppServerProtocolException( + $"Unknown App Server message field '{property.Name}'."); + } + } +} + +internal enum CodexAppServerMessageKind +{ + Result, + Error, + Notification, + ServerRequest, +} + +internal sealed record CodexAppServerMessage( + CodexAppServerMessageKind Kind, + long? Id, + string? Method, + JsonElement? Result, + long? ErrorCode, + string? ErrorMessage, + JsonElement? ErrorData) +{ + public static CodexAppServerMessage ForResult(long id, JsonElement result) => + new(CodexAppServerMessageKind.Result, id, null, result, null, null, null); + + public static CodexAppServerMessage ForError( + long id, + long code, + string message, + JsonElement? data) => + new(CodexAppServerMessageKind.Error, id, null, null, code, message, data); + + public static CodexAppServerMessage ForNotification(string method) => + new(CodexAppServerMessageKind.Notification, null, method, null, null, null, null); + + public static CodexAppServerMessage ForServerRequest(long id, string method) => + new(CodexAppServerMessageKind.ServerRequest, id, method, null, null, null, null); +} + +public class CodexAppServerException : Exception +{ + public CodexAppServerException(string message) + : base(message) + { + } + + public CodexAppServerException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public sealed class CodexAppServerProtocolException : CodexAppServerException +{ + public CodexAppServerProtocolException(string message) + : base(message) + { + } + + public CodexAppServerProtocolException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public sealed class CodexAppServerRemoteException : CodexAppServerException +{ + internal CodexAppServerRemoteException(long code, string message, JsonElement? data) + : base($"Codex App Server request failed ({code}): {message}") + { + Code = code; + DataValue = data; + } + + public long Code { get; } + + public JsonElement? DataValue { get; } +} + +public sealed class CodexAppServerTransportException : CodexAppServerException +{ + internal CodexAppServerTransportException( + string message, + bool responseBytesObserved, + Exception? innerException = null) + : base(message, innerException ?? new IOException(message)) + { + ResponseBytesObserved = responseBytesObserved; + } + + public bool ResponseBytesObserved { get; } +} + +public enum CodexAppServerTimeoutKind +{ + Request, + Idle, +} + +public sealed class CodexAppServerTimeoutException : CodexAppServerException +{ + internal CodexAppServerTimeoutException(CodexAppServerTimeoutKind kind) + : base(kind == CodexAppServerTimeoutKind.Request + ? "Codex App Server request timed out." + : "Codex App Server request exceeded the idle timeout.") + { + Kind = kind; + } + + public CodexAppServerTimeoutKind Kind { get; } +} + +public sealed class CodexAppServerCleanupException : CodexAppServerException +{ + internal CodexAppServerCleanupException(string message) + : base(message) + { + } + + internal CodexAppServerCleanupException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +internal sealed record CodexAppServerLimits +{ + public static CodexAppServerLimits Default { get; } = new( + maxLineBytes: 1_048_576, + maxResponseBytes: 1_048_576, + maxOperationBytes: 8_388_608, + maxStandardErrorBytes: 16_384, + requestTimeout: TimeSpan.FromSeconds(20), + idleTimeout: TimeSpan.FromSeconds(5), + cleanupTimeout: TimeSpan.FromSeconds(2)); + + public static CodexAppServerLimits Catalog { get; } = Default with + { + MaxLineBytes = CodexSessionCatalogService.MaxTranscriptPageBytes + + CodexSessionCatalogService.MaxJsonRpcEnvelopeBytes, + MaxResponseBytes = CodexSessionCatalogService.MaxTranscriptPageBytes + + CodexSessionCatalogService.MaxJsonRpcEnvelopeBytes, + MaxOperationBytes = CodexSessionCatalogService.MaxTranscriptPageBytes + + CodexSessionCatalogService.MaxJsonRpcEnvelopeBytes + + CodexSessionCatalogService.MaxCatalogOperationOverheadBytes, + RequestTimeout = TimeSpan.FromSeconds(60), + IdleTimeout = TimeSpan.FromSeconds(60), + }; + + public CodexAppServerLimits( + int maxLineBytes, + int maxResponseBytes, + int maxOperationBytes, + int maxStandardErrorBytes, + TimeSpan requestTimeout, + TimeSpan idleTimeout, + TimeSpan cleanupTimeout) + { + if (maxLineBytes <= 0 + || maxResponseBytes <= 0 + || maxOperationBytes <= 0 + || maxStandardErrorBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxLineBytes)); + } + + if (requestTimeout <= TimeSpan.Zero + || idleTimeout <= TimeSpan.Zero + || cleanupTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(requestTimeout)); + + MaxLineBytes = maxLineBytes; + MaxResponseBytes = maxResponseBytes; + MaxOperationBytes = maxOperationBytes; + MaxStandardErrorBytes = maxStandardErrorBytes; + RequestTimeout = requestTimeout; + IdleTimeout = idleTimeout; + CleanupTimeout = cleanupTimeout; + } + + public int MaxLineBytes { get; init; } + + public int MaxResponseBytes { get; init; } + + public int MaxOperationBytes { get; init; } + + public int MaxStandardErrorBytes { get; init; } + + public TimeSpan RequestTimeout { get; init; } + + public TimeSpan IdleTimeout { get; init; } + + public TimeSpan CleanupTimeout { get; init; } +} diff --git a/src/OpenClaw.Shared/Codex/CodexExecutableResolver.cs b/src/OpenClaw.Shared/Codex/CodexExecutableResolver.cs new file mode 100644 index 000000000..18ce4bb4a --- /dev/null +++ b/src/OpenClaw.Shared/Codex/CodexExecutableResolver.cs @@ -0,0 +1,205 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; + +namespace OpenClaw.Shared.Codex; + +internal sealed class CodexExecutableResolver +{ + private const string ExecutableName = "codex.exe"; + private readonly ICodexExecutablePlatform _platform; + + internal CodexExecutableResolver() + : this(new CurrentProcessCodexExecutablePlatform()) + { + } + + internal CodexExecutableResolver(ICodexExecutablePlatform platform) + { + ArgumentNullException.ThrowIfNull(platform); + _platform = platform; + } + + internal CodexLaunchPlan? Resolve() + { + var packagedAlias = GetPackagedAlias(); + if (packagedAlias is not null && IsExistingFile(packagedAlias, allowReparsePoint: true)) + return new CodexLaunchPlan(packagedAlias, path => IsExistingFile(path, allowReparsePoint: true)); + + if (string.IsNullOrWhiteSpace(_platform.PathEnvironment)) + return null; + + foreach (var pathEntry in _platform.PathEnvironment.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(pathEntry) + || !_platform.IsPathFullyQualified(pathEntry) + || ContainsTraversalSegment(pathEntry)) + { + continue; + } + + var candidate = TryGetFullPath(Path.Combine(pathEntry, ExecutableName)); + if (candidate is not null && IsExistingFile(candidate, allowReparsePoint: false)) + return new CodexLaunchPlan(candidate, path => IsExistingFile(path, allowReparsePoint: false)); + } + + return null; + } + + private static bool ContainsTraversalSegment(string path) + { + return path + .Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(segment => segment is "." or ".."); + } + + private string? GetPackagedAlias() + { + if (string.IsNullOrWhiteSpace(_platform.LocalApplicationData) + || !_platform.IsPathFullyQualified(_platform.LocalApplicationData)) + { + return null; + } + + return TryGetFullPath(Path.Combine( + _platform.LocalApplicationData, + "Microsoft", + "WindowsApps", + ExecutableName)); + } + + private string? TryGetFullPath(string path) + { + try + { + return _platform.GetFullPath(path); + } + catch (Exception exception) when (exception is ArgumentException + or NotSupportedException + or PathTooLongException) + { + return null; + } + } + + private bool IsExistingFile(string path, bool allowReparsePoint) + { + if (!string.Equals(Path.GetFileName(path), ExecutableName, StringComparison.OrdinalIgnoreCase) + || !string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase) + || !_platform.FileExists(path)) + { + return false; + } + + try + { + var attributes = _platform.GetAttributes(path); + return (attributes & FileAttributes.Directory) == 0 + && (allowReparsePoint || (attributes & FileAttributes.ReparsePoint) == 0); + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + return false; + } + } +} + +public sealed class CodexLaunchPlan +{ + private static readonly IReadOnlyList LaunchArguments = + Array.AsReadOnly(["app-server", "--listen", "stdio://"]); + + private static readonly IReadOnlyDictionary EmptyEnvironment = + new ReadOnlyDictionary(new Dictionary()); + + private readonly Func _isTrustedExecutable; + + internal CodexLaunchPlan(string executablePath) + : this(executablePath, path => File.Exists(path) && + (File.GetAttributes(path) & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0) + { + } + + internal CodexLaunchPlan(string executablePath, Func isTrustedExecutable) + { + ExecutablePath = executablePath; + _isTrustedExecutable = isTrustedExecutable ?? throw new ArgumentNullException(nameof(isTrustedExecutable)); + } + + public string ExecutablePath { get; } + + public IReadOnlyList Arguments => LaunchArguments; + + public IReadOnlyDictionary EnvironmentOverrides => EmptyEnvironment; + + public bool UseShellExecute => false; + + public bool RedirectStandardInput => true; + + public bool RedirectStandardOutput => true; + + public bool RedirectStandardError => true; + + internal bool IsTrustedForLaunch() + { + try + { + return _isTrustedExecutable(ExecutablePath); + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + return false; + } + } + + public ProcessStartInfo CreateProcessStartInfo() + { + var startInfo = new ProcessStartInfo + { + FileName = ExecutablePath, + UseShellExecute = UseShellExecute, + RedirectStandardInput = RedirectStandardInput, + RedirectStandardOutput = RedirectStandardOutput, + RedirectStandardError = RedirectStandardError, + }; + + foreach (var argument in Arguments) + startInfo.ArgumentList.Add(argument); + + return startInfo; + } +} + +internal interface ICodexExecutablePlatform +{ + string? LocalApplicationData { get; } + + string? PathEnvironment { get; } + + string GetFullPath(string path); + + bool IsPathFullyQualified(string path); + + bool FileExists(string path); + + FileAttributes GetAttributes(string path); +} + +internal sealed class CurrentProcessCodexExecutablePlatform : ICodexExecutablePlatform +{ + public string? LocalApplicationData => + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + public string? PathEnvironment => Environment.GetEnvironmentVariable("PATH"); + + public string GetFullPath(string path) => Path.GetFullPath(path); + + public bool IsPathFullyQualified(string path) => Path.IsPathFullyQualified(path); + + public bool FileExists(string path) => File.Exists(path); + + public FileAttributes GetAttributes(string path) => File.GetAttributes(path); +} diff --git a/src/OpenClaw.Shared/Codex/CodexSessionAccessMode.cs b/src/OpenClaw.Shared/Codex/CodexSessionAccessMode.cs new file mode 100644 index 000000000..401365875 --- /dev/null +++ b/src/OpenClaw.Shared/Codex/CodexSessionAccessMode.cs @@ -0,0 +1,8 @@ +namespace OpenClaw.Shared.Codex; + +public enum CodexSessionAccessMode +{ + Off, + ReadOnly, + ReadAndSteer, +} diff --git a/src/OpenClaw.Shared/Codex/CodexSessionCatalogService.cs b/src/OpenClaw.Shared/Codex/CodexSessionCatalogService.cs new file mode 100644 index 000000000..49fbb4033 --- /dev/null +++ b/src/OpenClaw.Shared/Codex/CodexSessionCatalogService.cs @@ -0,0 +1,752 @@ +using System.Text; +using System.Text.Json; + +namespace OpenClaw.Shared.Codex; + +internal interface ICodexSessionCatalogClient +{ + Task ListThreadsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default); + + Task ListThreadTurnsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default); +} + +internal sealed class CodexSessionCatalogClientAdapter : ICodexSessionCatalogClient +{ + private readonly CodexAppServerClient _client; + + internal CodexSessionCatalogClientAdapter(CodexAppServerClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public Task ListThreadsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default) => + _client.ListThreadsAsync(parameters, cancellationToken); + + public Task ListThreadTurnsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default) => + _client.ListThreadTurnsAsync(parameters, cancellationToken); +} + +internal sealed class CodexSessionCatalogValidationException : Exception +{ + public CodexSessionCatalogValidationException(string message) + : base(message) + { + } +} + +internal sealed class CodexSessionCatalogService +{ + internal const int DefaultPageLimit = 50; + internal const int MaxPageLimit = 100; + internal const int DefaultTranscriptPageLimit = 20; + internal const int MaxTranscriptPageLimit = 50; + internal const int MaxCursorLength = 4096; + internal const int MaxSearchLength = 500; + internal const int MaxCwdLength = 4096; + internal const int MaxSessionIdLength = 256; + internal const int MaxSessionNameLength = 500; + internal const int MaxSessionPreviewLength = 500; + internal const int MaxMetadataLength = 500; + internal const int MaxActiveFlags = 16; + internal const int MaxActiveFlagLength = 128; + internal const int MaxTitleSearchPages = 20; + internal const int MaxEligibilityPages = 100; + internal const int EligibilityPageLimit = 10; + internal const int MaxTranscriptTextLength = 1_000_000; + internal const int MaxTranscriptPageBytes = 20 * 1024 * 1024; + internal const int MaxJsonRpcEnvelopeBytes = 4 * 1024; + internal const int MaxCatalogOperationOverheadBytes = 4 * 1024; + + private static readonly HashSet InteractiveStringSources = + new(StringComparer.Ordinal) { "cli", "vscode" }; + + private static readonly HashSet InteractiveCustomSources = + new(StringComparer.Ordinal) { "atlas", "chatgpt" }; + + // This mirrors the current thread/turns/list contract. Keep this finite: + // App Server is an untrusted versioned boundary and must not grow the + // Windows catalog payload merely by adding fields upstream. + private static readonly HashSet TranscriptTurnFields = + new(StringComparer.Ordinal) { "id", "status", "createdAt", "updatedAt", "itemsView" }; + + private static readonly HashSet TranscriptItemFields = + new(StringComparer.Ordinal) + { + "id", "type", "title", "status", "name", "tool", "server", "command", "cwd", "query", + "arguments", "result", "error", "exitCode", "durationMs", "aggregatedOutput", "text", + "contentItems", "content", "clientId", "summary", "commandActions", "changes", + }; + + private readonly ICodexSessionCatalogClient _client; + + internal CodexSessionCatalogService(CodexAppServerClient client) + : this(new CodexSessionCatalogClientAdapter(client)) + { + } + + internal CodexSessionCatalogService(ICodexSessionCatalogClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + internal async Task ListThreadsAsync( + JsonElement arguments, + CancellationToken cancellationToken) + { + var request = ReadListRequest(arguments); + if (request.SearchTerm is null) + { + var response = await _client.ListThreadsAsync( + CreateThreadListParameters(request), + cancellationToken).ConfigureAwait(false); + return ProjectThreadPage(response, searchTerm: null, request.Limit, archived: false); + } + + return await SearchThreadPagesAsync(request, archived: false, cancellationToken).ConfigureAwait(false); + } + + internal async Task ListThreadHistoryAsync( + JsonElement arguments, + CancellationToken cancellationToken) + { + var request = ReadHistoryListRequest(arguments); + var listRequest = new ListRequest(request.Cursor, request.Limit, request.SearchTerm, null); + if (request.SearchTerm is null) + { + var response = await _client.ListThreadsAsync( + CreateThreadListParameters(listRequest, request.Archived, useStateDbOnly: false), + cancellationToken).ConfigureAwait(false); + return ProjectThreadPage(response, searchTerm: null, request.Limit, request.Archived); + } + + return await SearchThreadPagesAsync( + listRequest, + request.Archived, + cancellationToken, + useStateDbOnly: false).ConfigureAwait(false); + } + + internal async Task ListThreadTurnsAsync( + JsonElement arguments, + CancellationToken cancellationToken) + { + var request = ReadTranscriptRequest(arguments); + await RequireFreshEligibilityAsync(request.ThreadId, cancellationToken).ConfigureAwait(false); + + var response = await _client.ListThreadTurnsAsync( + JsonSerializer.SerializeToElement(new Dictionary + { + ["threadId"] = request.ThreadId, + ["cursor"] = request.Cursor, + ["limit"] = request.Limit, + ["sortDirection"] = "desc", + ["itemsView"] = "full", + }.Where(entry => entry.Value is not null) + .ToDictionary(entry => entry.Key, entry => entry.Value)), + cancellationToken).ConfigureAwait(false); + return ProjectTranscriptPage(response); + } + + private async Task SearchThreadPagesAsync( + ListRequest request, + bool archived, + CancellationToken cancellationToken, + bool? useStateDbOnly = true) + { + var sessions = new List(request.Limit); + var seenCursors = new HashSet(StringComparer.Ordinal); + string? cursor = request.Cursor; + string? nextCursor = null; + string? backwardsCursor = null; + for (var pageIndex = 0; pageIndex < MaxTitleSearchPages; pageIndex++) + { + var pageRequest = request with + { + Cursor = cursor, + Limit = request.Limit - sessions.Count, + }; + var response = await _client.ListThreadsAsync( + CreateThreadListParameters(pageRequest, archived, useStateDbOnly), + cancellationToken).ConfigureAwait(false); + var page = ProjectThreadPage(response, request.SearchTerm, pageRequest.Limit, archived); + if (pageIndex == 0 + && page.TryGetProperty("backwardsCursor", out var backwards) + && backwards.ValueKind == JsonValueKind.String) + { + backwardsCursor = backwards.GetString(); + } + sessions.AddRange(page.GetProperty("sessions") + .EnumerateArray() + .Take(request.Limit - sessions.Count) + .Select(value => value.Clone())); + nextCursor = page.TryGetProperty("nextCursor", out var next) + && next.ValueKind == JsonValueKind.String + ? next.GetString() + : null; + if (sessions.Count >= request.Limit || nextCursor is null) + break; + if (!seenCursors.Add(nextCursor)) + throw new InvalidDataException("Repeated Codex App Server search cursor."); + cursor = nextCursor; + } + + var result = new Dictionary { ["sessions"] = sessions }; + if (nextCursor is not null) + result["nextCursor"] = nextCursor; + if (backwardsCursor is not null) + result["backwardsCursor"] = backwardsCursor; + return JsonSerializer.SerializeToElement(result); + } + + private async Task RequireFreshEligibilityAsync( + string threadId, + CancellationToken cancellationToken) + { + string? cursor = null; + var seenCursors = new HashSet(StringComparer.Ordinal); + for (var pageIndex = 0; pageIndex < MaxEligibilityPages; pageIndex++) + { + var response = await _client.ListThreadsAsync( + CreateThreadListParameters( + new ListRequest(cursor, EligibilityPageLimit, null, null), + useStateDbOnly: null), + cancellationToken).ConfigureAwait(false); + var page = ProjectThreadPage(response, searchTerm: null, EligibilityPageLimit, archived: false); + if (page.GetProperty("sessions").EnumerateArray().Any(session => + session.GetProperty("threadId").GetString() == threadId)) + { + return; + } + var nextCursor = page.TryGetProperty("nextCursor", out var next) + && next.ValueKind == JsonValueKind.String + ? next.GetString() + : null; + if (nextCursor is null) + { + throw new CodexSessionCatalogValidationException( + "Codex session is not a non-archived interactive Codex session"); + } + if (!seenCursors.Add(nextCursor)) + { + throw new CodexSessionCatalogValidationException( + "Codex session eligibility could not be verified"); + } + cursor = nextCursor; + } + throw new CodexSessionCatalogValidationException( + "Codex session eligibility could not be verified"); + } + + private static ListRequest ReadListRequest(JsonElement arguments) + { + var values = ReadObject(arguments, "Codex session catalog parameters must be an object"); + RejectUnknownFields(values, new HashSet(StringComparer.Ordinal) + { + "cursor", "limit", "searchTerm", "cwd", + }); + return new ListRequest( + ReadOptionalString(values, "cursor", MaxCursorLength), + ReadLimit(values, "limit", DefaultPageLimit, MaxPageLimit), + ReadOptionalString(values, "searchTerm", MaxSearchLength), + ReadOptionalString(values, "cwd", MaxCwdLength)); + } + + private static HistoryListRequest ReadHistoryListRequest(JsonElement arguments) + { + var values = ReadObject(arguments, "Codex session catalog parameters must be an object"); + RejectUnknownFields(values, new HashSet(StringComparer.Ordinal) + { + "cursor", "limit", "searchTerm", "archived", + }); + if (!values.TryGetValue("archived", out var archived)) + throw new CodexSessionCatalogValidationException("archived is required"); + if (archived.ValueKind is not JsonValueKind.True and not JsonValueKind.False) + throw new CodexSessionCatalogValidationException("archived must be a boolean"); + return new HistoryListRequest( + ReadOptionalString(values, "cursor", MaxCursorLength), + ReadLimit(values, "limit", DefaultPageLimit, MaxPageLimit), + ReadOptionalString(values, "searchTerm", MaxSearchLength), + archived.GetBoolean()); + } + + private static TranscriptRequest ReadTranscriptRequest(JsonElement arguments) + { + var values = ReadObject(arguments, "Codex session read parameters must be an object"); + RejectUnknownFields(values, new HashSet(StringComparer.Ordinal) + { + "threadId", "cursor", "limit", + }); + var threadId = ReadOptionalString(values, "threadId", MaxSessionIdLength); + if (threadId is null) + throw new CodexSessionCatalogValidationException("threadId is required"); + if (!Guid.TryParseExact(threadId, "D", out _)) + throw new CodexSessionCatalogValidationException("threadId must be a UUID"); + return new TranscriptRequest( + threadId, + ReadOptionalString(values, "cursor", MaxCursorLength), + ReadLimit(values, "limit", DefaultTranscriptPageLimit, MaxTranscriptPageLimit)); + } + + private static JsonElement CreateThreadListParameters( + ListRequest request, + bool archived = false, + bool? useStateDbOnly = true) => + CodexAppServerProtocol.CreateThreadListParameters( + request.Cursor, + request.Limit, + request.Cwd, + archived, + useStateDbOnly); + + private static JsonElement ProjectThreadPage( + JsonElement response, + string? searchTerm, + int maxSessions, + bool archived) + { + if (Encoding.UTF8.GetByteCount(response.GetRawText()) > MaxTranscriptPageBytes) + throw new InvalidDataException("Codex App Server thread page exceeds the byte limit."); + if (response.ValueKind != JsonValueKind.Object + || !response.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Array + || data.GetArrayLength() > MaxPageLimit) + { + throw new InvalidDataException("Invalid Codex App Server thread page."); + } + + var sessions = new List>(); + foreach (var thread in data.EnumerateArray()) + { + var session = ProjectThread(thread, archived); + if (session is null) + continue; + if (searchTerm is not null + && (!session.TryGetValue("name", out var name) + || name is not string title + || !title.Contains(searchTerm, StringComparison.CurrentCultureIgnoreCase))) + { + continue; + } + sessions.Add(session); + if (sessions.Count == maxSessions) + break; + } + + var result = new Dictionary { ["sessions"] = sessions }; + CopyCursor(response, result, "nextCursor"); + CopyCursor(response, result, "backwardsCursor"); + return JsonSerializer.SerializeToElement(result); + } + + private static Dictionary? ProjectThread(JsonElement thread, bool archived) + { + if (thread.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("Invalid Codex App Server thread."); + var isArchived = thread.TryGetProperty("archived", out var archivedValue) + && archivedValue.ValueKind == JsonValueKind.True; + if (isArchived != archived) + return null; + var source = ReadInteractiveSource(thread); + if (source is null) + return null; + if (!thread.TryGetProperty("id", out var idValue) + || idValue.ValueKind != JsonValueKind.String + || !Guid.TryParseExact(idValue.GetString(), "D", out _)) + { + throw new InvalidDataException("Invalid Codex App Server thread id."); + } + + var result = new Dictionary + { + ["threadId"] = idValue.GetString(), + ["status"] = ReadStatus(thread, out var activeFlags), + ["archived"] = archived, + }; + CopyBoundedString(thread, result, "sessionId", "sessionId", MaxSessionIdLength); + CopyNameAndFallback(thread, result); + CopyBoundedString(thread, result, "cwd", "cwd", MaxCwdLength); + if (activeFlags.Count > 0) + result["activeFlags"] = activeFlags; + CopyFiniteNumber(thread, result, "createdAt"); + CopyFiniteNumber(thread, result, "updatedAt"); + CopyFiniteNumber(thread, result, "recencyAt", allowNull: true); + result["source"] = source; + CopyBoundedString(thread, result, "modelProvider", "modelProvider", MaxMetadataLength, truncate: true); + CopyBoundedString(thread, result, "cliVersion", "cliVersion", MaxMetadataLength, truncate: true); + if (thread.TryGetProperty("gitInfo", out var gitInfo) && gitInfo.ValueKind == JsonValueKind.Object) + CopyBoundedString(gitInfo, result, "branch", "gitBranch", MaxMetadataLength, truncate: true); + return result; + } + + private static JsonElement ProjectTranscriptPage(JsonElement response) + { + if (response.ValueKind != JsonValueKind.Object + || !response.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Array + || data.GetArrayLength() > MaxTranscriptPageLimit) + { + throw new InvalidDataException("Invalid Codex App Server transcript page."); + } + + var turns = data.EnumerateArray().Select(ProjectTranscriptTurn).ToArray(); + var result = new Dictionary { ["data"] = turns }; + CopyCursor(response, result, "nextCursor"); + CopyCursor(response, result, "backwardsCursor"); + var page = JsonSerializer.SerializeToElement(result); + if (Encoding.UTF8.GetByteCount(page.GetRawText()) > MaxTranscriptPageBytes) + throw new InvalidDataException("Codex App Server transcript page exceeds the byte limit."); + return page; + } + + private static Dictionary ProjectTranscriptTurn(JsonElement turn) + { + if (turn.ValueKind != JsonValueKind.Object + || !turn.TryGetProperty("items", out var items) + || items.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("Invalid Codex App Server transcript page."); + } + + var projected = new Dictionary(); + foreach (var property in turn.EnumerateObject()) + { + if (!TranscriptTurnFields.Contains(property.Name)) + continue; + ValidateTranscriptText(property.Value); + projected[property.Name] = property.Value.Clone(); + } + + projected["items"] = items.EnumerateArray().Select(ProjectTranscriptItem).ToArray(); + return projected; + } + + private static Dictionary ProjectTranscriptItem(JsonElement item) + { + if (item.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("Invalid Codex App Server transcript page."); + + var projected = new Dictionary(); + foreach (var property in item.EnumerateObject()) + { + if (!TranscriptItemFields.Contains(property.Name)) + continue; + ValidateTranscriptText(property.Value); + projected[property.Name] = property.Value.Clone(); + } + return projected; + } + + private static void ValidateTranscriptText(JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + if (value.GetString()!.Length > MaxTranscriptTextLength) + throw new InvalidDataException("Codex App Server transcript text exceeds the limit."); + break; + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) + ValidateTranscriptText(item); + break; + case JsonValueKind.Object: + foreach (var property in value.EnumerateObject()) + ValidateTranscriptText(property.Value); + break; + } + } + + private static Dictionary ReadObject(JsonElement value, string error) + { + if (value.ValueKind == JsonValueKind.Undefined) + return new Dictionary(StringComparer.Ordinal); + if (value.ValueKind != JsonValueKind.Object) + throw new CodexSessionCatalogValidationException(error); + return value.EnumerateObject().ToDictionary( + property => property.Name, + property => property.Value, + StringComparer.Ordinal); + } + + private static void RejectUnknownFields( + IReadOnlyDictionary values, + IReadOnlySet allowed) + { + var unknown = values.Keys.FirstOrDefault(key => !allowed.Contains(key)); + if (unknown is not null) + { + throw new CodexSessionCatalogValidationException( + $"unknown Codex session catalog parameter: {SanitizeErrorToken(unknown)}"); + } + } + + private static string? ReadOptionalString( + IReadOnlyDictionary values, + string key, + int maxLength) + { + if (!values.TryGetValue(key, out var value)) + return null; + if (value.ValueKind != JsonValueKind.String) + throw new CodexSessionCatalogValidationException($"{key} must be a string"); + var text = value.GetString()!.Trim(); + if (text.Length == 0) + return null; + if (text.Length > maxLength) + throw new CodexSessionCatalogValidationException($"{key} must be at most {maxLength} characters"); + return text; + } + + private static int ReadLimit( + IReadOnlyDictionary values, + string key, + int defaultValue, + int maxValue) + { + if (!values.TryGetValue(key, out var value)) + return defaultValue; + if (value.ValueKind != JsonValueKind.Number + || !value.TryGetInt32(out var limit) + || limit < 1 + || limit > maxValue) + { + throw new CodexSessionCatalogValidationException( + $"{key} must be an integer from 1 to {maxValue}"); + } + return limit; + } + + private static string? ReadInteractiveSource(JsonElement thread) + { + if (!thread.TryGetProperty("source", out var source)) + return null; + if (source.ValueKind == JsonValueKind.String) + { + var value = source.GetString(); + return value is not null && InteractiveStringSources.Contains(value) ? value : null; + } + if (source.ValueKind == JsonValueKind.Object + && source.TryGetProperty("custom", out var custom) + && custom.ValueKind == JsonValueKind.String) + { + var value = custom.GetString(); + return value is not null && InteractiveCustomSources.Contains(value) ? value : null; + } + return null; + } + + private static string ReadStatus(JsonElement thread, out List activeFlags) + { + activeFlags = []; + if (!thread.TryGetProperty("status", out var status) || status.ValueKind != JsonValueKind.Object) + return "notLoaded"; + if (!status.TryGetProperty("type", out var type) || type.ValueKind != JsonValueKind.String) + return "notLoaded"; + var value = type.GetString(); + if (string.IsNullOrWhiteSpace(value) || value.Length > 64) + throw new InvalidDataException("Invalid Codex App Server thread status."); + if (value == "active" + && status.TryGetProperty("activeFlags", out var flags) + && flags.ValueKind == JsonValueKind.Array) + { + foreach (var flag in flags.EnumerateArray().Take(MaxActiveFlags)) + { + if (flag.ValueKind != JsonValueKind.String) + continue; + var normalized = BoundedString(flag.GetString(), MaxActiveFlagLength, truncate: false); + if (normalized is not null) + activeFlags.Add(normalized); + } + } + return value; + } + + private static void CopyNameAndFallback( + JsonElement thread, + IDictionary result) + { + if (thread.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.Null) + result["name"] = null; + var normalizedName = thread.TryGetProperty("name", out name) && name.ValueKind == JsonValueKind.String + ? BoundedString(name.GetString(), MaxSessionNameLength, truncate: true) + : null; + if (normalizedName is not null) + { + result["name"] = normalizedName; + return; + } + if (!thread.TryGetProperty("preview", out var preview) || preview.ValueKind != JsonValueKind.String) + return; + var sanitized = SanitizePreview(preview.GetString()!); + var fallback = BoundedString(sanitized, MaxSessionPreviewLength, truncate: true); + if (fallback is not null) + result["fallbackName"] = fallback; + } + + private static void CopyBoundedString( + JsonElement source, + IDictionary destination, + string sourceName, + string destinationName, + int maxLength, + bool truncate = false) + { + if (!source.TryGetProperty(sourceName, out var value) || value.ValueKind != JsonValueKind.String) + return; + var normalized = BoundedString(value.GetString(), maxLength, truncate); + if (normalized is not null) + destination[destinationName] = normalized; + } + + private static void CopyFiniteNumber( + JsonElement source, + IDictionary destination, + string name, + bool allowNull = false) + { + if (!source.TryGetProperty(name, out var value)) + return; + if (allowNull && value.ValueKind == JsonValueKind.Null) + { + destination[name] = null; + return; + } + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number) && double.IsFinite(number)) + destination[name] = value.Clone(); + } + + private static void CopyCursor( + JsonElement source, + IDictionary destination, + string name) + { + if (!source.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) + return; + if (value.ValueKind != JsonValueKind.String) + throw new InvalidDataException("Invalid Codex App Server cursor."); + var cursor = value.GetString(); + if (string.IsNullOrWhiteSpace(cursor) || cursor.Length > MaxCursorLength) + throw new InvalidDataException("Invalid Codex App Server cursor."); + destination[name] = cursor; + } + + private static string? BoundedString(string? value, int maxLength, bool truncate) + { + var normalized = value?.Trim(); + if (string.IsNullOrEmpty(normalized)) + return null; + if (normalized.Length <= maxLength) + return normalized; + return truncate ? TruncateUtf16Safe(normalized, maxLength) : null; + } + + private static string TruncateUtf16Safe(string value, int maxLength) + { + var length = maxLength; + if (length > 0 + && length < value.Length + && char.IsHighSurrogate(value[length - 1]) + && char.IsLowSurrogate(value[length])) + { + length--; + } + return value[..length]; + } + + private static string SanitizePreview(string value) + { + var builder = new StringBuilder(value.Length); + var previousWhitespace = false; + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + if (character == '\u001b') + { + SkipTerminalEscape(value, ref index); + continue; + } + if (character == '\u009b') + { + SkipControlSequence(value, ref index); + continue; + } + if (character == '\u009d') + { + SkipOperatingSystemCommand(value, ref index); + continue; + } + var whitespace = char.IsWhiteSpace(character) || char.IsControl(character); + if (whitespace) + { + if (!previousWhitespace) + builder.Append(' '); + } + else + { + builder.Append(character); + } + previousWhitespace = whitespace; + } + return builder.ToString().Trim(); + } + + private static void SkipTerminalEscape(string value, ref int index) + { + if (index + 1 >= value.Length) + return; + var introducer = value[++index]; + if (introducer == '[') + { + SkipControlSequence(value, ref index); + return; + } + if (introducer != ']') + return; + SkipOperatingSystemCommand(value, ref index); + } + + private static void SkipControlSequence(string value, ref int index) + { + while (index + 1 < value.Length) + { + var candidate = value[++index]; + if (candidate is >= '@' and <= '~') + return; + } + } + + private static void SkipOperatingSystemCommand(string value, ref int index) + { + while (index + 1 < value.Length) + { + var candidate = value[++index]; + if (candidate is '\a' or '\u009c') + return; + if (candidate == '\u001b' && index + 1 < value.Length && value[index + 1] == '\\') + { + index++; + return; + } + } + } + + private static string SanitizeErrorToken(string value) + { + var sanitized = new string(value.Where(character => + char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-').Take(64).ToArray()); + return sanitized.Length > 0 ? sanitized : "unknown"; + } + + private sealed record ListRequest(string? Cursor, int Limit, string? SearchTerm, string? Cwd); + + private sealed record HistoryListRequest(string? Cursor, int Limit, string? SearchTerm, bool Archived); + + private sealed record TranscriptRequest(string ThreadId, string? Cursor, int Limit); +} diff --git a/src/OpenClaw.Shared/Mcp/McpHttpServer.cs b/src/OpenClaw.Shared/Mcp/McpHttpServer.cs index d48e41155..2d1b19dba 100644 --- a/src/OpenClaw.Shared/Mcp/McpHttpServer.cs +++ b/src/OpenClaw.Shared/Mcp/McpHttpServer.cs @@ -607,6 +607,8 @@ private async Task HandleAsync( return McpRequestResult.Success; } + if (!transportResponse.TryBeginDelivery()) + throw new OperationCanceledException("Capability delivery authorization was revoked."); _writeText(ctx.Response, HttpStatusCode.OK, transportResponse.Body, "application/json"); transportResponse.CompleteDelivery(); return McpRequestResult.Success; diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index 9828790e0..141d52f40 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -73,7 +73,7 @@ internal McpToolBridge( /// Dispatch a JSON-RPC request body and return the response body (or null /// for a JSON-RPC notification, which receives no response). /// - public Task HandleRequestAsync(string requestBody) + internal Task HandleRequestAsync(string requestBody) => HandleRequestAsync(requestBody, CancellationToken.None); /// @@ -83,11 +83,19 @@ internal McpToolBridge( /// ("request timed out"). MCP notifications/cancelled messages /// cancel the matching active request and surface as "cancelled". /// - public async Task HandleRequestAsync(string requestBody, CancellationToken cancellationToken) + internal async Task HandleRequestAsync(string requestBody, CancellationToken cancellationToken) { var response = await HandleTransportRequestAsync(requestBody, cancellationToken); - response.CompleteDelivery(); - return response.Body; + try + { + if (!response.TryBeginDelivery()) + throw new OperationCanceledException("Capability delivery authorization was revoked."); + return response.Body; + } + finally + { + response.CompleteDelivery(); + } } internal async Task HandleTransportRequestAsync( @@ -139,6 +147,7 @@ internal async Task HandleTransportRequestAsync( NodeToolExecutionMode? terminalExecutionMode = null; Type? terminalErrorType = null; string? responseBody; + INodeCapabilityDeliveryLease? deliveryLease = null; var requestKey = hasId ? GetRequestKey(idElement!.Value) : null; try @@ -166,6 +175,7 @@ internal async Task HandleTransportRequestAsync( if (result is McpToolCallResult toolCall) { result = toolCall.Result; + deliveryLease = toolCall.DeliveryLease; if (toolCall.Diagnostic != null) { terminalOutcome = NodeToolOutcome.Failure; @@ -224,7 +234,7 @@ internal async Task HandleTransportRequestAsync( terminalCategory, terminalExecutionMode, terminalErrorType); - return new McpTransportResponse(responseBody, pending); + return new McpTransportResponse(responseBody, pending, deliveryLease); } } @@ -352,6 +362,14 @@ private object HandleToolsList() ["tts.status"] = "Report TTS provider readiness. No args. Returns { configuredProvider, effectiveProvider (the provider that would run now after fallback), willFallBack (bool), providers: [{ provider ('piper'|'windows'|'elevenlabs'), readiness ('ready'|'needs-api-key'|'needs-voice'|'voice-not-downloaded'|'unavailable'), isReady (bool) }] }. Carries no PII (no voice ids, no key fragments, no device names). Requires NodeTtsEnabled.", + // codex.appServer.* + ["codex.appServer.threads.list.v1"] = + "Read-only, bounded Codex App Server thread catalog. Args: cursor (string, optional, max 4096 characters), limit (int, default 50, limit 1-100), searchTerm (string, optional, max 500 characters), cwd (string, optional, max 4096 characters). Returns { sessions, nextCursor?, backwardsCursor? } with non-archived interactive sessions only. Available in Read only and Read and steer modes. Off advertises no Codex catalog commands. Stage 0 Read and steer adds no owner controls.", + ["codex.appServer.threads.history.list.v1"] = + "Read-only, bounded Codex App Server archived/history thread catalog. Args: cursor (string, optional, max 4096 characters), limit (int, default 50, limit 1-100), searchTerm (string, optional, max 500 characters), archived (bool, required). Returns { sessions, nextCursor?, backwardsCursor? } with projected interactive metadata only and never transcript bodies. Available in Read only and Read and steer modes. Off advertises no Codex catalog commands. Stage 0 Read and steer adds no owner controls.", + ["codex.appServer.thread.turns.list.v1"] = + "Read-only, bounded Codex App Server transcript page for a freshly eligible catalog thread. Args: threadId (UUID, required), cursor (string, optional, max 4096 characters), limit (int, default 20, limit 1-50). Returns { data, nextCursor?, backwardsCursor? }. Available in Read only and Read and steer modes. Off advertises no Codex catalog commands. Stage 0 Read and steer adds no owner controls.", + // app.* ["app.navigate"] = "Navigate the companion app to a specific page (e.g., 'home', 'sessions', 'settings'). Args: page (string, required). Returns { navigated, page }.", @@ -631,9 +649,28 @@ private async Task HandleToolsCallAsync( response.Diagnostic?.ExecutionMode, sandboxDenialReason: response.Diagnostic?.SandboxDenialReason); - var payloadJson = response.Payload is null - ? "null" - : JsonSerializer.Serialize(response.Payload, PayloadJsonOptions); + var deliveryLeaseProvider = capability as INodeCapabilityDeliveryLeaseProvider; + var deliveryLease = deliveryLeaseProvider?.TryAcquireDeliveryLease(); + if (deliveryLeaseProvider != null && deliveryLease is null) + { + throw new McpToolException( + "cancelled", + NodeToolErrorCategory.Other, + outcome: NodeToolOutcome.Canceled); + } + + string payloadJson; + try + { + payloadJson = response.Payload is null + ? "null" + : JsonSerializer.Serialize(response.Payload, PayloadJsonOptions); + } + catch + { + deliveryLease?.Dispose(); + throw; + } return new McpToolCallResult( new @@ -644,7 +681,8 @@ private async Task HandleToolsCallAsync( }, isError = false, }, - response.Diagnostic); + response.Diagnostic, + deliveryLease); } private static string GetRequestKey(JsonElement requestId) => @@ -813,7 +851,10 @@ public McpCapabilityException(Exception innerException) } } - private sealed record McpToolCallResult(object Result, NodeToolDiagnostic? Diagnostic); + private sealed record McpToolCallResult( + object Result, + NodeToolDiagnostic? Diagnostic, + INodeCapabilityDeliveryLease? DeliveryLease); private void CompleteToolTelemetry( NodeToolInvocation telemetry, @@ -872,9 +913,21 @@ public void CompleteDelivery(Type? deliveryError = null) => internal sealed record McpTransportResponse( string? Body, - McpPendingToolTelemetry? PendingTelemetry) + McpPendingToolTelemetry? PendingTelemetry, + INodeCapabilityDeliveryLease? DeliveryLease = null) { - public void CompleteDelivery(Type? deliveryError = null) => - PendingTelemetry?.CompleteDelivery(deliveryError); + public bool TryBeginDelivery() => DeliveryLease?.TryBeginDelivery() ?? true; + + public void CompleteDelivery(Type? deliveryError = null) + { + try + { + PendingTelemetry?.CompleteDelivery(deliveryError); + } + finally + { + DeliveryLease?.Dispose(); + } + } } } diff --git a/src/OpenClaw.Shared/NodeCapabilities.cs b/src/OpenClaw.Shared/NodeCapabilities.cs index b467018bd..d1a1ea71a 100644 --- a/src/OpenClaw.Shared/NodeCapabilities.cs +++ b/src/OpenClaw.Shared/NodeCapabilities.cs @@ -90,6 +90,25 @@ Task ExecuteAsync(NodeInvokeRequest request, CancellationTok => ExecuteAsync(request); } +/// +/// Internal transport contract for capabilities whose successful results require a +/// final authorization lease spanning serialization and delivery. +/// +internal interface INodeCapabilityDeliveryLeaseProvider +{ + INodeCapabilityDeliveryLease? TryAcquireDeliveryLease(); +} + +/// +/// A revocable authorization for a successful capability response. A transport +/// must call immediately before it writes the +/// response so permission revocation wins over an already-prepared response. +/// +internal interface INodeCapabilityDeliveryLease : IDisposable +{ + bool TryBeginDelivery(); +} + /// /// Base class for node capabilities with common functionality /// diff --git a/src/OpenClaw.Shared/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj index 596558efd..9042f0570 100644 --- a/src/OpenClaw.Shared/OpenClaw.Shared.csproj +++ b/src/OpenClaw.Shared/OpenClaw.Shared.csproj @@ -8,8 +8,9 @@ - + + diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index bdcb0f351..7bb623461 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using OpenClaw.Shared.Codex; namespace OpenClaw.Shared; @@ -53,6 +54,7 @@ public record class SettingsData public bool CameraRecordingConsentGiven { get; set; } = false; public bool NodeLocationEnabled { get; set; } = true; public bool NodeBrowserProxyEnabled { get; set; } = true; + public CodexSessionAccessMode CodexSessionAccess { get; set; } = CodexSessionAccessMode.Off; /// /// Optional override for the browser-control host port the node-side @@ -228,7 +230,10 @@ public record class SettingsData return null; try { - return JsonSerializer.Deserialize(json); + var data = JsonSerializer.Deserialize(json); + return data is not null && Enum.IsDefined(data.CodexSessionAccess) + ? data + : null; } catch (JsonException) { diff --git a/src/OpenClaw.Shared/WebSocketClientBase.cs b/src/OpenClaw.Shared/WebSocketClientBase.cs index 131b9e849..720a40ad8 100644 --- a/src/OpenClaw.Shared/WebSocketClientBase.cs +++ b/src/OpenClaw.Shared/WebSocketClientBase.cs @@ -22,6 +22,7 @@ public readonly record struct ReconnectAuthorizationResult( /// public abstract class WebSocketClientBase : IDisposable { + private static readonly AsyncLocal SendLockOwner = new(); private ClientWebSocket? _webSocket; private readonly string _gatewayUrl; private readonly string? _credentials; @@ -520,7 +521,21 @@ or WebSocketState.Closed or WebSocketState.Aborted; /// Send a text message over the WebSocket. Thread-safe. - protected virtual async Task SendRawAsync(string message) + protected virtual Task SendRawAsync(string message) + { + if (ReferenceEquals(SendLockOwner.Value, this)) + return SendRawWhileLockHeldAsync(message); + return SendRawAsync(message, authorizeAtWrite: null); + } + + /// + /// Send a pre-serialized message, checking optional delivery authorization + /// only after this message owns the serialized socket-write slot. + /// + protected async Task SendRawAsync( + string message, + Func? authorizeAtWrite, + Action? authorizationDenied = null) { try { @@ -541,43 +556,50 @@ protected virtual async Task SendRawAsync(string message) { // Serialize sends; reconnect/dispose can still close the captured socket, // so the send below keeps the existing state-change guards. - var ws = _webSocket; - if (ws?.State != WebSocketState.Open) return; - - try - { - // Rent a pooled buffer to avoid per-send heap allocations on the hot send path. - var byteCount = Encoding.UTF8.GetByteCount(message); - var buffer = ArrayPool.Shared.Rent(byteCount); - try - { - var written = Encoding.UTF8.GetBytes(message, buffer); - await ws.SendAsync(buffer.AsMemory(0, written), - WebSocketMessageType.Text, true, _cts.Token); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. - catch (OperationCanceledException) when (_cts.Token.IsCancellationRequested) + if (authorizeAtWrite is not null && !authorizeAtWrite()) { - // Shutdown/reconnect canceled an in-flight send. + authorizationDenied?.Invoke(); + return; } - // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. - catch (ObjectDisposedException) + SendLockOwner.Value = this; + await SendRawAsync(message).ConfigureAwait(false); + } + finally + { + SendLockOwner.Value = null; + _sendLock.Release(); + } + } + + private async Task SendRawWhileLockHeldAsync(string message) + { + var ws = _webSocket; + if (ws?.State != WebSocketState.Open) return; + + try + { + var byteCount = Encoding.UTF8.GetByteCount(message); + var buffer = ArrayPool.Shared.Rent(byteCount); + try { - // WebSocket was disposed between state check and send. + var written = Encoding.UTF8.GetBytes(message, buffer); + await ws.SendAsync(buffer.AsMemory(0, written), + WebSocketMessageType.Text, true, _cts.Token); } - catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.InvalidState) + finally { - _logger.Warn($"WebSocket send failed (state changed): {ex.Message}"); + ArrayPool.Shared.Return(buffer); } } - finally + catch (OperationCanceledException) when (_cts.Token.IsCancellationRequested) { - _sendLock.Release(); + } + catch (ObjectDisposedException) + { + } + catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.InvalidState) + { + _logger.Warn($"WebSocket send failed (state changed): {ex.Message}"); } } diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 52289cbd0..f2fb53847 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -21,8 +21,10 @@ public class WindowsNodeClient : WebSocketClientBase // Node capabilities registry private readonly List _capabilities = new(); + private readonly object _capabilityLock = new(); private FrozenDictionary _commandMap = FrozenDictionary.Empty; + private HandshakeCatalog _handshakeCatalog = HandshakeCatalog.Empty; private readonly NodeRegistration _registration; // Connection state private bool _isConnected; @@ -94,7 +96,14 @@ protected override void OnReconnectAuthorizationDenied( public new bool IsConnected => _isConnected; public string? NodeId => _nodeId; public string GatewayUrl => GatewayUrlForDisplay; - public IReadOnlyList Capabilities => _capabilities; + public IReadOnlyList Capabilities + { + get + { + lock (_capabilityLock) + return _capabilities.ToArray(); + } + } /// True if connected but waiting for pairing approval on gateway public bool IsPendingApproval => _isPendingApproval; @@ -117,13 +126,14 @@ protected override void OnReconnectAuthorizationDenied( internal NodeRegistration Registration => _registration; /// Number of registered capabilities (read-only diagnostic accessor). - public int RegisteredCapabilityCount => _registration.Capabilities.Count; + public int RegisteredCapabilityCount => Volatile.Read(ref _handshakeCatalog).Capabilities.Length; /// Number of registered commands (read-only diagnostic accessor). - public int RegisteredCommandCount => _registration.Commands.Count; + public int RegisteredCommandCount => Volatile.Read(ref _handshakeCatalog).Commands.Length; /// First few registered command names for diagnostic logging. - public IEnumerable RegisteredCommandsSample => _registration.Commands.Take(5); + public IEnumerable RegisteredCommandsSample => + Volatile.Read(ref _handshakeCatalog).Commands.Take(5); protected override int ReceiveBufferSize => 65536; protected override string ClientRole => "node"; @@ -200,29 +210,79 @@ public static bool HasStoredNodeDeviceToken(string dataPath, IOpenClawLogger? lo /// public void RegisterCapability(INodeCapability capability) { - if (!_capabilities.Contains(capability)) + lock (_capabilityLock) { - _capabilities.Add(capability); + if (!_capabilities.Contains(capability)) + _capabilities.Add(capability); + + if (!_registration.Capabilities.Contains(capability.Category)) + _registration.Capabilities.Add(capability.Category); + foreach (var cmd in capability.Commands) + if (!_registration.Commands.Contains(cmd)) + _registration.Commands.Add(cmd); + + RebuildCommandMap(); + PublishHandshakeCatalog(); } - // Update registration - if (!_registration.Capabilities.Contains(capability.Category)) - { - _registration.Capabilities.Add(capability.Category); - } - foreach (var cmd in capability.Commands) - { - if (!_registration.Commands.Contains(cmd)) + _logger.Info($"Registered capability: {capability.Category} ({capability.Commands.Count} commands)"); + } + + /// Atomically replaces the live dispatch and next-handshake capability catalog. + public void ReplaceCapabilities(IEnumerable capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + var replacement = capabilities.ToArray(); + lock (_capabilityLock) + { + _capabilities.Clear(); + _capabilities.AddRange(replacement); + _registration.Capabilities.Clear(); + _registration.Commands.Clear(); + foreach (var capability in replacement) { - _registration.Commands.Add(cmd); + if (!_registration.Capabilities.Contains(capability.Category)) + _registration.Capabilities.Add(capability.Category); + foreach (var command in capability.Commands) + if (!_registration.Commands.Contains(command)) + _registration.Commands.Add(command); } + RebuildCommandMap(); + PublishHandshakeCatalog(); } - - // Rebuild the O(1) command dispatch map so node.invoke lookups stay fast - // regardless of how many capabilities or commands are registered. - RebuildCommandMap(); - - _logger.Info($"Registered capability: {capability.Category} ({capability.Commands.Count} commands)"); + } + + private void PublishHandshakeCatalog() => Volatile.Write( + ref _handshakeCatalog, + new HandshakeCatalog(_registration.Capabilities.ToArray(), _registration.Commands.ToArray())); + + internal HandshakeCatalog GetHandshakeCatalogForTest() => Volatile.Read(ref _handshakeCatalog); + + internal async Task DispatchRegisteredCommandForTestAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var dispatchEntry = ResolveCommand(request.Command); + if (dispatchEntry is null) + { + return new NodeInvokeResponse + { + Id = request.Id, + Ok = false, + Error = $"Command not supported: {request.Command}", + }; + } + + var response = await dispatchEntry.Capability.ExecuteAsync(request, cancellationToken); + using var deliveryLease = + (dispatchEntry.Capability as INodeCapabilityDeliveryLeaseProvider)?.TryAcquireDeliveryLease(); + if (dispatchEntry.Capability is INodeCapabilityDeliveryLeaseProvider && deliveryLease is null) + throw new OperationCanceledException("Capability delivery authorization was revoked."); + if (deliveryLease is not null && !deliveryLease.TryBeginDelivery()) + throw new OperationCanceledException("Capability delivery authorization was revoked."); + response.Id = request.Id; + return response; } /// @@ -239,6 +299,9 @@ private void RebuildCommandMap() ref _commandMap, map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase)); } + + private CommandDispatchEntry? ResolveCommand(string command) => + Volatile.Read(ref _commandMap).GetValueOrDefault(command); /// /// Set a permission for the node @@ -545,7 +608,7 @@ await SendGatewayResultAndCompleteTelemetryAsync( }; // Find capability that can handle this command - var dispatchEntry = Volatile.Read(ref _commandMap).GetValueOrDefault(command); + var dispatchEntry = ResolveCommand(command); if (dispatchEntry == null) { @@ -593,17 +656,23 @@ await SendGatewayResultAndCompleteTelemetryAsync( () => ExecuteGatewayCapabilityAsync( request, capability, - response => SendNodeInvokeResultAsync( - requestId, - response.Ok, - response.Payload, - response.Error), + (response, deliveryLease) => SendNodeInvokeResultAsync( + requestId, + response.Ok, + response.Payload, + response.Error, + deliveryLease), error => SendNodeInvokeResultAsync(requestId, false, null, error), invocation!), CancellationToken.None); } - private async Task SendNodeInvokeResultAsync(string requestId, bool success, object? payload, string? error) + private async Task SendNodeInvokeResultAsync( + string requestId, + bool success, + object? payload, + string? error, + INodeCapabilityDeliveryLease? deliveryLease = null) { // Gateway expects: id (not requestId), nodeId, ok, payload (not result) var response = new @@ -623,7 +692,7 @@ private async Task SendNodeInvokeResultAsync(string requestId, bool success, obj var json = JsonSerializer.Serialize(response, s_ignoreNullOptions); _logger.Info($"[NODE] Sending invoke result for {requestId}: ok={success}"); - await SendRawAsync(json); + return await SendAuthorizedGatewayResponseAsync(json, deliveryLease).ConfigureAwait(false); } private async Task HandleConnectChallengeAsync(JsonElement root) @@ -684,11 +753,12 @@ private async Task SendNodeConnectAsync(string? nonce, long? challengeTimestampM var (auth, tokenForSig) = BuildConnectAuth(); var authType = auth.ContainsKey("deviceToken") ? "deviceToken" : auth.ContainsKey("bootstrapToken") ? "bootstrapToken" : "token"; + var catalog = Volatile.Read(ref _handshakeCatalog); _logger.Info($"[HANDSHAKE] → Sending connect:"); _logger.Info($"[HANDSHAKE] role=node, clientId={ClientId}, mode=node"); - _logger.Info($"[HANDSHAKE] caps={_registration.Capabilities.Count}: [{string.Join(", ", _registration.Capabilities)}]"); - _logger.Info($"[HANDSHAKE] commands={_registration.Commands.Count}: [{string.Join(", ", _registration.Commands)}]"); + _logger.Info($"[HANDSHAKE] caps={catalog.Capabilities.Length}: [{string.Join(", ", catalog.Capabilities)}]"); + _logger.Info($"[HANDSHAKE] commands={catalog.Commands.Length}: [{string.Join(", ", catalog.Commands)}]"); _logger.Info($"[HANDSHAKE] isBootstrap={usingBootstrap}, hasNodeDeviceToken={isPaired}"); _logger.Info($"[HANDSHAKE] deviceId={_deviceIdentity.DeviceId[..Math.Min(16, _deviceIdentity.DeviceId.Length)]}..."); _logger.Info($"[HANDSHAKE] nonce={nonce?[..Math.Min(15, nonce?.Length ?? 0)]}..."); @@ -697,13 +767,23 @@ private async Task SendNodeConnectAsync(string? nonce, long? challengeTimestampM var requestId = Guid.NewGuid().ToString(); Volatile.Write(ref _pendingConnectRequestId, requestId); - await SendRawAsync(BuildNodeConnectMessage(nonce, challengeTimestampMs, requestId)); + await SendRawAsync(BuildNodeConnectMessageFromCatalog(nonce, challengeTimestampMs, requestId, catalog)); } private string BuildNodeConnectMessage( string? nonce, long? challengeTimestampMs, - string? requestId = null) + string? requestId = null) => BuildNodeConnectMessageFromCatalog( + nonce, + challengeTimestampMs, + requestId, + Volatile.Read(ref _handshakeCatalog)); + + private string BuildNodeConnectMessageFromCatalog( + string? nonce, + long? challengeTimestampMs, + string? requestId, + HandshakeCatalog catalog) { // Sign the full payload with Ed25519 - this is how device pairing works string? signature = null; @@ -750,8 +830,8 @@ private string BuildNodeConnectMessage( }, role = "node", scopes = Array.Empty(), - caps = _registration.Capabilities, - commands = _registration.Commands, + caps = catalog.Capabilities, + commands = catalog.Commands, permissions = _registration.Permissions, auth, locale = "en-US", @@ -1274,7 +1354,7 @@ await SendGatewayResultAndCompleteTelemetryAsync( }; // Find capability that can handle this command - var dispatchEntry = Volatile.Read(ref _commandMap).GetValueOrDefault(command); + var dispatchEntry = ResolveCommand(command); if (dispatchEntry == null) { @@ -1327,7 +1407,7 @@ await SendGatewayResultAndCompleteTelemetryAsync( private async Task ExecuteGatewayCapabilityAsync( NodeInvokeRequest request, INodeCapability capability, - Func sendResponse, + Func> sendResponse, Func sendErrorResponse, InvocationCancellationRegistry.InvocationCancellation invocation) { @@ -1347,6 +1427,22 @@ private async Task ExecuteGatewayCapabilityAsync( var response = await capability.ExecuteAsync(request, cancellationToken); response.Id = request.Id; + using var deliveryLease = + (capability as INodeCapabilityDeliveryLeaseProvider)?.TryAcquireDeliveryLease(); + if (capability is INodeCapabilityDeliveryLeaseProvider && deliveryLease is null) + { + activeInvocation.TryComplete(); + NodeToolInvocation.CompleteChild( + executeActivity, + NodeToolOutcome.Canceled, + NodeToolErrorCategory.Other); + CompleteToolTelemetry( + telemetry, + NodeToolOutcome.Canceled, + NodeToolErrorCategory.Other); + return; + } + if (!activeInvocation.TryComplete()) { if (activeInvocation.CancelledByCaller) @@ -1389,7 +1485,14 @@ await SendCancellationResponseAndCompleteTelemetryAsync( try { - await sendResponse(response); + if (!await sendResponse(response, deliveryLease)) + { + CompleteToolTelemetry( + telemetry, + NodeToolOutcome.Canceled, + NodeToolErrorCategory.Other); + return; + } CompleteToolTelemetry( telemetry, outcome, @@ -1666,6 +1769,11 @@ private sealed record CommandDispatchEntry( INodeCapability Capability, string CanonicalName); + internal sealed record HandshakeCatalog(string[] Capabilities, string[] Commands) + { + public static HandshakeCatalog Empty { get; } = new([], []); + } + private void RaiseInvokeCompleted(string requestId, string command, bool ok, string? error, TimeSpan duration) { var handlers = InvokeCompleted; @@ -1697,7 +1805,9 @@ private void RaiseInvokeCompleted(string requestId, string command, bool ok, str } } - private async Task SendInvokeResponseAsync(NodeInvokeResponse response) + private async Task SendInvokeResponseAsync( + NodeInvokeResponse response, + INodeCapabilityDeliveryLease? deliveryLease) { var msg = new { @@ -1708,9 +1818,31 @@ private async Task SendInvokeResponseAsync(NodeInvokeResponse response) error = response.Ok ? null : new { message = response.Error } }; - await SendRawAsync(JsonSerializer.Serialize(msg, s_ignoreNullOptions)); + if (!await SendAuthorizedGatewayResponseAsync( + JsonSerializer.Serialize(msg, s_ignoreNullOptions), + deliveryLease).ConfigureAwait(false)) + return false; _logger.Info($"Sent invoke response: ok={response.Ok}"); + return true; + } + + private async Task SendAuthorizedGatewayResponseAsync( + string message, + INodeCapabilityDeliveryLease? deliveryLease) + { + if (deliveryLease is null) + { + await SendRawAsync(message).ConfigureAwait(false); + return true; + } + + var authorizationDenied = false; + await SendRawAsync( + message, + deliveryLease.TryBeginDelivery, + () => authorizationDenied = true).ConfigureAwait(false); + return !authorizationDenied; } private async Task SendErrorResponseAsync(string requestId, string error) diff --git a/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs b/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs index 2769d1cb0..6d6ce2d3a 100644 --- a/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs +++ b/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs @@ -18,7 +18,7 @@ public static bool Register(string trayExecutablePath, string taskName = TaskNam public static bool Exists(string taskName = TaskName) => Run(CreateQueryProcessStartInfo(taskName)); - internal static ProcessStartInfo CreateRegisterProcessStartInfo(string trayExecutablePath, string taskName = TaskName) + public static ProcessStartInfo CreateRegisterProcessStartInfo(string trayExecutablePath, string taskName = TaskName) { ArgumentException.ThrowIfNullOrWhiteSpace(taskName); var fullPath = Path.GetFullPath(trayExecutablePath); @@ -30,7 +30,7 @@ internal static ProcessStartInfo CreateRegisterProcessStartInfo(string trayExecu "/F"); } - internal static ProcessStartInfo CreateUnregisterProcessStartInfo(string taskName = TaskName) + public static ProcessStartInfo CreateUnregisterProcessStartInfo(string taskName = TaskName) { ArgumentException.ThrowIfNullOrWhiteSpace(taskName); return CreateStartInfo( @@ -39,7 +39,7 @@ internal static ProcessStartInfo CreateUnregisterProcessStartInfo(string taskNam "/F"); } - internal static ProcessStartInfo CreateQueryProcessStartInfo(string taskName = TaskName) + public static ProcessStartInfo CreateQueryProcessStartInfo(string taskName = TaskName) { ArgumentException.ThrowIfNullOrWhiteSpace(taskName); return CreateStartInfo( diff --git a/src/OpenClaw.Shared/WslShellQuoting.cs b/src/OpenClaw.Shared/WslShellQuoting.cs index e9b2a0678..984552826 100644 --- a/src/OpenClaw.Shared/WslShellQuoting.cs +++ b/src/OpenClaw.Shared/WslShellQuoting.cs @@ -27,7 +27,7 @@ namespace OpenClaw.Shared; /// bytes between the quotes are preserved verbatim, which is what makes the /// result injection-safe for arbitrary values (URLs, JSON, paths, newlines). /// -internal static class WslShellQuoting +public static class WslShellQuoting { // Close quote ('), an escaped literal quote (\'), then reopen quote ('). private const string EscapedSingleQuote = "'\\''"; @@ -37,7 +37,7 @@ internal static class WslShellQuoting /// value the caller will wrap in its own outer single quotes. Does NOT add /// outer quotes. An empty input yields an empty string. /// - internal static string EscapePosixSingleQuoteInner(string value) + public static string EscapePosixSingleQuoteInner(string value) { ArgumentNullException.ThrowIfNull(value); return value.Replace("'", EscapedSingleQuote); @@ -48,7 +48,7 @@ internal static string EscapePosixSingleQuoteInner(string value) /// single quotes, so the result is exactly one POSIX-shell token. An empty /// input yields '' (an empty argument, not an omitted one). /// - internal static string QuotePosixSingleQuote(string value) + public static string QuotePosixSingleQuote(string value) { ArgumentNullException.ThrowIfNull(value); return string.Concat("'", EscapePosixSingleQuoteInner(value), "'"); diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index d317e399b..618c1f007 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3547,6 +3547,7 @@ private void OnSettingsSaved(object? sender, EventArgs e) break; case SettingsChangeImpact.CapabilityReload: + _nodeService?.RefreshCodexSessionAccess(); ReconnectWithSyncedBrowserProxyForward(); break; diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index 331d2a5c0..94e4068df 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -273,6 +273,61 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" Margin="4,0,4,4"/> + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs index 71936584c..294f4aaff 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs @@ -70,6 +70,7 @@ private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEve { _viewModel.SavedIndicated -= OnViewModelSavedIndicated; _viewModel.ExternalChanged -= OnViewModelExternalChanged; + _viewModel.PropertyChanged -= OnViewModelPropertyChanged; } _viewModel = args.NewValue as SettingsPageViewModel; @@ -78,9 +79,34 @@ private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEve { _viewModel.SavedIndicated += OnViewModelSavedIndicated; _viewModel.ExternalChanged += OnViewModelExternalChanged; + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + RefreshCodexSessionAccessStatus(); } } + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(SettingsPageViewModel.IsCodexAccessOff) + or nameof(SettingsPageViewModel.IsCodexCatalogAvailable) + or nameof(SettingsPageViewModel.IsCodexCatalogUnavailable) + or nameof(SettingsPageViewModel.IsCodexSteeringUnavailable)) + { + RefreshCodexSessionAccessStatus(); + } + } + + private void RefreshCodexSessionAccessStatus() + { + CodexAccessOffStatus.Visibility = _viewModel?.IsCodexAccessOff == true + ? Visibility.Visible : Visibility.Collapsed; + CodexCatalogAvailableStatus.Visibility = _viewModel?.IsCodexCatalogAvailable == true + ? Visibility.Visible : Visibility.Collapsed; + CodexCatalogUnavailableStatus.Visibility = _viewModel?.IsCodexCatalogUnavailable == true + ? Visibility.Visible : Visibility.Collapsed; + CodexSteeringUnavailableStatus.Visibility = _viewModel?.IsCodexSteeringUnavailable == true + ? Visibility.Visible : Visibility.Collapsed; + } + private void OnViewModelSavedIndicated(object? sender, EventArgs e) => ShowSavedIndicator(); /// diff --git a/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs b/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs index 7bc805150..c70f7a2ce 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs @@ -1,3 +1,5 @@ +using OpenClaw.Shared.Codex; + namespace OpenClawTray.Presentation; /// @@ -28,6 +30,12 @@ public interface ISettingsStore /// void Update(Action edit); + /// + /// Persists the interactive Codex permission and returns whether the durable write + /// succeeded. A failed write restores the previous in-memory permission. + /// + bool TryUpdateCodexSessionAccess(CodexSessionAccessMode mode); + /// /// Marks the calling thread as performing a store-originated write for the scope's lifetime, /// so a raised on that thread is treated as self-originated @@ -75,6 +83,7 @@ public interface ISettingsEditor bool CameraRecordingConsentGiven { set; } bool ShowChatToolCalls { set; } + CodexSessionAccessMode CodexSessionAccess { set; } } /// @@ -106,4 +115,5 @@ public sealed record SettingsSnapshot public bool CameraRecordingConsentGiven { get; init; } public bool ShowChatToolCalls { get; init; } + public CodexSessionAccessMode CodexSessionAccess { get; init; } } diff --git a/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs index b430c976e..bbe7c057f 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using OpenClaw.Shared.Codex; using OpenClawTray.Services; namespace OpenClawTray.Presentation; @@ -31,6 +32,7 @@ internal sealed class SettingsPageViewModel : INavigationAware, IDisposable, INo private readonly ISettingsStore _store; private readonly IAppCommands _appCommands; + private readonly Func _codexExecutableAvailable; private bool _loading; private bool _subscribed; @@ -53,11 +55,23 @@ internal sealed class SettingsPageViewModel : INavigationAware, IDisposable, INo private bool _screenRecordingConsentGiven; private bool _cameraRecordingConsentGiven; private bool _showChatToolCalls; + private CodexSessionAccessMode _codexSessionAccess; + private bool _isCodexExecutableAvailable; public SettingsPageViewModel(ISettingsStore store, IAppCommands appCommands) + : this(store, appCommands, () => new CodexExecutableResolver().Resolve() is not null) + { + } + + internal SettingsPageViewModel( + ISettingsStore store, + IAppCommands appCommands, + Func codexExecutableAvailable) { _store = store ?? throw new ArgumentNullException(nameof(store)); _appCommands = appCommands ?? throw new ArgumentNullException(nameof(appCommands)); + _codexExecutableAvailable = codexExecutableAvailable + ?? throw new ArgumentNullException(nameof(codexExecutableAvailable)); } public event PropertyChangedEventHandler? PropertyChanged; @@ -220,6 +234,62 @@ public bool ShowChatToolCalls } } + public CodexSessionAccessMode CodexSessionAccess + { + get => _codexSessionAccess; + set + { + if (!Enum.IsDefined(value)) + { + return; + } + + var previous = _codexSessionAccess; + if (SetField(ref _codexSessionAccess, value)) + { + RaiseCodexStatusChanged(); + OnPropertyChanged(nameof(CodexSessionAccessIndex)); + if (!_loading) + { + if (_store.TryUpdateCodexSessionAccess(value)) + { + _appCommands.NotifySettingsSaved(); + RaiseSaved(); + } + else + { + SetField(ref _codexSessionAccess, previous); + RaiseCodexStatusChanged(); + OnPropertyChanged(nameof(CodexSessionAccessIndex)); + } + } + } + } + } + + public int CodexSessionAccessIndex + { + get => (int)CodexSessionAccess; + set + { + if (Enum.IsDefined(typeof(CodexSessionAccessMode), value)) + { + CodexSessionAccess = (CodexSessionAccessMode)value; + } + } + } + + public bool IsCodexAccessOff => CodexSessionAccess == CodexSessionAccessMode.Off; + + public bool IsCodexCatalogAvailable => + !IsCodexAccessOff && _isCodexExecutableAvailable; + + public bool IsCodexCatalogUnavailable => + !IsCodexAccessOff && !_isCodexExecutableAvailable; + + public bool IsCodexSteeringUnavailable => + CodexSessionAccess == CodexSessionAccessMode.ReadAndSteer; + public void Activate(object? parameter) { IsActive = true; @@ -280,6 +350,11 @@ private void LoadFromStore() SetField(ref _screenRecordingConsentGiven, s.ScreenRecordingConsentGiven, nameof(ScreenRecordingConsentGiven)); SetField(ref _cameraRecordingConsentGiven, s.CameraRecordingConsentGiven, nameof(CameraRecordingConsentGiven)); SetField(ref _showChatToolCalls, s.ShowChatToolCalls, nameof(ShowChatToolCalls)); + var modeChanged = _codexSessionAccess != s.CodexSessionAccess; + _isCodexExecutableAvailable = _codexExecutableAvailable(); + CodexSessionAccess = s.CodexSessionAccess; + if (!modeChanged) + RaiseCodexStatusChanged(); } finally { @@ -326,4 +401,15 @@ private bool SetField(ref T field, T value, [CallerMemberName] string? proper PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); return true; } + + private void RaiseCodexStatusChanged() + { + OnPropertyChanged(nameof(IsCodexAccessOff)); + OnPropertyChanged(nameof(IsCodexCatalogAvailable)); + OnPropertyChanged(nameof(IsCodexCatalogUnavailable)); + OnPropertyChanged(nameof(IsCodexSteeringUnavailable)); + } + + private void OnPropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } diff --git a/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs b/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs index 5600d73a7..23b172368 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs @@ -33,27 +33,28 @@ public SettingsStore(SettingsManager settings, IUiDispatcher dispatcher) public event EventHandler? Changed; - public SettingsSnapshot Current => new() + public SettingsSnapshot Current => _settings.ReadLocked(settings => new SettingsSnapshot { - AutoStart = _settings.AutoStart, - GlobalHotkeyEnabled = _settings.GlobalHotkeyEnabled, - UseLegacyWebChat = _settings.UseLegacyWebChat, - ShowNotifications = _settings.ShowNotifications, - NotificationSound = _settings.NotificationSound, - AppTheme = _settings.AppTheme, - ShowDiagnosticsEffective = _settings.ShowDiagnosticsEffective, - NotifyHealth = _settings.NotifyHealth, - NotifyUrgent = _settings.NotifyUrgent, - NotifyReminder = _settings.NotifyReminder, - NotifyEmail = _settings.NotifyEmail, - NotifyCalendar = _settings.NotifyCalendar, - NotifyBuild = _settings.NotifyBuild, - NotifyStock = _settings.NotifyStock, - NotifyInfo = _settings.NotifyInfo, - ScreenRecordingConsentGiven = _settings.ScreenRecordingConsentGiven, - CameraRecordingConsentGiven = _settings.CameraRecordingConsentGiven, - ShowChatToolCalls = _settings.ShowChatToolCalls, - }; + AutoStart = settings.AutoStart, + GlobalHotkeyEnabled = settings.GlobalHotkeyEnabled, + UseLegacyWebChat = settings.UseLegacyWebChat, + ShowNotifications = settings.ShowNotifications, + NotificationSound = settings.NotificationSound, + AppTheme = settings.AppTheme, + ShowDiagnosticsEffective = settings.ShowDiagnosticsEffective, + NotifyHealth = settings.NotifyHealth, + NotifyUrgent = settings.NotifyUrgent, + NotifyReminder = settings.NotifyReminder, + NotifyEmail = settings.NotifyEmail, + NotifyCalendar = settings.NotifyCalendar, + NotifyBuild = settings.NotifyBuild, + NotifyStock = settings.NotifyStock, + NotifyInfo = settings.NotifyInfo, + ScreenRecordingConsentGiven = settings.ScreenRecordingConsentGiven, + CameraRecordingConsentGiven = settings.CameraRecordingConsentGiven, + ShowChatToolCalls = settings.ShowChatToolCalls, + CodexSessionAccess = settings.CodexSessionAccess, + }); public void Update(Action edit) { @@ -61,8 +62,15 @@ public void Update(Action edit) using (BeginSelfWrite()) { - edit(new Editor(_settings)); - _settings.Save(); + _settings.UpdateAndSave(settings => edit(new Editor(settings))); + } + } + + public bool TryUpdateCodexSessionAccess(OpenClaw.Shared.Codex.CodexSessionAccessMode mode) + { + using (BeginSelfWrite()) + { + return _settings.TryUpdateAndSave(settings => settings.CodexSessionAccess = mode); } } @@ -133,5 +141,6 @@ private sealed class Editor : ISettingsEditor public bool ScreenRecordingConsentGiven { set => _settings.ScreenRecordingConsentGiven = value; } public bool CameraRecordingConsentGiven { set => _settings.CameraRecordingConsentGiven = value; } public bool ShowChatToolCalls { set => _settings.ShowChatToolCalls = value; } + public OpenClaw.Shared.Codex.CodexSessionAccessMode CodexSessionAccess { set => _settings.CodexSessionAccess = value; } } } diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityRegistry.cs b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityRegistry.cs new file mode 100644 index 000000000..2c74884e9 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityRegistry.cs @@ -0,0 +1,373 @@ +using System.Collections.ObjectModel; +using OpenClaw.Shared; +using OpenClaw.Shared.Capabilities; +using OpenClaw.Shared.Codex; + +namespace OpenClawTray.Services; + +/// +/// Canonical immutable capability snapshot shared by the gateway and MCP transports. +/// NodeService creates and wires UI-bound capabilities; this owner decides which +/// capabilities are advertised and publishes each rebuild atomically. +/// +public sealed class NodeCapabilityRegistry +{ + private readonly object _gate = new(); + private readonly Func _codexCapabilityFactory; + private CodexAccessGeneration? _codexAccessGeneration; + private IReadOnlyList _sharedSnapshot = Array.Empty(); + private IReadOnlyList _mcpOnlySnapshot = Array.Empty(); + + public NodeCapabilityRegistry(IOpenClawLogger logger) + : this( + logger, + () => new CodexExecutableResolver().Resolve(), + new CodexAppServerProcessFactory()) + { + } + + internal NodeCapabilityRegistry( + IOpenClawLogger logger, + Func codexLaunchPlanResolver, + ICodexAppServerProcessFactory codexProcessFactory) + : this(() => CreateCodexCapability( + logger, + codexLaunchPlanResolver, + codexProcessFactory)) + { + ArgumentNullException.ThrowIfNull(codexLaunchPlanResolver); + ArgumentNullException.ThrowIfNull(codexProcessFactory); + } + + internal NodeCapabilityRegistry(Func codexCapabilityFactory) + { + _codexCapabilityFactory = codexCapabilityFactory + ?? throw new ArgumentNullException(nameof(codexCapabilityFactory)); + } + + public IReadOnlyList Rebuild( + IEnumerable capabilities, + CodexSessionAccessMode codexAccess) + { + ArgumentNullException.ThrowIfNull(capabilities); + + lock (_gate) + { + var rebuilt = capabilities.ToList(); + INodeCapability? codex = null; + if (codexAccess is CodexSessionAccessMode.ReadOnly or CodexSessionAccessMode.ReadAndSteer) + codex = _codexCapabilityFactory(); + + RevokeCodexAccessNoLock(); + if (codex is not null) + rebuilt.Add(CreateRevocableCodexCapabilityNoLock(codex)); + + var snapshot = Freeze(rebuilt); + _sharedSnapshot = snapshot; + return snapshot; + } + } + + public void Clear() + { + lock (_gate) + { + RevokeCodexAccessNoLock(); + _sharedSnapshot = Array.Empty(); + } + } + + public IReadOnlyList RefreshCodexSessionAccess( + CodexSessionAccessMode codexAccess, + WindowsNodeClient? client, + IOpenClawLogger logger) + { + IReadOnlyList snapshot; + lock (_gate) + { + RevokeCodexAccessNoLock(); + var refreshed = _sharedSnapshot + .Where(capability => !string.Equals( + capability.Category, + "codex-app-server-threads", + StringComparison.Ordinal)) + .ToList(); + if (codexAccess is CodexSessionAccessMode.ReadOnly or CodexSessionAccessMode.ReadAndSteer) + { + var codex = _codexCapabilityFactory(); + if (codex is not null) + refreshed.Add(CreateRevocableCodexCapabilityNoLock(codex)); + } + + snapshot = Freeze(refreshed); + _sharedSnapshot = snapshot; + } + + RegisterGateway(client, logger); + return snapshot; + } + + public IReadOnlyList GetGatewaySnapshot() + { + lock (_gate) + return _sharedSnapshot; + } + + public IReadOnlyList GetMcpSnapshot() + { + lock (_gate) + { + if (_mcpOnlySnapshot.Count == 0) + return _sharedSnapshot; + + return Freeze(_sharedSnapshot.Concat(_mcpOnlySnapshot)); + } + } + + public void RegisterMcpOnly(INodeCapability capability) + { + ArgumentNullException.ThrowIfNull(capability); + lock (_gate) + _mcpOnlySnapshot = Freeze(_mcpOnlySnapshot.Append(capability)); + } + + public void RegisterGateway(WindowsNodeClient? client, IOpenClawLogger logger) + { + if (client is null) + return; + + var gatewayCapabilities = new List(); + foreach (var capability in GetGatewaySnapshot()) + { + if (IsLocalOnly(capability)) + { + logger.Warn($"Capability {capability.Category} contains local-only commands and will not be registered with the gateway node transport."); + continue; + } + + gatewayCapabilities.Add(capability); + } + client.ReplaceCapabilities(gatewayCapabilities); + } + + private static bool IsLocalOnly(INodeCapability capability) => + capability.Commands.Any(command => + command.StartsWith("app.connection.", StringComparison.OrdinalIgnoreCase)); + + private static IReadOnlyList Freeze(IEnumerable capabilities) => + new ReadOnlyCollection(capabilities.ToArray()); + + private INodeCapability CreateRevocableCodexCapabilityNoLock(INodeCapability capability) + { + _codexAccessGeneration = new CodexAccessGeneration(); + return new RevocableCodexSessionCapability(capability, _codexAccessGeneration); + } + + private void RevokeCodexAccessNoLock() + { + _codexAccessGeneration?.Revoke(); + _codexAccessGeneration = null; + } + + private static INodeCapability? CreateCodexCapability( + IOpenClawLogger logger, + Func launchPlanResolver, + ICodexAppServerProcessFactory processFactory) + { + var launchPlan = launchPlanResolver(); + return launchPlan is null + ? null + : new DeferredCodexSessionCapability(logger, launchPlan, processFactory); + } + + private sealed class DeferredCodexSessionCapability( + IOpenClawLogger logger, + CodexLaunchPlan launchPlan, + ICodexAppServerProcessFactory processFactory) : NodeCapabilityBase(logger) + { + private static readonly IReadOnlyList ReadCommands = Array.AsReadOnly( + [ + CodexSessionCapability.ThreadsListCommand, + CodexSessionCapability.ThreadsHistoryListCommand, + CodexSessionCapability.ThreadTurnsListCommand, + ]); + + public override string Category => "codex-app-server-threads"; + + public override IReadOnlyList Commands => ReadCommands; + + public override Task ExecuteAsync(NodeInvokeRequest request) => + ExecuteAsync(request, CancellationToken.None); + + public override async Task ExecuteAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + try + { + await using var client = await CodexAppServerClient.ConnectCatalogAsync( + launchPlan, + processFactory, + cancellationToken).ConfigureAwait(false); + var capability = new CodexSessionCapability( + Logger, + new CodexSessionCatalogService(client)); + return await capability.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return Error(request.Command == CodexSessionCapability.ThreadTurnsListCommand + ? "Codex app-server transcript is unavailable" + : "Codex app-server catalog is unavailable"); + } + } + } + + private sealed class RevocableCodexSessionCapability( + INodeCapability inner, + CodexAccessGeneration accessGeneration) : INodeCapability, INodeCapabilityDeliveryLeaseProvider + { + public string Category => inner.Category; + + public IReadOnlyList Commands => inner.Commands; + + public bool CanHandle(string command) => inner.CanHandle(command); + + public Task ExecuteAsync(NodeInvokeRequest request) => + ExecuteAsync(request, CancellationToken.None); + + public async Task ExecuteAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + using var accessExecution = accessGeneration.BeginExecution(); + using var execution = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + accessExecution.Token); + var response = await inner.ExecuteAsync(request, execution.Token).ConfigureAwait(false); + accessExecution.Token.ThrowIfCancellationRequested(); + return response; + } + + public INodeCapabilityDeliveryLease? TryAcquireDeliveryLease() => + accessGeneration.TryAcquireDeliveryLease(); + } + + private sealed class CodexAccessGeneration + { + private readonly object _gate = new(); + private readonly CancellationTokenSource _cancellation = new(); + private readonly CancellationToken _token; + private bool _revoked; + private bool _disposed; + private int _activeExecutions; + private int _activeDeliveries; + + public CodexAccessGeneration() + { + _token = _cancellation.Token; + } + + public ExecutionLease BeginExecution() + { + lock (_gate) + { + if (_revoked) + throw new OperationCanceledException(_token); + _activeExecutions++; + return new ExecutionLease(this, _token); + } + } + + public INodeCapabilityDeliveryLease? TryAcquireDeliveryLease() + { + lock (_gate) + { + if (_revoked) + return null; + _activeDeliveries++; + return new DeliveryLease(this); + } + } + + public void Revoke() + { + lock (_gate) + { + if (_revoked) + return; + _revoked = true; + } + + _cancellation.Cancel(); + + lock (_gate) + DisposeIfRetiredNoLock(); + } + + private void EndExecution() + { + lock (_gate) + { + _activeExecutions--; + DisposeIfRetiredNoLock(); + } + } + + private void EndDelivery() + { + lock (_gate) + { + _activeDeliveries--; + DisposeIfRetiredNoLock(); + } + } + + private void DisposeIfRetiredNoLock() + { + if (_revoked && !_disposed && _activeExecutions == 0 && _activeDeliveries == 0) + { + _disposed = true; + _cancellation.Dispose(); + } + } + + internal sealed class ExecutionLease : IDisposable + { + private CodexAccessGeneration? _owner; + + public ExecutionLease(CodexAccessGeneration owner, CancellationToken token) + { + _owner = owner; + Token = token; + } + + public CancellationToken Token { get; } + + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.EndExecution(); + } + + private sealed class DeliveryLease(CodexAccessGeneration owner) : INodeCapabilityDeliveryLease + { + private CodexAccessGeneration? _owner = owner; + + public bool TryBeginDelivery() + { + var owner = Volatile.Read(ref _owner); + return owner is not null && owner.IsDeliveryStillAuthorized(); + } + + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.EndDelivery(); + } + + private bool IsDeliveryStillAuthorized() + { + lock (_gate) + return !_revoked; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index b006952cf..b686930de 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -108,21 +108,9 @@ public sealed class NodeService : IDisposable, IAsyncDisposable private readonly Func>? _browserControlAuthorization; private string? _token; - // Authoritative capability list — populated by RegisterCapabilities and - // shared with both the gateway client (when present) and the MCP bridge. - // Holding it here lets MCP-only mode skip the gateway client entirely. - // - // Mutated on the UI thread (Connect / StartLocalOnly / Disconnect rebuild - // it); read by the MCP bridge on threadpool threads (every tools/list and - // tools/call). Every read/write goes through _capabilitiesLock so a - // bridge snapshot can't race a re-register. - private readonly List _capabilities = new(); - private readonly object _capabilitiesLock = new(); - - // MCP-only capabilities — visible to local MCP clients but NOT registered - // on the gateway WebSocket. Used for app-level testing/control tools that - // should not be callable by remote agents. - private readonly List _mcpOnlyCapabilities = new(); + // Canonical capability storage and transport publication live in the + // registry. NodeService retains capability construction and UI wiring. + private readonly NodeCapabilityRegistry _capabilityRegistry; // Serializes AttachClient ↔ DisconnectAsync so a reconnect that overlaps a // disconnect can't leave stale subscriptions on an old client or double- @@ -222,6 +210,7 @@ public NodeService( _browserControlAuthorization = browserControlAuthorization; _execApprovalsStore = execApprovalsStore; _settings = settings; + _capabilityRegistry = new NodeCapabilityRegistry(logger); _enableMcpServer = enableMcpServer; _screenCaptureService = new ScreenCaptureService(logger); _screenRecordingService = new ScreenRecordingService(logger); @@ -283,7 +272,7 @@ public async Task DisconnectAsync() DetachClientHandlers(previous); } - lock (_capabilitiesLock) { _capabilities.Clear(); } + _capabilityRegistry.Clear(); // Close canvas window if (_canvasWindow != null && !_canvasWindow.IsClosed) @@ -301,13 +290,7 @@ public async Task DisconnectAsync() private void RegisterCapabilities() { - // Hold the lock across the entire rebuild. The body is sync construction - // (no awaits), so the lock is held briefly and an MCP tools/list arriving - // mid-rebuild waits for a consistent snapshot rather than seeing a half- - // populated list. - lock (_capabilitiesLock) - { - _capabilities.Clear(); + var capabilities = new List(); // System capability (notifications + command execution). The // "Run system tools" toggle gates the run/run.prepare commands @@ -326,7 +309,7 @@ private void RegisterCapabilities() _systemCapability.SetApprovalsStore(_execApprovalsStore); _systemCapability.SetV2Handler(_execApprovalsV2Handler ?? ExecApprovalV2NullHandler.Instance); - Register(_systemCapability); + capabilities.Add(_systemCapability); if (NodeCapabilityGating.ShouldRegisterCanvas(_settings)) { @@ -340,7 +323,7 @@ private void RegisterCapabilities() _canvasCapability.A2UIResetRequested += OnCanvasA2UIReset; _canvasCapability.A2UIDumpRequested += OnCanvasA2UIDumpAsync; _canvasCapability.CapsRequested += OnCanvasCapsAsync; - Register(_canvasCapability); + capabilities.Add(_canvasCapability); } if (NodeCapabilityGating.ShouldRegisterScreen(_settings)) @@ -348,7 +331,7 @@ private void RegisterCapabilities() _screenCapability = new ScreenCapability(_logger); _screenCapability.CaptureRequested += OnScreenCapture; _screenCapability.RecordRequested += OnScreenRecord; - Register(_screenCapability); + capabilities.Add(_screenCapability); } if (NodeCapabilityGating.ShouldRegisterCamera(_settings)) @@ -357,14 +340,14 @@ private void RegisterCapabilities() _cameraCapability.ListRequested += OnCameraList; _cameraCapability.SnapRequested += OnCameraSnap; _cameraCapability.ClipRequested += OnCameraClip; - Register(_cameraCapability); + capabilities.Add(_cameraCapability); } if (NodeCapabilityGating.ShouldRegisterLocation(_settings)) { _locationCapability = new LocationCapability(_logger); _locationCapability.GetRequested += async (args) => await GetLocationAsync(args); - Register(_locationCapability); + capabilities.Add(_locationCapability); } if (NodeCapabilityGating.ShouldRegisterTts(_settings)) @@ -374,7 +357,7 @@ private void RegisterCapabilities() _ttsCapability = new TtsCapability(_logger); _ttsCapability.SpeakRequested += OnTtsSpeakAsync; _ttsCapability.StatusRequested += OnTtsStatusAsync; - Register(_ttsCapability); + capabilities.Add(_ttsCapability); } if (NodeCapabilityGating.ShouldRegisterStt(_settings)) @@ -392,7 +375,7 @@ private void RegisterCapabilities() _sttCapability.TranscribeRequested += OnSttTranscribeAsync; _sttCapability.ListenRequested += OnSttListenAsync; _sttCapability.StatusRequested += OnSttStatusAsync; - Register(_sttCapability); + capabilities.Add(_sttCapability); } // Device metadata/status capability - dispose previous provider on re-registration @@ -400,7 +383,7 @@ private void RegisterCapabilities() _deviceStatusProvider = new DeviceStatusProvider(_logger); _deviceStatusProvider.StartCpuSampling(); _deviceCapability = new DeviceCapability(_logger, _deviceStatusProvider); - Register(_deviceCapability); + capabilities.Add(_deviceCapability); // BrowserProxy talks to the HTTP/browser-control surface, which expects // the shared gateway token rather than the node WebSocket device token. @@ -438,7 +421,7 @@ private void RegisterCapabilities() sshTunnelLocalPort: tunnelState.LocalPort, allowGatewayPortFallback: tunnelState.AllowGatewayPortFallback, authorizeEndpointAsync: _browserControlAuthorization); - Register(_browserProxyCapability); + capabilities.Add(_browserProxyCapability); } else if (browserProxyBlock != BrowserProxyActivation.RegistrationBlock.ToggleDisabled) { @@ -456,43 +439,30 @@ private void RegisterCapabilities() _nodeClient.SetPermission("screen.record", true); } - _logger.Info($"Capabilities registered: {string.Join(", ", _capabilities.Select(c => c.Category).Distinct(StringComparer.OrdinalIgnoreCase))} ({_capabilities.Count} caps)"); - } // end lock + var snapshot = _capabilityRegistry.Rebuild( + capabilities, + _settings?.CodexSessionAccess ?? OpenClaw.Shared.Codex.CodexSessionAccessMode.Off); + _capabilityRegistry.RegisterGateway(_nodeClient, _logger); + _logger.Info($"Capabilities registered: {string.Join(", ", snapshot.Select(c => c.Category).Distinct(StringComparer.OrdinalIgnoreCase))} ({snapshot.Count} caps)"); StartMcpServer(); } - /// - /// Register one capability with both NodeService and (when present) the - /// gateway client. Single seam so adding a new capability touches one - /// site and is exposed by every transport (gateway + MCP) automatically. - /// - private void Register(INodeCapability capability) - { - _capabilities.Add(capability); - if (IsLocalOnlyCapability(capability)) - { - _logger.Warn($"Capability {capability.Category} contains local-only commands and will not be registered with the gateway node transport."); - return; - } - - _nodeClient?.RegisterCapability(capability); - } - - private static bool IsLocalOnlyCapability(INodeCapability capability) => - capability.Commands.Any(command => - command.StartsWith("app.connection.", StringComparison.OrdinalIgnoreCase)); - /// /// Register a capability that is only visible to local MCP clients, not /// the gateway. Used for app-level testing/control tools. /// public void RegisterMcpOnlyCapability(INodeCapability capability) { - lock (_capabilitiesLock) - { - _mcpOnlyCapabilities.Add(capability); - } + _capabilityRegistry.RegisterMcpOnly(capability); + } + + public void RefreshCodexSessionAccess() + { + _capabilityRegistry.RefreshCodexSessionAccess( + _settings?.CodexSessionAccess ?? OpenClaw.Shared.Codex.CodexSessionAccessMode.Off, + _nodeClient, + _logger); } /// @@ -564,16 +534,11 @@ public void AttachClient(WindowsNodeClient client, string? bearerToken = null) _canvasWindow.SetTrustedGatewayOrigin(gatewayUrl, token, configuredGatewayUrl); }); - bool capabilitiesBuilt; - lock (_capabilitiesLock) - { - capabilitiesBuilt = _capabilities.Count > 0; - } - - _logger.Info($"[NodeService] AttachClient: capabilitiesBuilt={capabilitiesBuilt}, _capabilities.Count={_capabilities.Count}"); + var capabilitiesBuilt = _capabilityRegistry.GetGatewaySnapshot().Count > 0; + _logger.Info($"[NodeService] AttachClient: capabilitiesBuilt={capabilitiesBuilt}"); // Always rebuild from current settings. The previous reconnect path - // re-registered the cached _capabilities instances, but _capabilities + // re-registered cached capability instances, but the registry snapshot // is only cleared in DisconnectAsync — which is never invoked on the // reconnect path used by App.OnSettingsSaved (CapabilityReload calls // ReconnectAsync, not DisconnectAsync). That left toggles like @@ -645,8 +610,8 @@ private ICommandRunner BuildSystemRunRunner() var hostRunner = new LocalCommandRunner(_logger); var executor = new DirectAppContainerExecutor(GetOrProbeMxcAvailability, _logger); - // Do NOT probe synchronously here: this runs while _capabilitiesLock is held - // (RegisterCapabilities), and a blocking wxc-exec --probe (~15s) would stall + // Do NOT probe synchronously here: this runs on the capability rebuild + // path, and a blocking wxc-exec --probe (~15s) would stall // capability registration / reconnect. Log from a non-blocking peek; the // first real probe happens lazily on the first system.run via the // availability gate / executor provider below (off any of our locks). @@ -810,7 +775,7 @@ private MxcAvailability ProbeAndStoreMxcAvailability() /// /// Non-blocking read of the cached MXC availability for diagnostics/logging. /// Returns null when nothing has been probed yet. Never spawns or waits on a - /// probe, so it is safe to call while holding other locks (e.g. _capabilitiesLock). + /// probe, so it is safe to call during capability construction. /// private MxcAvailability? PeekMxcAvailability() { @@ -834,25 +799,11 @@ private bool StartMcpServer() McpHttpServer? attempt = null; try { - // Bridge reads the live _capabilities list every tools/list, so any - // future Register(...) call is exposed via MCP automatically. - // MCP-only capabilities (e.g. AppCapability) are merged in so - // they appear in tools/list but never touch the gateway client. - // The snapshot takes the same lock RegisterCapabilities holds, - // so a tools/list arriving mid-rebuild observes either the old - // or the new set — never a partially-cleared list. + // The bridge reads an immutable registry snapshot for each request. + // MCP-only capabilities are merged by the registry and never reach + // the gateway client. var bridge = new McpToolBridge( - () => { - lock (_capabilitiesLock) - { - if (_mcpOnlyCapabilities.Count == 0) - return _capabilities.ToArray(); - var merged = new List(_capabilities.Count + _mcpOnlyCapabilities.Count); - merged.AddRange(_capabilities); - merged.AddRange(_mcpOnlyCapabilities); - return merged.ToArray(); - } - }, + _capabilityRegistry.GetMcpSnapshot, _logger, serverName: "openclaw-tray-mcp", serverVersion: AppVersionInfo.Version); @@ -976,8 +927,7 @@ public void SetMcpEnabled(bool enabled) _logger.Info("[MCP] SetMcpEnabled(true) — starting MCP server"); _mcpStartupError = null; - bool needsCapabilities; - lock (_capabilitiesLock) { needsCapabilities = _capabilities.Count == 0; } + var needsCapabilities = _capabilityRegistry.GetGatewaySnapshot().Count == 0; try { if (needsCapabilities) @@ -1015,11 +965,7 @@ public void SetMcpEnabled(bool enabled) if (_nodeClient == null) return null; - INodeCapability[] capabilitySnapshot; - lock (_capabilitiesLock) - { - capabilitySnapshot = _capabilities.ToArray(); - } + var capabilitySnapshot = _capabilityRegistry.GetGatewaySnapshot(); var capabilities = capabilitySnapshot.Select(c => c.Category).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var commands = capabilitySnapshot.SelectMany(c => c.Commands).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs index 470fe6416..002294872 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs @@ -23,5 +23,6 @@ public static class SettingsDataExtensions settings.NodeSttEnabled, settings.NodeTtsEnabled, settings.NodeSystemRunEnabled, + settings.CodexSessionAccess, settings.ToJson()); } diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index ad7c9c2b2..3838e0d24 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -5,6 +5,7 @@ using System.Text.Json; using OpenClaw.Shared; using OpenClaw.Shared.Capabilities; +using OpenClaw.Shared.Codex; namespace OpenClawTray.Services; @@ -17,6 +18,7 @@ public class SettingsManager // instance can run alongside the user's real tray without clobbering settings. private readonly string _settingsDirectory; private readonly string _settingsFilePath; + internal ISettingsFileOperations FileOperations { get; set; } = new SettingsFileOperations(); private const string ProtectedSecretPrefix = "dpapi:"; private const int CurrentSettingsSchemaVersion = 1; private static readonly byte[] ProtectedSecretEntropy = Encoding.UTF8.GetBytes("OpenClawTray.Settings.v1"); @@ -34,16 +36,28 @@ public class SettingsManager private readonly object _saveLock = new(); private SettingsData _data = CreateDefaultData(); + private T ReadData(Func read) + { + lock (_saveLock) + return read(_data); + } + + private void UpdateData(Func update) + { + lock (_saveLock) + _data = update(_data); + } + // Connection - public string GatewayUrl { get => _data.GatewayUrl ?? AppIdentity.SetupGatewayUrl; set => _data = _data with { GatewayUrl = value }; } - public bool UseSshTunnel { get => _data.UseSshTunnel; set => _data = _data with { UseSshTunnel = value }; } - public string SshTunnelUser { get => _data.SshTunnelUser ?? ""; set => _data = _data with { SshTunnelUser = value }; } - public string SshTunnelHost { get => _data.SshTunnelHost ?? ""; set => _data = _data with { SshTunnelHost = value }; } - public int SshTunnelSshPort { get => IsValidPort(_data.SshTunnelSshPort) ? _data.SshTunnelSshPort : 22; set => _data = _data with { SshTunnelSshPort = value }; } - public int SshTunnelRemotePort { get => _data.SshTunnelRemotePort <= 0 ? 18789 : _data.SshTunnelRemotePort; set => _data = _data with { SshTunnelRemotePort = value }; } - public int SshTunnelLocalPort { get => _data.SshTunnelLocalPort <= 0 ? 18789 : _data.SshTunnelLocalPort; set => _data = _data with { SshTunnelLocalPort = value }; } + public string GatewayUrl { get => ReadData(data => data.GatewayUrl ?? AppIdentity.SetupGatewayUrl); set => UpdateData(data => data with { GatewayUrl = value }); } + public bool UseSshTunnel { get => ReadData(data => data.UseSshTunnel); set => UpdateData(data => data with { UseSshTunnel = value }); } + public string SshTunnelUser { get => ReadData(data => data.SshTunnelUser ?? ""); set => UpdateData(data => data with { SshTunnelUser = value }); } + public string SshTunnelHost { get => ReadData(data => data.SshTunnelHost ?? ""); set => UpdateData(data => data with { SshTunnelHost = value }); } + public int SshTunnelSshPort { get => ReadData(data => IsValidPort(data.SshTunnelSshPort) ? data.SshTunnelSshPort : 22); set => UpdateData(data => data with { SshTunnelSshPort = value }); } + public int SshTunnelRemotePort { get => ReadData(data => data.SshTunnelRemotePort <= 0 ? 18789 : data.SshTunnelRemotePort); set => UpdateData(data => data with { SshTunnelRemotePort = value }); } + public int SshTunnelLocalPort { get => ReadData(data => data.SshTunnelLocalPort <= 0 ? 18789 : data.SshTunnelLocalPort); set => UpdateData(data => data with { SshTunnelLocalPort = value }); } /// - public int? BrowserControlPort { get => _data.BrowserControlPort; set => _data = _data with { BrowserControlPort = value }; } + public int? BrowserControlPort { get => ReadData(data => data.BrowserControlPort); set => UpdateData(data => data with { BrowserControlPort = value }); } public string? LegacyToken { get; private set; } public string? LegacyBootstrapToken { get; private set; } public bool HasLegacyGatewayCredentials => @@ -51,35 +65,35 @@ public class SettingsManager !string.IsNullOrWhiteSpace(LegacyBootstrapToken); // Startup - public bool AutoStart { get => _data.AutoStart; set => _data = _data with { AutoStart = value }; } - public bool GlobalHotkeyEnabled { get => _data.GlobalHotkeyEnabled; set => _data = _data with { GlobalHotkeyEnabled = value }; } + public bool AutoStart { get => ReadData(data => data.AutoStart); set => UpdateData(data => data with { AutoStart = value }); } + public bool GlobalHotkeyEnabled { get => ReadData(data => data.GlobalHotkeyEnabled); set => UpdateData(data => data with { GlobalHotkeyEnabled = value }); } /// /// One-shot gate: set to true after the post-onboarding "first-run" bootstrap /// kickoff message has been injected into the chat exactly once. /// - public bool HasInjectedFirstRunBootstrap { get => _data.HasInjectedFirstRunBootstrap; set => _data = _data with { HasInjectedFirstRunBootstrap = value }; } + public bool HasInjectedFirstRunBootstrap { get => ReadData(data => data.HasInjectedFirstRunBootstrap); set => UpdateData(data => data with { HasInjectedFirstRunBootstrap = value }); } // Notifications - public bool ShowNotifications { get => _data.ShowNotifications; set => _data = _data with { ShowNotifications = value }; } - public string NotificationSound { get => _data.NotificationSound ?? "Default"; set => _data = _data with { NotificationSound = value }; } + public bool ShowNotifications { get => ReadData(data => data.ShowNotifications); set => UpdateData(data => data with { ShowNotifications = value }); } + public string NotificationSound { get => ReadData(data => data.NotificationSound ?? "Default"); set => UpdateData(data => data with { NotificationSound = value }); } // Notification filters - public bool NotifyHealth { get => _data.NotifyHealth; set => _data = _data with { NotifyHealth = value }; } - public bool NotifyUrgent { get => _data.NotifyUrgent; set => _data = _data with { NotifyUrgent = value }; } - public bool NotifyReminder { get => _data.NotifyReminder; set => _data = _data with { NotifyReminder = value }; } - public bool NotifyEmail { get => _data.NotifyEmail; set => _data = _data with { NotifyEmail = value }; } - public bool NotifyCalendar { get => _data.NotifyCalendar; set => _data = _data with { NotifyCalendar = value }; } - public bool NotifyBuild { get => _data.NotifyBuild; set => _data = _data with { NotifyBuild = value }; } - public bool NotifyStock { get => _data.NotifyStock; set => _data = _data with { NotifyStock = value }; } - public bool NotifyInfo { get => _data.NotifyInfo; set => _data = _data with { NotifyInfo = value }; } + public bool NotifyHealth { get => ReadData(data => data.NotifyHealth); set => UpdateData(data => data with { NotifyHealth = value }); } + public bool NotifyUrgent { get => ReadData(data => data.NotifyUrgent); set => UpdateData(data => data with { NotifyUrgent = value }); } + public bool NotifyReminder { get => ReadData(data => data.NotifyReminder); set => UpdateData(data => data with { NotifyReminder = value }); } + public bool NotifyEmail { get => ReadData(data => data.NotifyEmail); set => UpdateData(data => data with { NotifyEmail = value }); } + public bool NotifyCalendar { get => ReadData(data => data.NotifyCalendar); set => UpdateData(data => data with { NotifyCalendar = value }); } + public bool NotifyBuild { get => ReadData(data => data.NotifyBuild); set => UpdateData(data => data with { NotifyBuild = value }); } + public bool NotifyStock { get => ReadData(data => data.NotifyStock); set => UpdateData(data => data with { NotifyStock = value }); } + public bool NotifyInfo { get => ReadData(data => data.NotifyInfo); set => UpdateData(data => data with { NotifyInfo = value }); } // Enhanced categorization - public bool NotifyChatResponses { get => _data.NotifyChatResponses; set => _data = _data with { NotifyChatResponses = value }; } - public bool PreferStructuredCategories { get => _data.PreferStructuredCategories; set => _data = _data with { PreferStructuredCategories = value }; } + public bool NotifyChatResponses { get => ReadData(data => data.NotifyChatResponses); set => UpdateData(data => data with { NotifyChatResponses = value }); } + public bool PreferStructuredCategories { get => ReadData(data => data.PreferStructuredCategories); set => UpdateData(data => data with { PreferStructuredCategories = value }); } public List UserRules { - get => _data.UserRules ??= new(); - set => _data = _data with { UserRules = value ?? new() }; + get => ReadData(data => data.UserRules ?? []); + set => UpdateData(data => data with { UserRules = value ?? new() }); } // User interface @@ -88,59 +102,60 @@ public List UserRules /// native chat surface in both the Hub Chat tab and tray Chat popup. /// Default false (native). /// - public bool UseLegacyWebChat { get => _data.UseLegacyWebChat; set => _data = _data with { UseLegacyWebChat = value }; } - public bool ShowCompletedSessions { get => _data.ShowCompletedSessions; set => _data = _data with { ShowCompletedSessions = value }; } - public string AppTheme { get => NormalizeAppTheme(_data.AppTheme); set => _data = _data with { AppTheme = NormalizeAppTheme(value) }; } - public bool? ShowDiagnosticsOverride { get => _data.ShowDiagnostics; set => _data = _data with { ShowDiagnostics = value }; } - public bool ShowDiagnosticsEffective => _data.ShowDiagnostics ?? OpenClawTray.Helpers.DiagnosticsGate.BuildDefault; - public string OpenTelemetryEndpoint { get => _data.OpenTelemetryEndpoint ?? ""; set => _data = _data with { OpenTelemetryEndpoint = NormalizeOptionalString(value) }; } - public string OpenTelemetryProtocol { get => OpenTelemetryEndpointProtocol.Normalize(_data.OpenTelemetryProtocol); set => _data = _data with { OpenTelemetryProtocol = OpenTelemetryEndpointProtocol.Normalize(value) }; } + public bool UseLegacyWebChat { get => ReadData(data => data.UseLegacyWebChat); set => UpdateData(data => data with { UseLegacyWebChat = value }); } + public bool ShowCompletedSessions { get => ReadData(data => data.ShowCompletedSessions); set => UpdateData(data => data with { ShowCompletedSessions = value }); } + public string AppTheme { get => ReadData(data => NormalizeAppTheme(data.AppTheme)); set => UpdateData(data => data with { AppTheme = NormalizeAppTheme(value) }); } + public bool? ShowDiagnosticsOverride { get => ReadData(data => data.ShowDiagnostics); set => UpdateData(data => data with { ShowDiagnostics = value }); } + public bool ShowDiagnosticsEffective => ReadData(data => data.ShowDiagnostics ?? OpenClawTray.Helpers.DiagnosticsGate.BuildDefault); + public string OpenTelemetryEndpoint { get => ReadData(data => data.OpenTelemetryEndpoint ?? ""); set => UpdateData(data => data with { OpenTelemetryEndpoint = NormalizeOptionalString(value) }); } + public string OpenTelemetryProtocol { get => ReadData(data => OpenTelemetryEndpointProtocol.Normalize(data.OpenTelemetryProtocol)); set => UpdateData(data => data with { OpenTelemetryProtocol = OpenTelemetryEndpointProtocol.Normalize(value) }); } // Node mode(gateway WebSocket connection — separate from MCP) - public bool EnableNodeMode { get => _data.EnableNodeMode; set => _data = _data with { EnableNodeMode = value }; } + public bool EnableNodeMode { get => ReadData(data => data.EnableNodeMode); set => UpdateData(data => data with { EnableNodeMode = value }); } /// Master switch for the focused inbound-pairing approval dialog + awareness toast. - public bool ShowPairingApprovalDialog { get => _data.ShowPairingApprovalDialog; set => _data = _data with { ShowPairingApprovalDialog = value }; } - public bool NodeCanvasEnabled { get => _data.NodeCanvasEnabled; set => _data = _data with { NodeCanvasEnabled = value }; } - public bool NodeScreenEnabled { get => _data.NodeScreenEnabled; set => _data = _data with { NodeScreenEnabled = value }; } - public bool NodeCameraEnabled { get => _data.NodeCameraEnabled; set => _data = _data with { NodeCameraEnabled = value }; } - public bool ScreenRecordingConsentGiven { get => _data.ScreenRecordingConsentGiven; set => _data = _data with { ScreenRecordingConsentGiven = value }; } - public bool CameraRecordingConsentGiven { get => _data.CameraRecordingConsentGiven; set => _data = _data with { CameraRecordingConsentGiven = value }; } - public bool NodeLocationEnabled { get => _data.NodeLocationEnabled; set => _data = _data with { NodeLocationEnabled = value }; } - public bool NodeBrowserProxyEnabled { get => _data.NodeBrowserProxyEnabled; set => _data = _data with { NodeBrowserProxyEnabled = value }; } + public bool ShowPairingApprovalDialog { get => ReadData(data => data.ShowPairingApprovalDialog); set => UpdateData(data => data with { ShowPairingApprovalDialog = value }); } + public bool NodeCanvasEnabled { get => ReadData(data => data.NodeCanvasEnabled); set => UpdateData(data => data with { NodeCanvasEnabled = value }); } + public bool NodeScreenEnabled { get => ReadData(data => data.NodeScreenEnabled); set => UpdateData(data => data with { NodeScreenEnabled = value }); } + public bool NodeCameraEnabled { get => ReadData(data => data.NodeCameraEnabled); set => UpdateData(data => data with { NodeCameraEnabled = value }); } + public bool ScreenRecordingConsentGiven { get => ReadData(data => data.ScreenRecordingConsentGiven); set => UpdateData(data => data with { ScreenRecordingConsentGiven = value }); } + public bool CameraRecordingConsentGiven { get => ReadData(data => data.CameraRecordingConsentGiven); set => UpdateData(data => data with { CameraRecordingConsentGiven = value }); } + public bool NodeLocationEnabled { get => ReadData(data => data.NodeLocationEnabled); set => UpdateData(data => data with { NodeLocationEnabled = value }); } + public bool NodeBrowserProxyEnabled { get => ReadData(data => data.NodeBrowserProxyEnabled); set => UpdateData(data => data with { NodeBrowserProxyEnabled = value }); } + public CodexSessionAccessMode CodexSessionAccess { get => ReadData(data => data.CodexSessionAccess); set => UpdateData(data => data with { CodexSessionAccess = value }); } /// /// Master switch for the system.run / system.run.prepare /// commands. Per-command exec approvals still apply when this is on; /// flipping it off removes those commands from the declared capability /// entirely. Default true (backward compatible). /// - public bool NodeSystemRunEnabled { get => _data.NodeSystemRunEnabled; set => _data = _data with { NodeSystemRunEnabled = value }; } - public bool NodeSttEnabled { get => _data.NodeSttEnabled; set => _data = _data with { NodeSttEnabled = value }; } + public bool NodeSystemRunEnabled { get => ReadData(data => data.NodeSystemRunEnabled); set => UpdateData(data => data with { NodeSystemRunEnabled = value }); } + public bool NodeSttEnabled { get => ReadData(data => data.NodeSttEnabled); set => UpdateData(data => data with { NodeSttEnabled = value }); } /// STT language: "auto" for Whisper auto-detect, or a BCP-47 tag like "en-US". - public string SttLanguage { get => string.IsNullOrWhiteSpace(_data.SttLanguage) ? "auto" : _data.SttLanguage; set => _data = _data with { SttLanguage = value }; } + public string SttLanguage { get => ReadData(data => string.IsNullOrWhiteSpace(data.SttLanguage) ? "auto" : data.SttLanguage); set => UpdateData(data => data with { SttLanguage = value }); } /// Whisper model size: "tiny", "base", or "small". - public string SttModelName { get => string.IsNullOrWhiteSpace(_data.SttModelName) ? "base" : _data.SttModelName; set => _data = _data with { SttModelName = value }; } + public string SttModelName { get => ReadData(data => string.IsNullOrWhiteSpace(data.SttModelName) ? "base" : data.SttModelName); set => UpdateData(data => data with { SttModelName = value }); } /// Seconds of silence before auto-submit in voice chat mode. - public float SttSilenceTimeout { get => _data.SttSilenceTimeout > 0 ? _data.SttSilenceTimeout : 1.5f; set => _data = _data with { SttSilenceTimeout = value }; } + public float SttSilenceTimeout { get => ReadData(data => data.SttSilenceTimeout > 0 ? data.SttSilenceTimeout : 1.5f); set => UpdateData(data => data with { SttSilenceTimeout = value }); } /// Enable TTS playback of responses during voice sessions. - public bool VoiceTtsEnabled { get => _data.VoiceTtsEnabled; set => _data = _data with { VoiceTtsEnabled = value }; } + public bool VoiceTtsEnabled { get => ReadData(data => data.VoiceTtsEnabled); set => UpdateData(data => data with { VoiceTtsEnabled = value }); } /// Show tool-call and usage chips inline in the chat timeline. - public bool ShowChatToolCalls { get => _data.ShowChatToolCalls; set => _data = _data with { ShowChatToolCalls = value }; } + public bool ShowChatToolCalls { get => ReadData(data => data.ShowChatToolCalls); set => UpdateData(data => data with { ShowChatToolCalls = value }); } /// Play audio feedback chimes on listen start/stop. - public bool VoiceAudioFeedback { get => _data.VoiceAudioFeedback; set => _data = _data with { VoiceAudioFeedback = value }; } - public bool NodeTtsEnabled { get => _data.NodeTtsEnabled; set => _data = _data with { NodeTtsEnabled = value }; } - public string TtsProvider { get => string.IsNullOrWhiteSpace(_data.TtsProvider) ? TtsCapability.PiperProvider : _data.TtsProvider; set => _data = _data with { TtsProvider = value }; } - public string TtsElevenLabsApiKey { get => _data.TtsElevenLabsApiKey ?? ""; set => _data = _data with { TtsElevenLabsApiKey = value }; } - public string TtsElevenLabsModel { get => _data.TtsElevenLabsModel ?? ""; set => _data = _data with { TtsElevenLabsModel = value }; } - public string TtsElevenLabsVoiceId { get => _data.TtsElevenLabsVoiceId ?? ""; set => _data = _data with { TtsElevenLabsVoiceId = value }; } - public string TtsWindowsVoiceId { get => _data.TtsWindowsVoiceId ?? ""; set => _data = _data with { TtsWindowsVoiceId = value }; } + public bool VoiceAudioFeedback { get => ReadData(data => data.VoiceAudioFeedback); set => UpdateData(data => data with { VoiceAudioFeedback = value }); } + public bool NodeTtsEnabled { get => ReadData(data => data.NodeTtsEnabled); set => UpdateData(data => data with { NodeTtsEnabled = value }); } + public string TtsProvider { get => ReadData(data => string.IsNullOrWhiteSpace(data.TtsProvider) ? TtsCapability.PiperProvider : data.TtsProvider); set => UpdateData(data => data with { TtsProvider = value }); } + public string TtsElevenLabsApiKey { get => ReadData(data => data.TtsElevenLabsApiKey ?? ""); set => UpdateData(data => data with { TtsElevenLabsApiKey = value }); } + public string TtsElevenLabsModel { get => ReadData(data => data.TtsElevenLabsModel ?? ""); set => UpdateData(data => data with { TtsElevenLabsModel = value }); } + public string TtsElevenLabsVoiceId { get => ReadData(data => data.TtsElevenLabsVoiceId ?? ""); set => UpdateData(data => data with { TtsElevenLabsVoiceId = value }); } + public string TtsWindowsVoiceId { get => ReadData(data => data.TtsWindowsVoiceId ?? ""); set => UpdateData(data => data with { TtsWindowsVoiceId = value }); } /// Hub NavigationView pane expanded (true) vs compact (false). Default true. - public bool HubNavPaneOpen { get => _data.HubNavPaneOpen; set => _data = _data with { HubNavPaneOpen = value }; } + public bool HubNavPaneOpen { get => ReadData(data => data.HubNavPaneOpen); set => UpdateData(data => data with { HubNavPaneOpen = value }); } /// Piper voice identifier, e.g. "en_US-amy-low". - public string TtsPiperVoiceId { get => string.IsNullOrWhiteSpace(_data.TtsPiperVoiceId) ? "en_US-amy-low" : _data.TtsPiperVoiceId; set => _data = _data with { TtsPiperVoiceId = value }; } + public string TtsPiperVoiceId { get => ReadData(data => string.IsNullOrWhiteSpace(data.TtsPiperVoiceId) ? "en_US-amy-low" : data.TtsPiperVoiceId); set => UpdateData(data => data with { TtsPiperVoiceId = value }); } // Local MCP HTTP server (independent of EnableNodeMode) - public bool EnableMcpServer { get => _data.EnableMcpServer; set => _data = _data with { EnableMcpServer = value }; } + public bool EnableMcpServer { get => ReadData(data => data.EnableMcpServer); set => UpdateData(data => data with { EnableMcpServer = value }); } // Automatic self-repair of app-owned setup-managed local WSL gateways (kill switch). - public bool EnableManagedLocalGatewayAutoRepair { get => _data.EnableManagedLocalGatewayAutoRepair; set => _data = _data with { EnableManagedLocalGatewayAutoRepair = value }; } + public bool EnableManagedLocalGatewayAutoRepair { get => ReadData(data => data.EnableManagedLocalGatewayAutoRepair); set => UpdateData(data => data with { EnableManagedLocalGatewayAutoRepair = value }); } /// /// Hostnames the A2UI image renderer is allowed to fetch over HTTPS. /// Empty by default — agents can still ship inline data: images. The @@ -149,32 +164,32 @@ public List UserRules /// public List A2UIImageHosts { - get => _data.A2UIImageHosts ??= new(); - set => _data = _data with { A2UIImageHosts = value ?? new() }; + get => ReadData(data => data.A2UIImageHosts ?? []); + set => UpdateData(data => data with { A2UIImageHosts = value ?? new() }); } - public bool HasSeenActivityStreamTip { get => _data.HasSeenActivityStreamTip; set => _data = _data with { HasSeenActivityStreamTip = value }; } - public string SkippedUpdateTag { get => _data.SkippedUpdateTag ?? ""; set => _data = _data with { SkippedUpdateTag = value }; } - public string? PreferredGatewayId { get => _data.PreferredGatewayId; set => _data = _data with { PreferredGatewayId = value }; } + public bool HasSeenActivityStreamTip { get => ReadData(data => data.HasSeenActivityStreamTip); set => UpdateData(data => data with { HasSeenActivityStreamTip = value }); } + public string SkippedUpdateTag { get => ReadData(data => data.SkippedUpdateTag ?? ""); set => UpdateData(data => data with { SkippedUpdateTag = value }); } + public string? PreferredGatewayId { get => ReadData(data => data.PreferredGatewayId); set => UpdateData(data => data with { PreferredGatewayId = value }); } // ── MXC sandbox ───────────────────────────────────────────────────── /// Master switch for system.run containment. When true (default), system.run uses MXC when available and falls back to host execution when unavailable unless strict fallback blocking is enabled. When false, system.run runs on host like before. - public bool SystemRunSandboxEnabled { get => _data.SystemRunSandboxEnabled; set => _data = _data with { SystemRunSandboxEnabled = value }; } + public bool SystemRunSandboxEnabled { get => ReadData(data => data.SystemRunSandboxEnabled); set => UpdateData(data => data with { SystemRunSandboxEnabled = value }); } /// When true, sandbox-enabled system.run blocks instead of using the compatibility host fallback if MXC is unavailable. Default false. - public bool SystemRunBlockHostFallbackWhenMxcUnavailable { get => _data.SystemRunBlockHostFallbackWhenMxcUnavailable; set => _data = _data with { SystemRunBlockHostFallbackWhenMxcUnavailable = value }; } + public bool SystemRunBlockHostFallbackWhenMxcUnavailable { get => ReadData(data => data.SystemRunBlockHostFallbackWhenMxcUnavailable); set => UpdateData(data => data with { SystemRunBlockHostFallbackWhenMxcUnavailable = value }); } /// When sandboxed, allow system.run commands to reach the public internet. Default false. - public bool SystemRunAllowOutbound { get => _data.SystemRunAllowOutbound; set => _data = _data with { SystemRunAllowOutbound = value }; } + public bool SystemRunAllowOutbound { get => ReadData(data => data.SystemRunAllowOutbound); set => UpdateData(data => data with { SystemRunAllowOutbound = value }); } // ── MXC sandbox: additional knobs (Sandbox page) ───────────────── - public SandboxClipboardMode SandboxClipboard { get => _data.SandboxClipboard; set => _data = _data with { SandboxClipboard = value }; } - public SandboxFolderAccess? SandboxDocumentsAccess { get => _data.SandboxDocumentsAccess; set => _data = _data with { SandboxDocumentsAccess = value }; } - public SandboxFolderAccess? SandboxDownloadsAccess { get => _data.SandboxDownloadsAccess; set => _data = _data with { SandboxDownloadsAccess = value }; } - public SandboxFolderAccess? SandboxDesktopAccess { get => _data.SandboxDesktopAccess; set => _data = _data with { SandboxDesktopAccess = value }; } + public SandboxClipboardMode SandboxClipboard { get => ReadData(data => data.SandboxClipboard); set => UpdateData(data => data with { SandboxClipboard = value }); } + public SandboxFolderAccess? SandboxDocumentsAccess { get => ReadData(data => data.SandboxDocumentsAccess); set => UpdateData(data => data with { SandboxDocumentsAccess = value }); } + public SandboxFolderAccess? SandboxDownloadsAccess { get => ReadData(data => data.SandboxDownloadsAccess); set => UpdateData(data => data with { SandboxDownloadsAccess = value }); } + public SandboxFolderAccess? SandboxDesktopAccess { get => ReadData(data => data.SandboxDesktopAccess); set => UpdateData(data => data with { SandboxDesktopAccess = value }); } public List SandboxCustomFolders { - get => _data.SandboxCustomFolders ??= new(); - set => _data = _data with { SandboxCustomFolders = value ?? new() }; + get => ReadData(data => data.SandboxCustomFolders ?? []); + set => UpdateData(data => data with { SandboxCustomFolders = value ?? new() }); } - public int SandboxTimeoutMs { get => _data.SandboxTimeoutMs > 0 ? _data.SandboxTimeoutMs : 30_000; set => _data = _data with { SandboxTimeoutMs = value }; } - public long SandboxMaxOutputBytes { get => _data.SandboxMaxOutputBytes > 0 ? _data.SandboxMaxOutputBytes : 4 * 1024 * 1024; set => _data = _data with { SandboxMaxOutputBytes = value }; } + public int SandboxTimeoutMs { get => ReadData(data => data.SandboxTimeoutMs > 0 ? data.SandboxTimeoutMs : 30_000); set => UpdateData(data => data with { SandboxTimeoutMs = value }); } + public long SandboxMaxOutputBytes { get => ReadData(data => data.SandboxMaxOutputBytes > 0 ? data.SandboxMaxOutputBytes : 4 * 1024 * 1024); set => UpdateData(data => data with { SandboxMaxOutputBytes = value }); } public SettingsManager() : this(GetDefaultSettingsDirectory()) { @@ -196,6 +211,12 @@ private static string GetDefaultSettingsDirectory() } public void Load() + { + lock (_saveLock) + LoadCore(); + } + + private void LoadCore() { LegacyToken = null; LegacyBootstrapToken = null; @@ -261,6 +282,7 @@ public void Load() CameraRecordingConsentGiven = false, NodeLocationEnabled = true, NodeBrowserProxyEnabled = true, + CodexSessionAccess = CodexSessionAccessMode.Off, NodeSystemRunEnabled = true, NodeSttEnabled = false, SttLanguage = "auto", @@ -388,7 +410,7 @@ private void LoadLegacyGatewayCredentials(string json) /// Creates a detached snapshot of current settings. No DPAPI protection is /// applied here; Save applies it to a second clone for on-disk storage only. /// - public SettingsData ToSettingsData() => _data with + public SettingsData ToSettingsData() => ReadData(data => data with { GatewayUrl = GatewayUrl, SshTunnelUser = SshTunnelUser, @@ -417,7 +439,7 @@ public SettingsData ToSettingsData() => _data with SandboxTimeoutMs = SandboxTimeoutMs, SandboxMaxOutputBytes = SandboxMaxOutputBytes, McpOnlyMode = null - }; + }); public static string NormalizeAppTheme(string? value) { @@ -447,32 +469,107 @@ internal void SaveOrThrow() { lock (_saveLock) { - Directory.CreateDirectory(_settingsDirectory); - // Lock the tray data dir to current user + SYSTEM + Administrators — - // it co-locates the MCP bearer token, settings.json (which embeds - // gateway/bootstrap credentials), and diagnostics jsonl. Other apps - // running as the same user could otherwise read these freely. - OpenClaw.Shared.Mcp.McpAuthToken.TryRestrictDataDirectoryAcl(_settingsDirectory); + SaveOrThrowCore(); + } + } - var data = ToSettingsData(); - // Apply DPAPI protection to the API key for on-disk storage only - data.TtsElevenLabsApiKey = ProtectSettingSecret(data.TtsElevenLabsApiKey); + internal bool UpdateAndSave(Action update) => + UpdateAndSave(update, rollbackOnFailure: false); - var json = data.ToJson(); - File.WriteAllText(_settingsFilePath, json); + internal bool TryUpdateAndSave(Action update) => + UpdateAndSave(update, rollbackOnFailure: true); - Logger.Info("Settings saved"); + private bool UpdateAndSave(Action update, bool rollbackOnFailure) + { + ArgumentNullException.ThrowIfNull(update); + lock (_saveLock) + { + var previousData = _data; + update(this); try { - Saved?.Invoke(this, EventArgs.Empty); + SaveOrThrowCore(); + return true; } catch (Exception ex) { - Logger.Warn($"Settings saved, but a notification subscriber failed: {ex.Message}"); + if (rollbackOnFailure) + _data = previousData; + Logger.Error($"Failed to save settings: {ex.Message}"); + return false; } } } + internal T ReadLocked(Func read) + { + ArgumentNullException.ThrowIfNull(read); + lock (_saveLock) + { + return read(this); + } + } + + private void SaveOrThrowCore() + { + Directory.CreateDirectory(_settingsDirectory); + // Lock the tray data dir to current user + SYSTEM + Administrators — + // it co-locates the MCP bearer token, settings.json (which embeds + // gateway/bootstrap credentials), and diagnostics jsonl. Other apps + // running as the same user could otherwise read these freely. + OpenClaw.Shared.Mcp.McpAuthToken.TryRestrictDataDirectoryAcl(_settingsDirectory); + + var data = ToSettingsData(); + // Apply DPAPI protection to the API key for on-disk storage only + data.TtsElevenLabsApiKey = ProtectSettingSecret(data.TtsElevenLabsApiKey); + + var json = data.ToJson(); + WriteSettingsAtomically(json); + + Logger.Info("Settings saved"); + try + { + Saved?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + Logger.Warn($"Settings saved, but a notification subscriber failed: {ex.Message}"); + } + } + + private void WriteSettingsAtomically(string json) + { + var suffix = Guid.NewGuid().ToString("N"); + var tempPath = Path.Combine(_settingsDirectory, $"settings.{suffix}.tmp"); + var backupPath = Path.Combine(_settingsDirectory, $"settings.{suffix}.backup"); + try + { + FileOperations.WriteAllText(tempPath, json); + if (FileOperations.Exists(_settingsFilePath)) + FileOperations.Replace(tempPath, _settingsFilePath, backupPath); + else + FileOperations.Move(tempPath, _settingsFilePath); + } + finally + { + TryDeleteSettingsArtifact(tempPath); + TryDeleteSettingsArtifact(backupPath); + } + } + + private void TryDeleteSettingsArtifact(string path) + { + try + { + if (FileOperations.Exists(path)) + FileOperations.Delete(path); + } + catch (Exception ex) + { + Logger.Warn($"Failed to remove settings persistence artifact: {ex.Message}"); + } + } + internal static string? ProtectSettingSecret(string? value) { if (string.IsNullOrWhiteSpace(value)) @@ -559,3 +656,22 @@ public string GetEffectiveGatewayUrl() return $"ws://127.0.0.1:{SshTunnelLocalPort}"; } } + +internal interface ISettingsFileOperations +{ + bool Exists(string path); + void WriteAllText(string path, string contents); + void Replace(string source, string destination, string backup); + void Move(string source, string destination); + void Delete(string path); +} + +internal sealed class SettingsFileOperations : ISettingsFileOperations +{ + public bool Exists(string path) => File.Exists(path); + public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents); + public void Replace(string source, string destination, string backup) => + File.Replace(source, destination, backup, ignoreMetadataErrors: true); + public void Move(string source, string destination) => File.Move(source, destination); + public void Delete(string path) => File.Delete(path); +} diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index e9ae5a612..55fca4db8 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -6841,6 +6841,36 @@ Make sure the gateway is running. About + + Codex sessions + + + Choose whether OpenClaw can list local Codex sessions and read transcripts. This does not change Gateway configuration. + + + Catalog unavailable: access is off. + + + Catalog available: OpenClaw can list sessions and read transcripts. + + + Catalog unavailable: Codex executable not found. + + + Steering unavailable: owner control is not available because Stage 0 did not pass validation. + + + Off + + + Read only + + + Read and steer + + + Codex session access + App info diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index db3a18477..43e466995 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -6843,6 +6843,37 @@ Le binaire wxc-exec est introuvable. {1} S'il s'agit d'une build développeur, c About + + Codex sessions + + + Choose whether OpenClaw can list local Codex sessions and read transcripts. This does not change Gateway configuration. + + + Catalog unavailable: access is off. + + + Catalog available: OpenClaw can list sessions and read transcripts. + + + Catalog unavailable: Codex executable not found. + + + Steering unavailable: owner control is not available because Stage 0 did not pass validation. + + + Off + + + Read only + + + Read and steer + + + Codex session access + + Gateway info diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c69d7e596..c8037c25a 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -6844,6 +6844,37 @@ Het binaire bestand wxc-exec is niet gevonden. {1} Als dit een ontwikkelaarsbuil About + + Codex sessions + + + Choose whether OpenClaw can list local Codex sessions and read transcripts. This does not change Gateway configuration. + + + Catalog unavailable: access is off. + + + Catalog available: OpenClaw can list sessions and read transcripts. + + + Catalog unavailable: Codex executable not found. + + + Steering unavailable: owner control is not available because Stage 0 did not pass validation. + + + Off + + + Read only + + + Read and steer + + + Codex session access + + Gateway info diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 3adedbbe3..9fb035b22 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -6843,6 +6843,37 @@ About + + Codex sessions + + + Choose whether OpenClaw can list local Codex sessions and read transcripts. This does not change Gateway configuration. + + + Catalog unavailable: access is off. + + + Catalog available: OpenClaw can list sessions and read transcripts. + + + Catalog unavailable: Codex executable not found. + + + Steering unavailable: owner control is not available because Stage 0 did not pass validation. + + + Off + + + Read only + + + Read and steer + + + Codex session access + + Gateway info diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 634d21159..54ba557fb 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -6843,6 +6843,37 @@ About + + Codex sessions + + + Choose whether OpenClaw can list local Codex sessions and read transcripts. This does not change Gateway configuration. + + + Catalog unavailable: access is off. + + + Catalog available: OpenClaw can list sessions and read transcripts. + + + Catalog unavailable: Codex executable not found. + + + Steering unavailable: owner control is not available because Stage 0 did not pass validation. + + + Off + + + Read only + + + Read and steer + + + Codex session access + + Gateway info diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md index f577c9dbd..4cc804f4f 100644 --- a/src/OpenClaw.WinNode.Cli/skill.md +++ b/src/OpenClaw.WinNode.Cli/skill.md @@ -349,6 +349,61 @@ The configured/effective view reflects configured defaults only; explicit `tts.speak` provider requests stay strict and may not match the default snapshot. +## Codex App Server catalog + +Codex session access is opt-in and controlled only from the tray Settings UI. +`Off` advertises none of the commands below. `Read only` advertises all three bounded +read commands. `Read and steer` currently advertises the same three read commands +because Stage 0 has no owner-control endpoint. It does not expose resume, steer, +interrupt, or any other write command. + +Transcript responses contain conversation content. Keep them private, request +only the page needed, and do not paste them into logs or bug reports. Audit and +error summaries contain command/outcome metadata only, not transcript bodies. + +### codex.appServer.threads.list.v1 +Read the bounded catalog of non-archived interactive Codex threads. + +```json +{"cursor":"opaque cursor","limit":50,"searchTerm":"title text","cwd":"C:\\work"} +``` + +All fields are optional. `limit` defaults to 50 and must be from 1 through 100. +`cursor` is opaque and limited to 4096 characters. `searchTerm` is limited to +500 characters, and `cwd` is limited to 4096 characters. Unknown fields are +rejected. Returns `{ "sessions": [...], "nextCursor"?: string, +"backwardsCursor"?: string }`. + +### codex.appServer.threads.history.list.v1 +Read a separately authorized bounded history catalog of interactive Codex threads. + +```json +{"cursor":"opaque cursor","limit":50,"searchTerm":"title text","archived":true} +``` + +`archived` is required and must be a boolean. It explicitly selects archived +(`true`) or non-archived (`false`) metadata; it does not change the semantics of +`codex.appServer.threads.list.v1`, which always selects non-archived threads. +`limit` defaults to 50 and must be from 1 through 100. `cursor` is opaque and +limited to 4096 characters, `searchTerm` is limited to 500 characters, and +unknown fields are rejected. Returns `{ "sessions": [...], "nextCursor"?: string, +"backwardsCursor"?: string }` with projected metadata only; it never returns +transcript bodies. + +### codex.appServer.thread.turns.list.v1 +Read one bounded transcript page after the thread is freshly verified as an +eligible non-archived interactive Codex thread. + +```json +{"threadId":"123e4567-e89b-12d3-a456-426614174000","cursor":"opaque cursor","limit":20} +``` + +`threadId` is a required UUID. `limit` defaults to 20 and must be from 1 through +50. `cursor` is optional, opaque, and limited to 4096 characters. Unknown fields +are rejected. Returns `{ "data": [...], "nextCursor"?: string, +"backwardsCursor"?: string }`. The implementation also caps transcript text and +aggregate response bytes. + ## App control (app.*) Read-only and small write operations targeting the running tray. Used diff --git a/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs b/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs index 82cb8ba72..6229378c6 100644 --- a/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs +++ b/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Connection; +using OpenClaw.Shared.Codex; namespace OpenClaw.Connection.Tests; @@ -22,6 +23,7 @@ private static ConnectionSettingsSnapshot MakeSnapshot( bool nodeSttEnabled = false, bool nodeTtsEnabled = false, bool nodeSystemRunEnabled = true, + CodexSessionAccessMode codexSessionAccess = CodexSessionAccessMode.Off, string? fullSettingsJson = null) => new( gatewayUrl, useSshTunnel, @@ -40,8 +42,23 @@ private static ConnectionSettingsSnapshot MakeSnapshot( nodeSttEnabled, nodeTtsEnabled, nodeSystemRunEnabled, + codexSessionAccess, fullSettingsJson); + [Theory] + [InlineData(CodexSessionAccessMode.Off, CodexSessionAccessMode.ReadOnly)] + [InlineData(CodexSessionAccessMode.ReadOnly, CodexSessionAccessMode.Off)] + public void CodexSessionAccessChanged_ReturnsCapabilityReload( + CodexSessionAccessMode before, + CodexSessionAccessMode after) + { + Assert.Equal( + SettingsChangeImpact.CapabilityReload, + SettingsChangeClassifier.Classify( + MakeSnapshot(codexSessionAccess: before), + MakeSnapshot(codexSessionAccess: after))); + } + [Fact] public void NullPrev_ReturnsFullReconnect() { diff --git a/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs b/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs index 1c71bf587..b601a3493 100644 --- a/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs @@ -124,6 +124,34 @@ public async Task SettingsSet_WithHandlerSuccessPayload_ReturnsData() Assert.True(res.Ok); } + [Theory] + [InlineData("CodexSessionAccess")] + [InlineData("codexsessionaccess")] + public async Task SettingsSet_CodexSessionAccess_IsDeniedBeforeTheLocalHandler(string name) + { + var handlerCalled = false; + var cap = new AppCapability(NullLogger.Instance) + { + SettingsSetHandler = (_, _) => + { + handlerCalled = true; + return new { value = "ReadOnly" }; + } + }; + var req = new NodeInvokeRequest + { + Id = "1", + Command = "app.settings.set", + Args = JsonSerializer.SerializeToElement(new { name, value = "ReadOnly" }) + }; + + var res = await cap.ExecuteAsync(req); + + Assert.False(res.Ok); + Assert.Contains("not accessible", res.Error, StringComparison.OrdinalIgnoreCase); + Assert.False(handlerCalled); + } + [Fact] public async Task UnknownCommand_ReturnsError() { diff --git a/tests/OpenClaw.Shared.Tests/CodexAppServerClientTests.cs b/tests/OpenClaw.Shared.Tests/CodexAppServerClientTests.cs new file mode 100644 index 000000000..d73aa4126 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/CodexAppServerClientTests.cs @@ -0,0 +1,908 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared.Codex; + +namespace OpenClaw.Shared.Tests; + +public sealed class CodexAppServerClientTests +{ + private static readonly CodexAppServerLimits DefaultTestLimits = new( + maxLineBytes: 512, + maxResponseBytes: 2_048, + maxOperationBytes: 4_096, + maxStandardErrorBytes: 128, + requestTimeout: TimeSpan.FromSeconds(3), + idleTimeout: TimeSpan.FromSeconds(2), + cleanupTimeout: TimeSpan.FromSeconds(2)); + + [Fact] + public async Task ConnectAsync_InitializesExperimentalApiBeforeSendingInitializedAndReads() + { + using var harness = new JsonlProcessHarness("success"); + await using var client = await ConnectAsync(harness); + + var result = await client.ListThreadsAsync(Params("catalog")); + + Assert.Equal("catalog", result.GetProperty("tag").GetString()); + var messages = harness.ClientMessages(); + Assert.Equal("initialize", messages[0].GetProperty("method").GetString()); + Assert.Equal("openclaw-windows-node", messages[0] + .GetProperty("params").GetProperty("clientInfo").GetProperty("name").GetString()); + Assert.True(messages[0].GetProperty("params").GetProperty("capabilities") + .GetProperty("experimentalApi").GetBoolean()); + Assert.False(messages[0].GetProperty("params").GetProperty("capabilities") + .GetProperty("requestAttestation").GetBoolean()); + Assert.False(messages[0].GetProperty("params").GetProperty("capabilities") + .GetProperty("mcpServerOpenaiFormElicitation").GetBoolean()); + Assert.Equal("initialized", messages[1].GetProperty("method").GetString()); + Assert.False(messages[1].TryGetProperty("params", out _)); + Assert.Equal("thread/list", messages[2].GetProperty("method").GetString()); + Assert.All(messages, message => Assert.False(message.TryGetProperty("jsonrpc", out _))); + } + + [Fact] + public async Task CurrentServerNotificationTimestamp_DoesNotTerminateCatalogRead() + { + using var harness = new JsonlProcessHarness("notification-emitted-at"); + await using var client = await ConnectAsync(harness); + + var result = await client.ListThreadsAsync(Params("catalog")); + + Assert.Equal("catalog", result.GetProperty("tag").GetString()); + Assert.Equal(1, harness.StartCount); + } + + [Fact] + public async Task GeneralAndCatalogFactoryRoutes_EnforceTheirOwnResponseProfiles() + { + var largeResult = BuildRawResult(1_200_000); + using var generalHarness = new JsonlProcessHarness("payload-response", largeResult); + await using var generalClient = await CodexAppServerClient.ConnectAsync( + new CodexLaunchPlan(Path.Combine(Path.GetTempPath(), "codex.exe")), + generalHarness, + CancellationToken.None); + + var generalError = await Assert.ThrowsAsync( + () => generalClient.ListThreadsAsync(Params("general"))); + Assert.Contains("line limit", generalError.Message, StringComparison.OrdinalIgnoreCase); + + using var catalogHarness = new JsonlProcessHarness("payload-response", largeResult); + await using var catalogClient = await CodexAppServerClient.ConnectCatalogAsync( + new CodexLaunchPlan(Path.Combine(Path.GetTempPath(), "codex.exe")), + catalogHarness, + CancellationToken.None); + + var catalogResult = await catalogClient.ListThreadsAsync(Params("catalog")); + Assert.Equal("payload", catalogResult.GetProperty("tag").GetString()); + } + + [Fact] + public async Task ConcurrentReads_CorrelateOutOfOrderNumericResponses() + { + using var harness = new JsonlProcessHarness("out-of-order"); + await using var client = await ConnectAsync(harness); + + var first = client.ListThreadsAsync(Params("first")); + var second = client.ListThreadTurnsAsync(Params("second")); + + Assert.Equal("first", (await first).GetProperty("tag").GetString()); + Assert.Equal("second", (await second).GetProperty("tag").GetString()); + var requestIds = harness.ClientMessages() + .Where(message => message.TryGetProperty("id", out _)) + .Select(message => message.GetProperty("id")) + .ToArray(); + Assert.All(requestIds, id => Assert.Equal(JsonValueKind.Number, id.ValueKind)); + Assert.Equal(requestIds.Length, requestIds.Select(id => id.GetInt64()).Distinct().Count()); + } + + [Fact] + public async Task ConcurrentCatalogReads_ChargeOnlyTheirCorrelatedResponseBytes() + { + using var harness = new JsonlProcessHarness( + "concurrent-catalog-large", + BuildRawResult(20 * 1024 * 1024)); + await using var client = await CodexAppServerClient.ConnectCatalogAsync( + new CodexLaunchPlan(Path.Combine(Path.GetTempPath(), "codex.exe")), + harness, + CancellationToken.None); + using var safety = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + var largeRead = client.ListThreadTurnsAsync(Params("large"), safety.Token); + var overlappingRead = client.ListThreadsAsync(Params("overlap"), safety.Token); + + Assert.Equal("payload", (await largeRead).GetProperty("tag").GetString()); + Assert.Equal("overlap", (await overlappingRead).GetProperty("tag").GetString()); + Assert.False(safety.IsCancellationRequested); + } + + [Fact] + public async Task DuplicateResponseId_IsRejectedAndFailsOtherPendingReads() + { + using var harness = new JsonlProcessHarness("duplicate-id"); + await using var client = await ConnectAsync(harness); + + var first = client.ListThreadsAsync(Params("first")); + var second = client.ListThreadTurnsAsync(Params("second")); + + Assert.Equal("second", (await second).GetProperty("tag").GetString()); + var exception = await Assert.ThrowsAsync( + async () => await first); + Assert.Contains("duplicate response id", exception.Message, StringComparison.OrdinalIgnoreCase); + await harness.AssertAllProcessesExitedAsync(); + } + + [Theory] + [InlineData("malformed", "malformed")] + [InlineData("oversized", "line limit")] + public async Task InvalidStdoutLine_IsRejectedAndCleansUpProcess( + string scenario, + string expectedMessage) + { + using var harness = new JsonlProcessHarness(scenario); + await using var client = await ConnectAsync(harness); + + var exception = await Assert.ThrowsAsync( + () => client.ListThreadsAsync(Params("invalid"))); + + Assert.Contains(expectedMessage, exception.Message, StringComparison.OrdinalIgnoreCase); + await harness.AssertAllProcessesExitedAsync(); + } + + [Theory] + [InlineData("approval", "item/commandExecution/requestApproval")] + [InlineData("elicitation", "mcpServer/elicitation/request")] + public async Task ServerApprovalOrElicitationRequest_IsExplicitlyRefused( + string scenario, + string serverMethod) + { + using var harness = new JsonlProcessHarness(scenario); + await using var client = await ConnectAsync(harness); + + var result = await client.ListThreadsAsync(Params("safe")); + + Assert.Equal("safe", result.GetProperty("tag").GetString()); + var refusal = harness.ClientMessages().Single(message => + message.TryGetProperty("id", out var id) && id.GetInt64() == 900); + Assert.Equal(-32601, refusal.GetProperty("error").GetProperty("code").GetInt32()); + Assert.Contains("read-only", refusal.GetProperty("error").GetProperty("message").GetString()); + Assert.DoesNotContain( + harness.ClientMessages(), + message => message.TryGetProperty("method", out var method) + && method.GetString() == serverMethod + && message.TryGetProperty("result", out _)); + } + + [Fact] + public async Task StandardErrorDrain_RetainsOnlyTheBoundedTail() + { + using var harness = new JsonlProcessHarness("stderr"); + await using var client = await ConnectAsync(harness); + + _ = await client.ListThreadsAsync(Params("stderr")); + await harness.WaitForMarkerAsync("stderr-written"); + + Assert.InRange(client.StandardErrorSnapshot.Length, 1, DefaultTestLimits.MaxStandardErrorBytes); + Assert.Equal(new string('z', DefaultTestLimits.MaxStandardErrorBytes), client.StandardErrorSnapshot); + } + + [Fact] + public async Task RequestTimeout_FailsReadAndCleansUpProcess() + { + var limits = DefaultTestLimits with + { + RequestTimeout = TimeSpan.FromMilliseconds(1_500), + IdleTimeout = TimeSpan.FromSeconds(2), + }; + using var harness = new JsonlProcessHarness("no-response"); + await using var client = await ConnectAsync(harness, limits); + + var exception = await Assert.ThrowsAsync( + () => client.ListThreadsAsync(Params("timeout"))); + + Assert.Equal(CodexAppServerTimeoutKind.Request, exception.Kind); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task RequestDeadline_IncludesBlockedPartialFrameWriteAndReplacesSession() + { + var writeGate = new PartialFrameWriteGate(); + var limits = DefaultTestLimits with + { + RequestTimeout = TimeSpan.FromMilliseconds(1_500), + IdleTimeout = TimeSpan.FromSeconds(2), + }; + using var harness = new JsonlProcessHarness( + "success", + wrapProcess: (attempt, process) => attempt == 1 + ? new WriteGatedProcess(process, writeGate) + : process); + await using var client = await ConnectAsync(harness, limits); + using var safety = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + var blockedRead = client.ListThreadsAsync(Params("blocked"), safety.Token); + await writeGate.Entered.WaitAsync(safety.Token); + var exception = await Assert.ThrowsAsync( + async () => await blockedRead); + + Assert.Equal(CodexAppServerTimeoutKind.Request, exception.Kind); + Assert.False(safety.IsCancellationRequested); + await harness.AssertProcessExitedAsync(0); + var recovered = await client.ListThreadsAsync(Params("recovered"), safety.Token); + Assert.Equal("recovered", recovered.GetProperty("tag").GetString()); + Assert.Equal(2, harness.StartCount); + } + + [Fact] + public async Task CallerCancellation_DuringPartialFrameWriteFailsSessionAndRemovesPendingRequest() + { + var writeGate = new PartialFrameWriteGate(); + using var harness = new JsonlProcessHarness( + "success", + wrapProcess: (attempt, process) => attempt == 1 + ? new WriteGatedProcess(process, writeGate) + : process); + await using var client = await ConnectAsync(harness); + using var cancellation = new CancellationTokenSource(); + using var safety = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + var canceledRead = client.ListThreadsAsync(Params("cancel"), cancellation.Token); + await writeGate.Entered.WaitAsync(safety.Token); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => await canceledRead); + + await harness.AssertProcessExitedAsync(0); + var recovered = await client.ListThreadsAsync(Params("recovered"), safety.Token); + Assert.Equal("recovered", recovered.GetProperty("tag").GetString()); + Assert.Equal(2, harness.StartCount); + } + + [Fact] + public async Task IdleTimeout_FailsReadAfterOutputStopsAndCleansUpProcess() + { + var limits = DefaultTestLimits with + { + RequestTimeout = TimeSpan.FromSeconds(3), + IdleTimeout = TimeSpan.FromMilliseconds(800), + }; + using var harness = new JsonlProcessHarness("idle"); + await using var client = await ConnectAsync(harness, limits); + + var exception = await Assert.ThrowsAsync( + () => client.ListThreadsAsync(Params("idle"))); + + Assert.Equal(CodexAppServerTimeoutKind.Idle, exception.Kind); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task TransportExitBeforeResponseBytes_RetriesExactlyOnceWithCleanInitialization() + { + using var harness = new JsonlProcessHarness("retry-before-response"); + await using var client = await ConnectAsync(harness); + + var result = await client.ListThreadsAsync(Params("retried")); + + Assert.Equal("retried", result.GetProperty("tag").GetString()); + Assert.Equal(2, harness.StartCount); + Assert.Equal(2, harness.ClientMessages().Count(message => + message.TryGetProperty("method", out var method) + && method.GetString() == "initialize")); + Assert.Equal(2, harness.ClientMessages().Count(message => + message.TryGetProperty("method", out var method) + && method.GetString() == "thread/list")); + } + + [Fact] + public async Task TransportExitAfterPartialResponseBytes_DoesNotRetry() + { + using var harness = new JsonlProcessHarness("partial-response"); + await using var client = await ConnectAsync(harness); + + var exception = await Assert.ThrowsAsync( + () => client.ListThreadsAsync(Params("partial"))); + + Assert.True(exception.ResponseBytesObserved); + Assert.Equal(1, harness.StartCount); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task TransportExitAfterCompleteNotification_DoesNotRetry() + { + using var harness = new JsonlProcessHarness("notification-then-exit"); + await using var client = await ConnectAsync(harness); + + var exception = await Assert.ThrowsAsync( + () => client.ListThreadsAsync(Params("notification"))); + + Assert.True(exception.ResponseBytesObserved); + Assert.Equal(1, harness.StartCount); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task TransportExitOnSecondAttempt_IsSurfacedWithoutThirdStart() + { + using var harness = new JsonlProcessHarness("retry-both-attempts"); + await using var client = await ConnectAsync(harness); + + var exception = await Assert.ThrowsAsync( + () => client.ListThreadsAsync(Params("twice"))); + + Assert.False(exception.ResponseBytesObserved); + Assert.Equal(2, harness.StartCount); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task DisposeDuringRestart_DoesNotPublishOrLeakReplacementSession() + { + using var harness = new JsonlProcessHarness( + "retry-before-response", + blockStartAttempt: 2); + var client = await ConnectAsync(harness); + using var safety = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var read = client.ListThreadsAsync(Params("restart"), safety.Token); + await harness.BlockedStartEntered.WaitAsync(safety.Token); + var disposal = client.DisposeAsync().AsTask(); + harness.ReleaseBlockedStart(); + + await Assert.ThrowsAnyAsync(async () => await read); + await disposal.WaitAsync(safety.Token); + await harness.AssertAllProcessesExitedAsync(); + Assert.Equal(2, harness.StartCount); + } + + [Fact] + public async Task AggregateOperationBytesOverLimit_AreRejected() + { + using var harness = new JsonlProcessHarness("operation-oversized"); + await using var client = await ConnectAsync(harness); + + var exception = await Assert.ThrowsAsync( + () => client.ListThreadsAsync(Params("operation-oversized"))); + + Assert.Contains("operation byte limit", exception.Message, StringComparison.OrdinalIgnoreCase); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task ResponseBytesOverPerRequestLimit_AreRejectedAndCleanUpProcess() + { + var limits = DefaultTestLimits with { MaxResponseBytes = 128 }; + using var harness = new JsonlProcessHarness("response-oversized"); + await using var client = await ConnectAsync(harness, limits); + + var exception = await Assert.ThrowsAsync( + () => client.ListThreadsAsync(Params("response-oversized"))); + + Assert.Contains("response byte limit", exception.Message, StringComparison.OrdinalIgnoreCase); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task DisposeAsync_TerminatesAStillRunningProcessDeterministically() + { + using var harness = new JsonlProcessHarness("success"); + var client = await ConnectAsync(harness); + _ = await client.ListThreadsAsync(Params("dispose")); + + await client.DisposeAsync(); + + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task DisposeAsync_KillsTheRealGrandchildProcessTree() + { + using var harness = new JsonlProcessHarness("grandchild"); + var client = await ConnectAsync(harness); + _ = await client.ListThreadsAsync(Params("tree")); + var grandchildId = int.Parse( + await harness.WaitForMarkerValueAsync("grandchild"), + System.Globalization.CultureInfo.InvariantCulture); + Assert.True(JsonlProcessHarness.IsProcessRunning(grandchildId)); + + await client.DisposeAsync(); + + await harness.AssertProcessIdExitedAsync(grandchildId); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task DisposeAsync_SurfacesFailureWhenKilledProcessDoesNotExitByDeadline() + { + var limits = DefaultTestLimits with { CleanupTimeout = TimeSpan.FromMilliseconds(200) }; + using var harness = new JsonlProcessHarness( + "success", + wrapProcess: (_, process) => new SuppressedKillProcess(process)); + var client = await ConnectAsync(harness, limits); + _ = await client.ListThreadsAsync(Params("unkillable")); + + var exception = await Assert.ThrowsAsync( + async () => await client.DisposeAsync()); + + Assert.Contains("did not exit", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + private static JsonElement Params(string tag) => + JsonSerializer.SerializeToElement(new { tag }); + + private static string BuildRawResult(int targetUtf8Bytes) + { + const string prefix = "{\"tag\":\"payload\",\"padding\":\""; + const string suffix = "\"}"; + var paddingLength = targetUtf8Bytes - prefix.Length - suffix.Length; + Assert.True(paddingLength >= 0); + var result = prefix + new string('x', paddingLength) + suffix; + Assert.Equal(targetUtf8Bytes, Encoding.UTF8.GetByteCount(result)); + return result; + } + + private static Task ConnectAsync( + JsonlProcessHarness harness, + CodexAppServerLimits? limits = null) => + CodexAppServerClient.ConnectAsync( + new CodexLaunchPlan(Path.Combine(Path.GetTempPath(), "codex.exe")), + harness, + limits ?? DefaultTestLimits, + CancellationToken.None); + + private sealed class JsonlProcessHarness : ICodexAppServerProcessFactory, IDisposable + { + private const string Script = """ + param([string]$Scenario, [int]$Attempt, [string]$RecordPath, [string]$PayloadPath) + $ErrorActionPreference = 'Stop' + function Record([string]$Kind, [string]$Value) { + Add-Content -LiteralPath $RecordPath -Value ("$Attempt|$Kind|$Value") -Encoding utf8 + } + function Read-Message { + $line = [Console]::In.ReadLine() + if ($null -eq $line) { exit 80 } + Record 'in' $line + return ($line | ConvertFrom-Json) + } + function Write-Message($Value) { + $json = $Value | ConvertTo-Json -Compress -Depth 30 + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() + } + function Write-Result($Request) { + Write-Message @{ id = [long]$Request.id; result = @{ tag = [string]$Request.params.tag } } + } + function Write-RawResult($Request) { + $payload = [IO.File]::ReadAllText($PayloadPath) + [Console]::Out.Write('{"id":' + [long]$Request.id + ',"result":' + $payload + '}' + "`n") + [Console]::Out.Flush() + } + + $initialize = Read-Message + if ($initialize.method -ne 'initialize') { exit 81 } + Write-Message @{ id = [long]$initialize.id; result = @{ userAgent = 'fake'; codexHome = 'C:\fake'; platformFamily = 'windows'; platformOs = 'windows' } } + $initialized = Read-Message + if ($initialized.method -ne 'initialized') { exit 82 } + + if ($Scenario -eq 'out-of-order' -or $Scenario -eq 'duplicate-id') { + $first = Read-Message + $second = Read-Message + Write-Result $second + if ($Scenario -eq 'duplicate-id') { + Write-Result $second + Start-Sleep -Seconds 30 + exit 0 + } + Start-Sleep -Milliseconds 30 + Write-Result $first + Start-Sleep -Seconds 30 + exit 0 + } + + if ($Scenario -eq 'concurrent-catalog-large') { + $first = Read-Message + $second = Read-Message + Write-RawResult $first + Write-Message @{ id = [long]$second.id; result = @{ tag = [string]$second.params.tag; padding = ('s' * 9000) } } + Start-Sleep -Seconds 30 + exit 0 + } + + $request = Read-Message + switch ($Scenario) { + 'malformed' { + [Console]::Out.WriteLine('{not-json}') + [Console]::Out.Flush() + } + 'oversized' { + [Console]::Out.WriteLine(('x' * 700)) + [Console]::Out.Flush() + } + 'approval' { + Write-Message @{ id = 900; method = 'item/commandExecution/requestApproval'; params = @{ command = @('danger') } } + $refusal = Read-Message + Write-Result $request + } + 'elicitation' { + Write-Message @{ id = 900; method = 'mcpServer/elicitation/request'; params = @{ message = 'secret' } } + $refusal = Read-Message + Write-Result $request + } + 'stderr' { + [Console]::Error.Write(('a' * 200) + ('z' * 128)) + [Console]::Error.Flush() + Record 'marker' 'stderr-written' + Write-Result $request + } + 'no-response' { + Start-Sleep -Seconds 30 + } + 'idle' { + Write-Message @{ method = 'server/pulse'; params = @{ value = 1 } } + Start-Sleep -Seconds 30 + } + 'retry-before-response' { + if ($Attempt -eq 1) { exit 9 } + Write-Result $request + } + 'retry-both-attempts' { + exit 9 + } + 'partial-response' { + [Console]::Out.Write('{"id":') + [Console]::Out.Flush() + exit 9 + } + 'notification-then-exit' { + Write-Message @{ method = 'server/pulse'; params = @{ value = 1 } } + exit 9 + } + 'notification-emitted-at' { + Write-Message @{ method = 'remoteControl/status/changed'; params = @{ status = 'disconnected' }; emittedAtMs = [long]1786521411098 } + Write-Result $request + } + 'operation-oversized' { + for ($i = 0; $i -lt 20; $i++) { + Write-Message @{ method = 'server/noise'; params = @{ payload = ('n' * 250) } } + } + } + 'response-oversized' { + Write-Message @{ id = [long]$request.id; result = @{ tag = [string]$request.params.tag; payload = ('r' * 250) } } + } + 'payload-response' { + Write-RawResult $request + } + 'grandchild' { + $child = Start-Process -FilePath 'powershell.exe' -ArgumentList '-NoLogo','-NoProfile','-NonInteractive','-Command','Start-Sleep -Seconds 30' -WindowStyle Hidden -PassThru + Record 'marker' ("grandchild:$($child.Id)") + Write-Result $request + } + default { + Write-Result $request + } + } + Start-Sleep -Seconds 30 + """; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"openclaw-codex-jsonl-{Guid.NewGuid():N}"); + private readonly string _scenario; + private readonly string _scriptPath; + private readonly string _recordPath; + private readonly string _payloadPath; + private readonly List _processIds = []; + private readonly Func? _wrapProcess; + private readonly int? _blockStartAttempt; + private readonly TaskCompletionSource _blockedStartEntered = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly ManualResetEventSlim _releaseBlockedStart = new(initialState: false); + + public JsonlProcessHarness( + string scenario, + string? rawResult = null, + Func? wrapProcess = null, + int? blockStartAttempt = null) + { + _scenario = scenario; + _wrapProcess = wrapProcess; + _blockStartAttempt = blockStartAttempt; + Directory.CreateDirectory(_root); + _scriptPath = Path.Combine(_root, "fake-app-server.ps1"); + _recordPath = Path.Combine(_root, "record.txt"); + _payloadPath = Path.Combine(_root, "payload.json"); + File.WriteAllText(_scriptPath, Script); + if (rawResult is not null) + File.WriteAllText(_payloadPath, rawResult, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + public int StartCount { get; private set; } + + public Task BlockedStartEntered => _blockedStartEntered.Task; + + public ICodexAppServerProcess Start(CodexLaunchPlan launchPlan) + { + StartCount++; + if (_blockStartAttempt == StartCount) + { + _blockedStartEntered.TrySetResult(); + _releaseBlockedStart.Wait(); + } + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-ExecutionPolicy"); + startInfo.ArgumentList.Add("Bypass"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(_scriptPath); + startInfo.ArgumentList.Add(_scenario); + startInfo.ArgumentList.Add(StartCount.ToString(System.Globalization.CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(_recordPath); + startInfo.ArgumentList.Add(_payloadPath); + var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Fake process did not start."); + _processIds.Add(process.Id); + var appServerProcess = new CodexAppServerProcess(process); + return _wrapProcess?.Invoke(StartCount, appServerProcess) ?? appServerProcess; + } + + public void ReleaseBlockedStart() => _releaseBlockedStart.Set(); + + public IReadOnlyList ClientMessages() + { + if (!File.Exists(_recordPath)) + return []; + + return File.ReadAllLines(_recordPath) + .Where(line => line.Contains("|in|", StringComparison.Ordinal)) + .Select(line => line[(line.IndexOf("|in|", StringComparison.Ordinal) + 4)..]) + .Select(line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + } + + public async Task WaitForMarkerAsync(string marker) + { + var deadline = Stopwatch.StartNew(); + while (deadline.Elapsed < TimeSpan.FromSeconds(2)) + { + if (File.Exists(_recordPath) + && File.ReadAllText(_recordPath).Contains($"|marker|{marker}", StringComparison.Ordinal)) + { + return; + } + + await Task.Delay(20); + } + + throw new Xunit.Sdk.XunitException($"Timed out waiting for fake process marker '{marker}'."); + } + + public async Task WaitForMarkerValueAsync(string marker) + { + var deadline = Stopwatch.StartNew(); + while (deadline.Elapsed < TimeSpan.FromSeconds(3)) + { + if (File.Exists(_recordPath)) + { + var prefix = $"|marker|{marker}:"; + var line = File.ReadAllLines(_recordPath) + .FirstOrDefault(value => value.Contains(prefix, StringComparison.Ordinal)); + if (line is not null) + return line[(line.IndexOf(prefix, StringComparison.Ordinal) + prefix.Length)..]; + } + + await Task.Delay(20); + } + + throw new Xunit.Sdk.XunitException($"Timed out waiting for fake process marker '{marker}'."); + } + + public Task AssertProcessExitedAsync(int processIndex) => + AssertProcessIdExitedAsync(_processIds[processIndex]); + + public async Task AssertProcessIdExitedAsync(int processId) + { + var deadline = Stopwatch.StartNew(); + while (deadline.Elapsed < TimeSpan.FromSeconds(2) && IsProcessRunning(processId)) + await Task.Delay(20); + + Assert.False(IsProcessRunning(processId), $"Process {processId} is still running."); + } + + public async Task AssertAllProcessesExitedAsync() + { + var deadline = Stopwatch.StartNew(); + while (deadline.Elapsed < TimeSpan.FromSeconds(2) && _processIds.Any(IsProcessRunning)) + await Task.Delay(20); + + Assert.All(_processIds, id => Assert.False(IsProcessRunning(id), $"Process {id} is still running.")); + } + + public void Dispose() + { + _releaseBlockedStart.Set(); + var cleanupIds = _processIds.Concat(RecordedGrandchildIds()).Distinct().ToArray(); + foreach (var processId in cleanupIds.Where(IsProcessRunning)) + { + try + { + using var process = Process.GetProcessById(processId); + process.Kill(entireProcessTree: true); + process.WaitForExit(2_000); + } + catch (ArgumentException) + { + } + } + + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + _releaseBlockedStart.Dispose(); + } + + private IEnumerable RecordedGrandchildIds() + { + if (!File.Exists(_recordPath)) + return []; + + const string marker = "|marker|grandchild:"; + return File.ReadAllLines(_recordPath) + .Where(line => line.Contains(marker, StringComparison.Ordinal)) + .Select(line => line[(line.IndexOf(marker, StringComparison.Ordinal) + marker.Length)..]) + .Select(value => int.TryParse( + value, + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var processId) + ? processId + : -1) + .Where(processId => processId > 0); + } + + public static bool IsProcessRunning(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + } + } + + private sealed class PartialFrameWriteGate + { + public TaskCompletionSource EnteredSource { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Entered => EnteredSource.Task; + } + + private sealed class WriteGatedProcess : DelegatingProcess + { + private readonly Stream _standardInput; + + public WriteGatedProcess(ICodexAppServerProcess inner, PartialFrameWriteGate gate) + : base(inner) + { + _standardInput = new PartialFrameBlockingStream(inner.StandardInput, gate); + } + + public override Stream StandardInput => _standardInput; + } + + private sealed class SuppressedKillProcess : DelegatingProcess + { + public SuppressedKillProcess(ICodexAppServerProcess inner) + : base(inner) + { + } + + public override void KillProcessTree() + { + } + } + + private class DelegatingProcess : ICodexAppServerProcess + { + protected DelegatingProcess(ICodexAppServerProcess inner) + { + Inner = inner; + } + + protected ICodexAppServerProcess Inner { get; } + + public virtual Stream StandardInput => Inner.StandardInput; + + public virtual Stream StandardOutput => Inner.StandardOutput; + + public virtual Stream StandardError => Inner.StandardError; + + public virtual bool HasExited => Inner.HasExited; + + public virtual void CloseStandardInput() => Inner.CloseStandardInput(); + + public virtual void KillProcessTree() => Inner.KillProcessTree(); + + public virtual Task WaitForExitAsync(CancellationToken cancellationToken) => + Inner.WaitForExitAsync(cancellationToken); + + public virtual void Dispose() => Inner.Dispose(); + } + + private sealed class PartialFrameBlockingStream : Stream + { + private readonly Stream _inner; + private readonly PartialFrameWriteGate _gate; + private int _blocked; + + public PartialFrameBlockingStream(Stream inner, PartialFrameWriteGate gate) + { + _inner = inner; + _gate = gate; + } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + _inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + var isReadRequest = Encoding.UTF8.GetString(buffer.Span) + .Contains("\"method\":\"thread/list\"", StringComparison.Ordinal); + if (!isReadRequest || Interlocked.Exchange(ref _blocked, 1) != 0) + { + await _inner.WriteAsync(buffer, cancellationToken); + return; + } + + var prefixLength = Math.Min(8, buffer.Length); + await _inner.WriteAsync(buffer[..prefixLength], CancellationToken.None); + await _inner.FlushAsync(CancellationToken.None); + _gate.EnteredSource.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/CodexCatalogPolicySurfaceTests.cs b/tests/OpenClaw.Shared.Tests/CodexCatalogPolicySurfaceTests.cs new file mode 100644 index 000000000..dbb2dbf05 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/CodexCatalogPolicySurfaceTests.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace OpenClaw.Shared.Tests; + +public sealed class CodexCatalogPolicySurfaceTests +{ + [Fact] + public void RawCodexAppServerSurfaces_AreInternalToThePermissionOwner() + { + var assembly = typeof(SettingsData).Assembly; + var resolver = RequiredType(assembly, "OpenClaw.Shared.Codex.CodexExecutableResolver"); + var client = RequiredType(assembly, "OpenClaw.Shared.Codex.CodexAppServerClient"); + var catalog = RequiredType(assembly, "OpenClaw.Shared.Codex.CodexSessionCatalogService"); + var capability = RequiredType(assembly, "OpenClaw.Shared.Capabilities.CodexSessionCapability"); + var mcpBridge = RequiredType(assembly, "OpenClaw.Shared.Mcp.McpToolBridge"); + + Assert.DoesNotContain( + assembly.ExportedTypes, + type => type == resolver || type == client || type == catalog || type == capability); + Assert.DoesNotContain( + client.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static), + method => method.Name.StartsWith("Connect", StringComparison.Ordinal) && method.IsPublic); + Assert.DoesNotContain( + client.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), + method => method.Name is "ListThreadsAsync" or "ListThreadTurnsAsync" && method.IsPublic); + Assert.DoesNotContain( + mcpBridge.GetMethods(BindingFlags.Public | BindingFlags.Instance), + method => method.Name == "HandleRequestAsync"); + + var friends = assembly.GetCustomAttributes() + .Select(attribute => attribute.AssemblyName.Split(',')[0]) + .ToArray(); + Assert.Equal( + [ + "OpenClaw.Shared.Tests", + "OpenClaw.Tray.Tests", + "OpenClaw.Tray.WinUI", + "OpenClaw.WinNode.Cli.Tests", + ], + friends.Order(StringComparer.Ordinal).ToArray()); + } + + [Fact] + public void HistoryCatalog_RemainsAnInternalCapabilityOperation() + { + var assembly = typeof(SettingsData).Assembly; + var capability = RequiredType(assembly, "OpenClaw.Shared.Capabilities.CodexSessionCapability"); + var catalog = RequiredType(assembly, "OpenClaw.Shared.Codex.CodexSessionCatalogService"); + + Assert.NotNull(capability.GetField( + "ThreadsHistoryListCommand", + BindingFlags.Public | BindingFlags.Static)); + Assert.DoesNotContain( + catalog.GetMethods(BindingFlags.Public | BindingFlags.Instance), + method => method.Name == "ListThreadHistoryAsync"); + } + + private static Type RequiredType(Assembly assembly, string name) => + assembly.GetType(name, throwOnError: true)!; +} diff --git a/tests/OpenClaw.Shared.Tests/CodexExecutableResolverTests.cs b/tests/OpenClaw.Shared.Tests/CodexExecutableResolverTests.cs new file mode 100644 index 000000000..c9f5ccdb3 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/CodexExecutableResolverTests.cs @@ -0,0 +1,256 @@ +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using OpenClaw.Shared.Codex; + +namespace OpenClaw.Shared.Tests; + +public sealed class CodexExecutableResolverTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"openclaw-codex-resolver-{Guid.NewGuid():N}"); + + public CodexExecutableResolverTests() + { + Directory.CreateDirectory(_root); + } + + public static TheoryData PackagedAliasLocalAppDataShapes() => new() + { + false, + true, + }; + + [Theory] + [MemberData(nameof(PackagedAliasLocalAppDataShapes))] + public void Resolve_PrefersThePackagedAppExecutionAlias(bool trailingSeparator) + { + var localAppData = Directory.CreateDirectory(Path.Combine(_root, "local-app-data")).FullName; + var aliasDirectory = Directory.CreateDirectory( + Path.Combine(localAppData, "Microsoft", "WindowsApps")).FullName; + var alias = CreateFile(aliasDirectory, "codex.exe"); + var pathDirectory = Directory.CreateDirectory(Path.Combine(_root, "path-bin")).FullName; + _ = CreateFile(pathDirectory, "codex.exe"); + var platform = new TestPlatform( + trailingSeparator ? localAppData + Path.DirectorySeparatorChar : localAppData, + pathDirectory, + attributes: path => string.Equals(path, alias, StringComparison.OrdinalIgnoreCase) + ? FileAttributes.Archive | FileAttributes.ReparsePoint + : File.GetAttributes(path)); + + var plan = new CodexExecutableResolver(platform).Resolve(); + + Assert.NotNull(plan); + Assert.Equal(Path.GetFullPath(alias), plan.ExecutablePath); + AssertLaunchBoundary(plan); + } + + [Fact] + public void Resolve_ReturnsCanonicalExistingCodexExeFromCurrentProcessPath() + { + var firstDirectory = Directory.CreateDirectory(Path.Combine(_root, "missing-bin")).FullName; + var secondDirectory = Directory.CreateDirectory( + Path.Combine(_root, "bin", "nested", "..")).FullName; + var executable = CreateFile(secondDirectory, "codex.exe"); + var platform = new TestPlatform( + Path.Combine(_root, "no-local-app-data"), + string.Join(Path.PathSeparator, firstDirectory, secondDirectory)); + + var plan = new CodexExecutableResolver(platform).Resolve(); + + Assert.NotNull(plan); + Assert.Equal(Path.GetFullPath(executable), plan.ExecutablePath); + AssertLaunchBoundary(plan); + } + + public static TheoryData UntrustedPathCandidates() => new() + { + "missing", + "directory-named-codex.exe", + "codex", + "codex.cmd", + "other.exe", + }; + + [Theory] + [MemberData(nameof(UntrustedPathCandidates))] + public void Resolve_RejectsMissingDirectoriesAndNonExecutableCandidates(string candidateKind) + { + var pathDirectory = Path.Combine(_root, candidateKind); + string pathValue; + if (candidateKind == "missing") + { + pathValue = pathDirectory; + } + else + { + Directory.CreateDirectory(pathDirectory); + if (candidateKind == "directory-named-codex.exe") + Directory.CreateDirectory(Path.Combine(pathDirectory, "codex.exe")); + else + _ = CreateFile(pathDirectory, candidateKind); + pathValue = pathDirectory; + } + + var plan = new CodexExecutableResolver(new TestPlatform( + Path.Combine(_root, "no-local-app-data"), + pathValue)).Resolve(); + + Assert.Null(plan); + } + + [Theory] + [InlineData("relative-bin")] + [InlineData("..\\outside-bin")] + public void Resolve_RejectsRelativeAndTraversalPathEntries(string pathEntry) + { + var platform = new TestPlatform( + Path.Combine(_root, "no-local-app-data"), + pathEntry); + + Assert.Null(new CodexExecutableResolver(platform).Resolve()); + } + + [Fact] + public void Resolve_RejectsFullyQualifiedPathEntryContainingTraversal() + { + var pathDirectory = Directory.CreateDirectory( + Path.Combine(_root, "path-parent", "bin")).FullName; + _ = CreateFile(pathDirectory, "codex.exe"); + var traversalPath = Path.Combine( + _root, + "path-parent", + "unused", + "..", + "bin"); + var platform = new TestPlatform( + Path.Combine(_root, "no-local-app-data"), + traversalPath); + + Assert.True(Path.IsPathFullyQualified(traversalPath)); + Assert.Null(new CodexExecutableResolver(platform).Resolve()); + } + + [Fact] + public void Resolve_RejectsReparsePointCandidateOutsideThePackagedAliasDirectory() + { + var pathDirectory = Directory.CreateDirectory(Path.Combine(_root, "path-bin")).FullName; + var executable = CreateFile(pathDirectory, "codex.exe"); + var platform = new TestPlatform( + Path.Combine(_root, "no-local-app-data"), + pathDirectory, + attributes: path => string.Equals(path, executable, StringComparison.OrdinalIgnoreCase) + ? FileAttributes.Archive | FileAttributes.ReparsePoint + : File.GetAttributes(path)); + + Assert.Null(new CodexExecutableResolver(platform).Resolve()); + } + + [Fact] + public void Resolve_HasNoCallerSuppliedExecutableCandidate() + { + var resolveMethods = typeof(CodexExecutableResolver) + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(method => method.Name == nameof(CodexExecutableResolver.Resolve)) + .ToArray(); + + Assert.Single(resolveMethods); + Assert.Empty(resolveMethods[0].GetParameters()); + Assert.False(resolveMethods[0].IsPublic); + Assert.DoesNotContain( + typeof(CodexExecutableResolver).GetConstructors( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic), + constructor => constructor.IsPublic); + } + + [Fact] + public void CreateProcessStartInfo_UsesArgumentListAndRedirectedStdio() + { + var pathDirectory = Directory.CreateDirectory(Path.Combine(_root, "path-bin")).FullName; + var executable = CreateFile(pathDirectory, "codex.exe"); + var plan = new CodexExecutableResolver(new TestPlatform( + Path.Combine(_root, "no-local-app-data"), + pathDirectory)).Resolve(); + + Assert.NotNull(plan); + var startInfo = plan.CreateProcessStartInfo(); + + Assert.Equal(executable, startInfo.FileName); + Assert.Equal(new[] { "app-server", "--listen", "stdio://" }, startInfo.ArgumentList); + Assert.True(startInfo.RedirectStandardInput); + Assert.True(startInfo.RedirectStandardOutput); + Assert.True(startInfo.RedirectStandardError); + Assert.False(startInfo.UseShellExecute); + Assert.Equal(string.Empty, startInfo.Arguments); + } + + [Fact] + public void ProcessFactory_RejectsAnExecutableThatIsNoLongerTrustedAtStartTime() + { + var pathDirectory = Directory.CreateDirectory(Path.Combine(_root, "path-bin")).FullName; + var executable = CreateFile(pathDirectory, "codex.exe"); + var platform = new TestPlatform(Path.Combine(_root, "no-local-app-data"), pathDirectory); + var plan = new CodexExecutableResolver(platform).Resolve(); + + Assert.NotNull(plan); + File.Delete(executable); + + Assert.Throws(() => + new CodexAppServerProcessFactory().Start(plan)); + } + + private static void AssertLaunchBoundary(CodexLaunchPlan plan) + { + Assert.Equal(new[] { "app-server", "--listen", "stdio://" }, plan.Arguments); + Assert.Empty(plan.EnvironmentOverrides); + Assert.False(plan.UseShellExecute); + Assert.True(plan.RedirectStandardInput); + Assert.True(plan.RedirectStandardOutput); + Assert.True(plan.RedirectStandardError); + + Assert.Throws(() => + ((IList)plan.Arguments)[0] = "exec"); + Assert.Throws(() => + ((IDictionary)plan.EnvironmentOverrides).Add("CODEX_HOME", "untrusted")); + } + + private static string CreateFile(string directory, string fileName) + { + var path = Path.GetFullPath(Path.Combine(directory, fileName)); + File.WriteAllBytes(path, []); + return path; + } + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + } + + private sealed class TestPlatform : ICodexExecutablePlatform + { + private readonly Func _attributes; + + public TestPlatform( + string? localApplicationData, + string? pathEnvironment, + Func? attributes = null) + { + LocalApplicationData = localApplicationData; + PathEnvironment = pathEnvironment; + _attributes = attributes ?? File.GetAttributes; + } + + public string? LocalApplicationData { get; } + + public string? PathEnvironment { get; } + + public string GetFullPath(string path) => Path.GetFullPath(path); + + public bool IsPathFullyQualified(string path) => Path.IsPathFullyQualified(path); + + public bool FileExists(string path) => File.Exists(path); + + public FileAttributes GetAttributes(string path) => _attributes(path); + } +} diff --git a/tests/OpenClaw.Shared.Tests/CodexSessionCapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CodexSessionCapabilityTests.cs new file mode 100644 index 000000000..652a04422 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/CodexSessionCapabilityTests.cs @@ -0,0 +1,1143 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared.Capabilities; +using OpenClaw.Shared.Codex; + +namespace OpenClaw.Shared.Tests; + +public sealed class CodexSessionCapabilityTests +{ + private const string ThreadId = "123e4567-e89b-12d3-a456-426614174000"; + + [Fact] + public void Commands_ExposeExactlyTheThreeReadOnlyCatalogOperations() + { + var capability = CreateCapability(new RecordingCatalogClient()); + + Assert.Equal( + [ + "codex.appServer.threads.list.v1", + "codex.appServer.threads.history.list.v1", + "codex.appServer.thread.turns.list.v1", + ], + capability.Commands); + Assert.Equal("codex-app-server-threads", capability.Category); + } + + [Fact] + public async Task ThreadsHistoryList_RequiresExplicitArchivedAndProjectsOnlyCatalogMetadata() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = Json(""" + { + "data": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "Archived work", + "preview": "must not become a transcript", + "status": { "type": "idle" }, + "source": "cli", + "archived": true, + "turns": [{ "body": "private" }], + "private": "do-not-forward" + }, + { + "id": "123e4567-e89b-12d3-a456-426614174001", + "name": "Current work", + "status": { "type": "idle" }, + "source": "cli", + "archived": false + } + ] + } + """), + }; + + var response = await ExecuteAsync( + CreateCapability(client), + "codex.appServer.threads.history.list.v1", + """{"archived":true,"limit":1,"searchTerm":"archived"}"""); + + Assert.True(response.Ok, response.Error); + AssertJsonEqual( + """ + { + "sessions": [ + { + "threadId": "123e4567-e89b-12d3-a456-426614174000", + "status": "idle", + "archived": true, + "name": "Archived work", + "source": "cli" + } + ] + } + """, + PayloadJson(response)); + AssertJsonEqual( + """{"limit":1,"modelProviders":[],"sortKey":"updated_at","sortDirection":"desc","archived":true,"useStateDbOnly":false}""", + client.Parameters.Single()); + Assert.DoesNotContain("private", PayloadJson(response).GetRawText(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ThreadsHistoryList_CurrentPartitionSearchesTheCompleteCatalog() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = Json(""" + { + "data": [ + { "id": "123e4567-e89b-12d3-a456-426614174000", "name": "Current work", "status": { "type": "idle" }, "source": "cli", "archived": false } + ] + } + """), + }; + + var response = await ExecuteAsync( + CreateCapability(client), + CodexSessionCapability.ThreadsHistoryListCommand, + """{"archived":false,"limit":1}"""); + + Assert.True(response.Ok, response.Error); + Assert.False(client.Parameters.Single().GetProperty("useStateDbOnly").GetBoolean()); + } + + [Fact] + public async Task ThreadsList_ProjectsTheLiteralCoreCatalogFixtureWithoutPrivateFields() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = Json(""" + { + "data": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "sessionId": "cli-session-1", + "name": "Remote task", + "preview": "must stay private", + "cwd": "C:\\workspace\\project", + "status": { "type": "active", "activeFlags": ["waitingOnApproval"] }, + "source": "vscode", + "modelProvider": "openai", + "cliVersion": "1.2.3", + "createdAt": 123, + "updatedAt": 456, + "recencyAt": 455, + "gitInfo": { "branch": "feature/catalog", "originUrl": "private" }, + "turns": [{ "private": true }], + "path": "C:\\private\\rollout.jsonl" + } + ], + "nextCursor": "page-2", + "backwardsCursor": "page-0" + } + """), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.threads.list.v1", + """{"cursor":"page-1","limit":25,"cwd":"C:\\workspace\\project"}"""); + + Assert.True(response.Ok, response.Error); + AssertJsonEqual( + """ + { + "sessions": [ + { + "threadId": "123e4567-e89b-12d3-a456-426614174000", + "status": "active", + "archived": false, + "sessionId": "cli-session-1", + "name": "Remote task", + "cwd": "C:\\workspace\\project", + "activeFlags": ["waitingOnApproval"], + "createdAt": 123, + "updatedAt": 456, + "recencyAt": 455, + "source": "vscode", + "modelProvider": "openai", + "cliVersion": "1.2.3", + "gitBranch": "feature/catalog" + } + ], + "nextCursor": "page-2", + "backwardsCursor": "page-0" + } + """, + PayloadJson(response)); + Assert.Equal([CodexAppServerProtocol.ThreadListMethod], client.Methods); + AssertJsonEqual( + """ + { + "cursor": "page-1", + "limit": 25, + "modelProviders": [], + "sortKey": "updated_at", + "sortDirection": "desc", + "archived": false, + "useStateDbOnly": true, + "cwd": "C:\\workspace\\project" + } + """, + client.Parameters.Single()); + Assert.DoesNotContain("private", PayloadJson(response).GetRawText(), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("thread/resume", client.Methods); + } + + [Fact] + public async Task ThreadTurnsList_RequiresFreshEligibilityAndProjectsTheLiteralCoreFixture() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = EligibleThreadsPage(), + TurnsResponse = Json(""" + { + "data": [ + { + "id": "turn-1", + "status": "completed", + "itemsView": "full", + "items": [ + { "id": "item-1", "type": "agentMessage", "text": "bounded answer" } + ] + } + ], + "nextCursor": "turns-page-2" + } + """), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.thread.turns.list.v1", + $$"""{"threadId":"{{ThreadId}}","cursor":"turns-page-1","limit":25}"""); + + Assert.True(response.Ok, response.Error); + AssertJsonEqual(client.TurnsResponse.GetRawText(), PayloadJson(response)); + Assert.Equal( + [CodexAppServerProtocol.ThreadListMethod, CodexAppServerProtocol.ThreadTurnsListMethod], + client.Methods); + Assert.Equal(10, client.Parameters[0].GetProperty("limit").GetInt32()); + Assert.False(client.Parameters[0].TryGetProperty("useStateDbOnly", out _)); + AssertJsonEqual( + $$"""{"threadId":"{{ThreadId}}","cursor":"turns-page-1","limit":25,"sortDirection":"desc","itemsView":"full"}""", + client.Parameters[1]); + Assert.DoesNotContain("thread/resume", client.Methods); + } + + [Fact] + public async Task ThreadTurnsList_ProjectsOnlyTheAllowlistedTranscriptContract() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = EligibleThreadsPage(), + TurnsResponse = Json(""" + { + "data": [ + { + "id": "turn-1", + "status": "completed", + "itemsView": "full", + "privateTurnField": "do-not-forward", + "items": [ + { + "id": "item-1", + "type": "agentMessage", + "text": "bounded answer", + "title": "Answer", + "content": "visible content", + "clientId": "client-1", + "summary": "visible summary", + "commandActions": [{ "type": "run" }], + "arguments": { "safe": true }, + "privateItemField": "do-not-forward" + } + ] + } + ], + "nextCursor": "turns-page-2", + "privatePageField": "do-not-forward" + } + """), + }; + + var response = await ExecuteAsync( + CreateCapability(client), + CodexSessionCapability.ThreadTurnsListCommand, + $$"""{"threadId":"{{ThreadId}}"}"""); + + Assert.True(response.Ok, response.Error); + var payload = PayloadJson(response); + var item = payload.GetProperty("data")[0].GetProperty("items")[0]; + Assert.Equal("turn-1", payload.GetProperty("data")[0].GetProperty("id").GetString()); + Assert.Equal("item-1", item.GetProperty("id").GetString()); + Assert.Equal("agentMessage", item.GetProperty("type").GetString()); + Assert.Equal("full", payload.GetProperty("data")[0].GetProperty("itemsView").GetString()); + Assert.Equal("visible content", item.GetProperty("content").GetString()); + Assert.Equal("client-1", item.GetProperty("clientId").GetString()); + Assert.Equal("visible summary", item.GetProperty("summary").GetString()); + Assert.Equal("run", item.GetProperty("commandActions")[0].GetProperty("type").GetString()); + Assert.True(item.GetProperty("arguments").GetProperty("safe").GetBoolean()); + Assert.Equal("turns-page-2", payload.GetProperty("nextCursor").GetString()); + Assert.DoesNotContain("privateTurnField", payload.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("privateItemField", payload.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("privatePageField", payload.GetRawText(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("codex.appServer.threads.list.v1", "{\"unknown\":true}", "unknown Codex session catalog parameter")] + [InlineData("codex.appServer.threads.list.v1", "{\"limit\":0}", "limit must be an integer from 1 to 100")] + [InlineData("codex.appServer.threads.list.v1", "{\"limit\":101}", "limit must be an integer from 1 to 100")] + [InlineData("codex.appServer.threads.history.list.v1", "{}", "archived is required")] + [InlineData("codex.appServer.threads.history.list.v1", "{\"archived\":\"true\"}", "archived must be a boolean")] + [InlineData("codex.appServer.threads.history.list.v1", "{\"archived\":true,\"cwd\":\"C:\\\\work\"}", "unknown Codex session catalog parameter")] + [InlineData("codex.appServer.thread.turns.list.v1", "{\"threadId\":\"not-a-uuid\"}", "threadId must be a UUID")] + [InlineData("codex.appServer.thread.turns.list.v1", "{\"threadId\":\"123e4567-e89b-12d3-a456-426614174000\",\"extra\":1}", "unknown Codex session catalog parameter")] + [InlineData("codex.appServer.thread.turns.list.v1", "{\"threadId\":\"123e4567-e89b-12d3-a456-426614174000\",\"limit\":51}", "limit must be an integer from 1 to 50")] + public async Task InvalidParameters_AreRejectedBeforeAppServerIo( + string command, + string argsJson, + string expectedError) + { + var client = new RecordingCatalogClient(); + var capability = CreateCapability(client); + + var response = await ExecuteAsync(capability, command, argsJson); + + Assert.False(response.Ok); + Assert.Contains(expectedError, response.Error, StringComparison.Ordinal); + Assert.Empty(client.Methods); + } + + [Theory] + [InlineData("codex.appServer.threads.list.v1", "null", "Codex session catalog parameters must be an object")] + [InlineData("codex.appServer.threads.list.v1", "[]", "Codex session catalog parameters must be an object")] + [InlineData("codex.appServer.threads.history.list.v1", "null", "Codex session catalog parameters must be an object")] + [InlineData("codex.appServer.thread.turns.list.v1", "null", "Codex session read parameters must be an object")] + [InlineData("codex.appServer.thread.turns.list.v1", "[]", "Codex session read parameters must be an object")] + public async Task NullAndNonObjectParameters_AreRejectedBeforeAppServerIo( + string command, + string argsJson, + string expectedError) + { + var client = new RecordingCatalogClient(); + var capability = CreateCapability(client); + + var response = await ExecuteAsync(capability, command, argsJson); + + Assert.False(response.Ok); + Assert.Equal(expectedError, response.Error); + Assert.Empty(client.Methods); + } + + [Fact] + public async Task UndefinedParameters_RemainEquivalentToOmittedArguments() + { + var client = new RecordingCatalogClient(); + var capability = CreateCapability(client); + + var response = await capability.ExecuteAsync(new NodeInvokeRequest + { + Id = "request-1", + Command = "codex.appServer.threads.list.v1", + }); + + Assert.True(response.Ok, response.Error); + Assert.Single(client.Methods); + Assert.Equal(50, client.Parameters.Single().GetProperty("limit").GetInt32()); + } + + [Fact] + public async Task ThreadsList_TitleSearchWalksBoundedPagesWithoutSearchingPrivatePreviewText() + { + var client = new RecordingCatalogClient(); + client.EnqueueThreads(Json(""" + { + "data": [ + { + "id": "123e4567-e89b-12d3-a456-426614174001", + "name": "Unrelated", + "preview": "match only in private preview", + "status": { "type": "idle" }, + "source": "cli" + } + ], + "nextCursor": "opaque-page-2", + "backwardsCursor": "opaque-page-0" + } + """)); + client.EnqueueThreads(Json(""" + { + "data": [ + { + "id": "123e4567-e89b-12d3-a456-426614174002", + "name": "MATCH title", + "status": { "type": "idle" }, + "source": "vscode" + } + ] + } + """)); + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.threads.list.v1", + """{"limit":1,"searchTerm":"match"}"""); + + Assert.True(response.Ok, response.Error); + AssertJsonEqual( + """ + { + "sessions": [ + { + "threadId": "123e4567-e89b-12d3-a456-426614174002", + "status": "idle", + "archived": false, + "name": "MATCH title", + "source": "vscode" + } + ], + "backwardsCursor": "opaque-page-0" + } + """, + PayloadJson(response)); + Assert.Equal(2, client.ThreadsCallCount); + Assert.Equal("opaque-page-2", client.Parameters[1].GetProperty("cursor").GetString()); + Assert.All(client.Parameters, parameters => Assert.False(parameters.TryGetProperty("searchTerm", out _))); + Assert.DoesNotContain("private preview", PayloadJson(response).GetRawText(), StringComparison.Ordinal); + } + + [Fact] + public async Task ThreadsHistoryList_SearchWalksBoundedPagesAndHonorsTheRequestedLimit() + { + var client = new RecordingCatalogClient(); + client.EnqueueThreads(Json(""" + { + "data": [ + { "id": "123e4567-e89b-12d3-a456-426614174001", "name": "Unrelated", "status": { "type": "idle" }, "source": "cli", "archived": true } + ], + "nextCursor": "history-page-2" + } + """)); + client.EnqueueThreads(Json(""" + { + "data": [ + { "id": "123e4567-e89b-12d3-a456-426614174002", "name": "MATCH archive", "status": { "type": "idle" }, "source": "cli", "archived": true }, + { "id": "123e4567-e89b-12d3-a456-426614174003", "name": "MATCH overflow", "status": { "type": "idle" }, "source": "cli", "archived": true } + ] + } + """)); + + var response = await ExecuteAsync( + CreateCapability(client), + CodexSessionCapability.ThreadsHistoryListCommand, + """{"archived":true,"limit":1,"searchTerm":"match"}"""); + + Assert.True(response.Ok, response.Error); + var sessions = PayloadJson(response).GetProperty("sessions"); + var session = Assert.Single(sessions.EnumerateArray()); + Assert.Equal("123e4567-e89b-12d3-a456-426614174002", session.GetProperty("threadId").GetString()); + Assert.Equal(2, client.ThreadsCallCount); + Assert.All(client.Parameters, parameters => + { + Assert.True(parameters.GetProperty("archived").GetBoolean()); + Assert.InRange(parameters.GetProperty("limit").GetInt32(), 1, 100); + }); + } + + [Fact] + public async Task ThreadsList_RepeatedSearchCursorFailsClosedWithSanitizedError() + { + var client = new RecordingCatalogClient(); + client.EnqueueThreads(Json("""{"data":[],"nextCursor":"cycle"}""")); + client.EnqueueThreads(Json("""{"data":[],"nextCursor":"cycle"}""")); + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.threads.list.v1", + """{"limit":10,"searchTerm":"match"}"""); + + Assert.False(response.Ok); + Assert.Equal("Codex app-server catalog is unavailable", response.Error); + Assert.Equal(2, client.ThreadsCallCount); + } + + [Fact] + public async Task ThreadsList_NeverReturnsMoreSessionsThanTheRequestedLimit() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = Json(""" + { + "data": [ + { "id": "123e4567-e89b-12d3-a456-426614174001", "name": "Match one", "status": { "type": "idle" }, "source": "cli" }, + { "id": "123e4567-e89b-12d3-a456-426614174002", "name": "Match two", "status": { "type": "idle" }, "source": "cli" } + ], + "nextCursor": "opaque-page-2" + } + """), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.threads.list.v1", + """{"limit":1,"searchTerm":"match"}"""); + + Assert.True(response.Ok, response.Error); + Assert.Single(PayloadJson(response).GetProperty("sessions").EnumerateArray()); + Assert.Equal( + "123e4567-e89b-12d3-a456-426614174001", + PayloadJson(response).GetProperty("sessions")[0].GetProperty("threadId").GetString()); + Assert.Equal("opaque-page-2", PayloadJson(response).GetProperty("nextCursor").GetString()); + } + + [Fact] + public async Task ThreadsList_DirectPageNeverReturnsMoreSessionsThanTheRequestedLimit() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = Json(""" + { + "data": [ + { "id": "123e4567-e89b-12d3-a456-426614174001", "status": { "type": "idle" }, "source": "cli" }, + { "id": "123e4567-e89b-12d3-a456-426614174002", "status": { "type": "idle" }, "source": "cli" } + ], + "nextCursor": "opaque-page-2" + } + """), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.threads.list.v1", + """{"limit":1}"""); + + Assert.True(response.Ok, response.Error); + Assert.Single(PayloadJson(response).GetProperty("sessions").EnumerateArray()); + Assert.Equal("opaque-page-2", PayloadJson(response).GetProperty("nextCursor").GetString()); + } + + [Fact] + public async Task ThreadsList_OmitsArchivedAndUnsupportedThreads() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = Json(""" + { + "data": [ + { "id": "123e4567-e89b-12d3-a456-426614174001", "status": { "type": "idle" }, "source": "cli", "archived": true }, + { "id": "123e4567-e89b-12d3-a456-426614174002", "status": { "type": "idle" }, "source": "exec" }, + { "id": "123e4567-e89b-12d3-a456-426614174003", "status": { "type": "idle" }, "source": { "custom": "atlas" } }, + { "id": "123e4567-e89b-12d3-a456-426614174004", "status": { "type": "idle" }, "source": { "custom": "integration" } } + ] + } + """), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync(capability, "codex.appServer.threads.list.v1", "{}"); + + Assert.True(response.Ok, response.Error); + var sessions = PayloadJson(response).GetProperty("sessions"); + var session = Assert.Single(sessions.EnumerateArray()); + Assert.Equal("123e4567-e89b-12d3-a456-426614174003", session.GetProperty("threadId").GetString()); + Assert.Equal("atlas", session.GetProperty("source").GetString()); + } + + [Fact] + public async Task ThreadTurnsList_RechecksFreshCatalogEligibilityForEveryRead() + { + var client = new RecordingCatalogClient { TurnsResponse = Json("""{"data":[]}""") }; + client.EnqueueThreads(EligibleThreadsPage()); + client.EnqueueThreads(Json("""{"data":[]}""")); + var capability = CreateCapability(client); + + var first = await ExecuteAsync( + capability, + "codex.appServer.thread.turns.list.v1", + $$"""{"threadId":"{{ThreadId}}"}"""); + var second = await ExecuteAsync( + capability, + "codex.appServer.thread.turns.list.v1", + $$"""{"threadId":"{{ThreadId}}"}"""); + + Assert.True(first.Ok, first.Error); + Assert.False(second.Ok); + Assert.Equal("Codex session is not a non-archived interactive Codex session", second.Error); + Assert.Equal(2, client.ThreadsCallCount); + Assert.Equal(1, client.TurnsCallCount); + } + + [Fact] + public async Task ThreadTurnsList_RejectsEligibilityCursorCyclesBeforeTranscriptIo() + { + var client = new RecordingCatalogClient(); + client.EnqueueThreads(Json("""{"data":[],"nextCursor":"cycle"}""")); + client.EnqueueThreads(Json("""{"data":[],"nextCursor":"cycle"}""")); + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.thread.turns.list.v1", + $$"""{"threadId":"{{ThreadId}}"}"""); + + Assert.False(response.Ok); + Assert.Equal("Codex session eligibility could not be verified", response.Error); + Assert.Equal(2, client.ThreadsCallCount); + Assert.Equal(0, client.TurnsCallCount); + } + + [Fact] + public async Task ThreadTurnsList_RejectsEligibilityThatExceedsThePageCap() + { + var client = new RecordingCatalogClient(); + for (var page = 1; page <= 100; page++) + client.EnqueueThreads(Json($$"""{"data":[],"nextCursor":"page-{{page}}"}""")); + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.thread.turns.list.v1", + $$"""{"threadId":"{{ThreadId}}"}"""); + + Assert.False(response.Ok); + Assert.Equal("Codex session eligibility could not be verified", response.Error); + Assert.Equal(100, client.ThreadsCallCount); + Assert.Equal(0, client.TurnsCallCount); + } + + [Fact] + public async Task ThreadTurnsList_RejectsOneOversizedTextField() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = EligibleThreadsPage(), + TurnsResponse = JsonSerializer.SerializeToElement(new + { + data = new[] + { + new + { + id = "turn-1", + items = new[] + { + new + { + id = "item-1", + type = "agentMessage", + text = new string('x', 1_000_001), + }, + }, + }, + }, + }), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.thread.turns.list.v1", + $$"""{"threadId":"{{ThreadId}}"}"""); + + Assert.False(response.Ok); + Assert.Equal("Codex app-server transcript is unavailable", response.Error); + } + + [Fact] + public async Task ThreadTurnsList_RejectsAggregatePayloadOverTheByteLimit() + { + var text = new string('x', 1_000_000); + var client = new RecordingCatalogClient + { + ThreadsResponse = EligibleThreadsPage(), + TurnsResponse = JsonSerializer.SerializeToElement(new + { + data = Enumerable.Range(1, 22).Select(index => new + { + id = $"turn-{index}", + items = new[] { new { id = $"item-{index}", type = "agentMessage", text } }, + }), + }), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync( + capability, + "codex.appServer.thread.turns.list.v1", + $$"""{"threadId":"{{ThreadId}}","limit":50}"""); + + Assert.False(response.Ok); + Assert.Equal("Codex app-server transcript is unavailable", response.Error); + } + + [Fact] + public async Task ThreadsList_SanitizesAndBoundsFallbackMetadata() + { + var client = new RecordingCatalogClient + { + ThreadsResponse = JsonSerializer.SerializeToElement(new + { + data = new object[] + { + new + { + id = ThreadId, + name = (string?)null, + preview = "Investigate\n\u001b[31mfailed\u001b[0m\r " + + "\u009b32msafely\u009b0m " + + "\u009dprivate terminal title\u009c" + + "run", + cwd = new string('c', 4097), + status = new + { + type = "active", + activeFlags = Enumerable.Range(1, 18) + .Select(index => $"flag-{index}"), + }, + source = "cli", + modelProvider = new string('m', 501), + }, + }, + }), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync(capability, "codex.appServer.threads.list.v1", "{}"); + + Assert.True(response.Ok, response.Error); + var session = Assert.Single(PayloadJson(response).GetProperty("sessions").EnumerateArray()); + Assert.Equal(JsonValueKind.Null, session.GetProperty("name").ValueKind); + Assert.Equal("Investigate failed safely run", session.GetProperty("fallbackName").GetString()); + Assert.False(session.TryGetProperty("cwd", out _)); + Assert.Equal(16, session.GetProperty("activeFlags").GetArrayLength()); + Assert.Equal(500, session.GetProperty("modelProvider").GetString()!.Length); + } + + [Theory] + [InlineData("cursor", 4096)] + [InlineData("searchTerm", 500)] + [InlineData("cwd", 4096)] + public async Task ThreadsList_RejectsOversizedTextBeforeAppServerIo(string field, int maxLength) + { + var client = new RecordingCatalogClient(); + var capability = CreateCapability(client); + var args = JsonSerializer.Serialize(new Dictionary + { + [field] = new string('x', maxLength + 1), + }); + + var response = await ExecuteAsync(capability, "codex.appServer.threads.list.v1", args); + + Assert.False(response.Ok); + Assert.Equal($"{field} must be at most {maxLength} characters", response.Error); + Assert.Empty(client.Methods); + } + + [Fact] + public async Task AppServerFailures_ReturnOnlyStableSanitizedErrors() + { + var client = new RecordingCatalogClient + { + ThreadsException = new InvalidOperationException( + "private C:\\Users\\operator\\.codex path and transcript text"), + }; + var capability = CreateCapability(client); + + var response = await ExecuteAsync(capability, "codex.appServer.threads.list.v1", "{}"); + + Assert.False(response.Ok); + Assert.Equal("Codex app-server catalog is unavailable", response.Error); + Assert.DoesNotContain("operator", response.Error, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("transcript", response.Error, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(1_200_000, true)] + [InlineData(20 * 1024 * 1024, true)] + [InlineData((20 * 1024 * 1024) + 1, false)] + public async Task RealAdapter_BoundsTranscriptAfterJsonRpcEnvelopeOverhead( + int rawResultBytes, + bool expectedSuccess) + { + using var harness = new CatalogJsonlProcessHarness( + BuildTranscriptPage(rawResultBytes)); + await using var client = await CodexAppServerClient.ConnectCatalogAsync( + new CodexLaunchPlan(Path.Combine(Path.GetTempPath(), "codex.exe")), + harness, + CancellationToken.None); + var capability = new CodexSessionCapability( + NullLogger.Instance, + new CodexSessionCatalogService(client)); + + var response = await ExecuteAsync( + capability, + "codex.appServer.thread.turns.list.v1", + $$"""{"threadId":"{{ThreadId}}","limit":50}"""); + + Assert.Equal(expectedSuccess, response.Ok); + Assert.Equal( + expectedSuccess ? null : "Codex app-server transcript is unavailable", + response.Error); + Assert.Equal( + ["initialize", "initialized", "thread/list", "thread/turns/list"], + harness.RecordedMethods()); + Assert.DoesNotContain("thread/resume", harness.RecordedMethods()); + Assert.DoesNotContain("turn/steer", harness.RecordedMethods()); + Assert.DoesNotContain("turn/interrupt", harness.RecordedMethods()); + await harness.AssertAllProcessesExitedAfterDisposalAsync(client); + } + + [Theory] + [InlineData(1_200_000, true)] + [InlineData(20 * 1024 * 1024, true)] + [InlineData((20 * 1024 * 1024) + 1, false)] + public async Task RealAdapter_HistoryListRejectsOverBudgetAppServerPages( + int rawResultBytes, + bool expectedSuccess) + { + using var harness = new CatalogJsonlProcessHarness(BuildHistoryPage(rawResultBytes)); + await using var client = await CodexAppServerClient.ConnectCatalogAsync( + new CodexLaunchPlan(Path.Combine(Path.GetTempPath(), "codex.exe")), + harness, + CancellationToken.None); + var capability = new CodexSessionCapability( + NullLogger.Instance, + new CodexSessionCatalogService(client)); + + var response = await ExecuteAsync( + capability, + CodexSessionCapability.ThreadsHistoryListCommand, + """{"archived":true,"limit":1}"""); + + Assert.Equal(expectedSuccess, response.Ok); + Assert.Equal(expectedSuccess ? null : "Codex app-server catalog is unavailable", response.Error); + if (expectedSuccess) + { + var session = Assert.Single(PayloadJson(response).GetProperty("sessions").EnumerateArray()); + Assert.True(session.GetProperty("archived").GetBoolean()); + } + Assert.Equal(["initialize", "initialized", "thread/list"], harness.RecordedMethods()); + await harness.AssertAllProcessesExitedAfterDisposalAsync(client); + } + + [Fact] + public void CatalogTransportLimits_AreScopedAndIncludeSerializedJsonRpcFraming() + { + var result = Json("""{"data":[{"text":"escaped \\u009b and utf8 ☃"}]}"""); + var framedResponse = JsonSerializer.SerializeToUtf8Bytes(new + { + id = long.MinValue, + result, + }); + var rawResultBytes = Encoding.UTF8.GetByteCount(result.GetRawText()); + var serializedEnvelopeBytes = framedResponse.Length - rawResultBytes; + + Assert.InRange( + serializedEnvelopeBytes, + 1, + CodexSessionCatalogService.MaxJsonRpcEnvelopeBytes); + Assert.Equal(1_048_576, CodexAppServerLimits.Default.MaxLineBytes); + Assert.Equal( + CodexSessionCatalogService.MaxTranscriptPageBytes + + CodexSessionCatalogService.MaxJsonRpcEnvelopeBytes, + CodexAppServerLimits.Catalog.MaxLineBytes); + Assert.Equal( + CodexAppServerLimits.Catalog.MaxLineBytes, + CodexAppServerLimits.Catalog.MaxResponseBytes); + Assert.True( + CodexAppServerLimits.Catalog.MaxOperationBytes + > CodexAppServerLimits.Catalog.MaxResponseBytes); + Assert.Equal(TimeSpan.FromSeconds(60), CodexAppServerLimits.Catalog.RequestTimeout); + Assert.Equal(TimeSpan.FromSeconds(60), CodexAppServerLimits.Catalog.IdleTimeout); + Assert.Equal(TimeSpan.FromSeconds(20), CodexAppServerLimits.Default.RequestTimeout); + Assert.Equal(TimeSpan.FromSeconds(5), CodexAppServerLimits.Default.IdleTimeout); + } + + private static CodexSessionCapability CreateCapability(RecordingCatalogClient client) => + new(NullLogger.Instance, new CodexSessionCatalogService(client)); + + private static Task ExecuteAsync( + CodexSessionCapability capability, + string command, + string argsJson) => + capability.ExecuteAsync(new NodeInvokeRequest + { + Id = "request-1", + Command = command, + Args = Json(argsJson), + }); + + private static JsonElement PayloadJson(NodeInvokeResponse response) => + JsonSerializer.SerializeToElement(response.Payload); + + private static JsonElement EligibleThreadsPage() => Json($$""" + { + "data": [ + { + "id": "{{ThreadId}}", + "sessionId": "cli-session-1", + "name": "Remote task", + "preview": "private preview", + "cwd": "C:\\workspace\\project", + "status": { "type": "idle" }, + "source": "cli", + "modelProvider": "openai", + "cliVersion": "1.2.3", + "createdAt": 123, + "updatedAt": 456, + "recencyAt": 455, + "gitInfo": { "branch": "feature/catalog" }, + "turns": [] + } + ] + } + """); + + private static JsonElement Json(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static string BuildTranscriptPage(int targetUtf8Bytes) + { + var builder = new StringBuilder(targetUtf8Bytes); + builder.Append("{\"data\":["); + for (var index = 0; ; index++) + { + var prefix = $"{(index == 0 ? "" : ",")}{{\"id\":\"turn-{index}\",\"items\":[{{\"id\":\"item-{index}\",\"type\":\"agentMessage\",\"text\":\""; + const string itemSuffix = "\"}]}"; + const string pageSuffix = "]}"; + var remaining = targetUtf8Bytes + - builder.Length + - prefix.Length + - itemSuffix.Length + - pageSuffix.Length; + var textLength = Math.Min(1_000_000, remaining); + Assert.True(textLength >= 0, "Target transcript page is too small for its JSON structure."); + builder.Append(prefix); + builder.Append('x', textLength); + builder.Append(itemSuffix); + if (remaining <= 1_000_000) + break; + } + builder.Append("]}"); + var page = builder.ToString(); + Assert.Equal(targetUtf8Bytes, Encoding.UTF8.GetByteCount(page)); + return page; + } + + private static string BuildHistoryPage(int targetUtf8Bytes) + { + const string prefix = "{\"data\":[{\"id\":\"123e4567-e89b-12d3-a456-426614174000\",\"status\":{\"type\":\"idle\"},\"source\":\"cli\",\"archived\":true}],\"private\":\""; + const string suffix = "\"}"; + var paddingLength = targetUtf8Bytes - Encoding.UTF8.GetByteCount(prefix) - Encoding.UTF8.GetByteCount(suffix); + Assert.True(paddingLength >= 0, "Target history page is too small for its JSON structure."); + var page = prefix + new string('x', paddingLength) + suffix; + Assert.Equal(targetUtf8Bytes, Encoding.UTF8.GetByteCount(page)); + return page; + } + + private static void AssertJsonEqual(string expectedJson, JsonElement actual) + { + var expected = Json(expectedJson); + Assert.True( + JsonElement.DeepEquals(expected, actual), + $"Expected: {expected.GetRawText()}{Environment.NewLine}Actual: {actual.GetRawText()}"); + } + + private sealed class RecordingCatalogClient : ICodexSessionCatalogClient + { + private readonly Queue _threadResponses = new(); + + public JsonElement ThreadsResponse { get; set; } = Json("""{"data":[]}"""); + + public JsonElement TurnsResponse { get; set; } = Json("""{"data":[]}"""); + + public Exception? ThreadsException { get; set; } + + public List Methods { get; } = []; + + public List Parameters { get; } = []; + + public int ThreadsCallCount { get; private set; } + + public int TurnsCallCount { get; private set; } + + public void EnqueueThreads(JsonElement response) => _threadResponses.Enqueue(response.Clone()); + + public Task ListThreadsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default) + { + ThreadsCallCount++; + Methods.Add(CodexAppServerProtocol.ThreadListMethod); + Parameters.Add(parameters.Clone()); + if (ThreadsException is not null) + return Task.FromException(ThreadsException); + return Task.FromResult( + (_threadResponses.Count > 0 ? _threadResponses.Dequeue() : ThreadsResponse).Clone()); + } + + public Task ListThreadTurnsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default) + { + TurnsCallCount++; + Methods.Add(CodexAppServerProtocol.ThreadTurnsListMethod); + Parameters.Add(parameters.Clone()); + return Task.FromResult(TurnsResponse.Clone()); + } + } + + private sealed class CatalogJsonlProcessHarness : ICodexAppServerProcessFactory, IDisposable + { + private const string Script = """ + param([string]$RecordPath, [string]$PayloadPath) + $ErrorActionPreference = 'Stop' + function Read-Message { + $line = [Console]::In.ReadLine() + if ($null -eq $line) { exit 80 } + $message = $line | ConvertFrom-Json + Add-Content -LiteralPath $RecordPath -Value ([string]$message.method) -Encoding utf8 + return $message + } + function Write-Message($Value) { + $json = $Value | ConvertTo-Json -Compress -Depth 10 + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() + } + + $initialize = Read-Message + if ($initialize.method -ne 'initialize') { exit 81 } + Write-Message @{ id = [long]$initialize.id; result = @{} } + $initialized = Read-Message + if ($initialized.method -ne 'initialized') { exit 82 } + $list = Read-Message + if ($list.method -ne 'thread/list') { exit 83 } + if ($list.'params'.archived -eq $true) { + $payload = [IO.File]::ReadAllText($PayloadPath) + [Console]::Out.Write('{"id":' + [long]$list.id + ',"result":' + $payload + '}' + "`n") + [Console]::Out.Flush() + Start-Sleep -Seconds 30 + exit 0 + } + Write-Message @{ + id = [long]$list.id + result = @{ + data = @(@{ + id = '123e4567-e89b-12d3-a456-426614174000' + status = @{ type = 'idle' } + source = 'cli' + }) + } + } + $turns = Read-Message + if ($turns.method -ne 'thread/turns/list') { exit 84 } + $payload = [IO.File]::ReadAllText($PayloadPath) + [Console]::Out.Write('{"id":' + [long]$turns.id + ',"result":' + $payload + '}' + "`n") + [Console]::Out.Flush() + Start-Sleep -Seconds 30 + """; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"openclaw-codex-catalog-jsonl-{Guid.NewGuid():N}"); + private readonly string _scriptPath; + private readonly string _recordPath; + private readonly string _payloadPath; + private readonly List _processIds = []; + + public CatalogJsonlProcessHarness(string transcriptPage) + { + Directory.CreateDirectory(_root); + _scriptPath = Path.Combine(_root, "fake-catalog-app-server.ps1"); + _recordPath = Path.Combine(_root, "methods.txt"); + _payloadPath = Path.Combine(_root, "transcript.json"); + File.WriteAllText(_scriptPath, Script); + File.WriteAllText(_payloadPath, transcriptPage, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + public ICodexAppServerProcess Start(CodexLaunchPlan launchPlan) + { + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-ExecutionPolicy"); + startInfo.ArgumentList.Add("Bypass"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(_scriptPath); + startInfo.ArgumentList.Add(_recordPath); + startInfo.ArgumentList.Add(_payloadPath); + var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Fake catalog App Server did not start."); + _processIds.Add(process.Id); + return new CodexAppServerProcess(process); + } + + public IReadOnlyList RecordedMethods() => + File.Exists(_recordPath) + ? File.ReadAllLines(_recordPath).Where(value => value.Length > 0).ToArray() + : []; + + public async Task AssertAllProcessesExitedAfterDisposalAsync(CodexAppServerClient client) + { + await client.DisposeAsync(); + var deadline = Stopwatch.StartNew(); + while (deadline.Elapsed < TimeSpan.FromSeconds(2) && _processIds.Any(IsProcessRunning)) + await Task.Delay(20); + Assert.All(_processIds, processId => + Assert.False(IsProcessRunning(processId), $"Process {processId} is still running.")); + } + + public void Dispose() + { + foreach (var processId in _processIds.Where(IsProcessRunning)) + { + try + { + using var process = Process.GetProcessById(processId); + process.Kill(entireProcessTree: true); + process.WaitForExit(2_000); + } + catch (ArgumentException) + { + } + } + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + + private static bool IsProcessRunning(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs index ab344f9b8..0e1a78c25 100644 --- a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs +++ b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using OpenClaw.Shared; using OpenClaw.Shared.Capabilities; +using OpenClaw.Shared.Codex; using OpenClaw.Shared.Mcp; using OpenClaw.Shared.Telemetry; using Xunit; @@ -91,6 +92,32 @@ public Task ExecuteAsync( => Task.FromResult(new NodeInvokeResponse { Ok = false, Error = "cancelled" }); } + private sealed class DeniedDeliveryCapability : INodeCapability, INodeCapabilityDeliveryLeaseProvider + { + public string Category => "codex-app-server-threads"; + public IReadOnlyList Commands => ["codex.appServer.threads.list.v1"]; + public bool CanHandle(string command) => Commands.Contains(command); + public Task ExecuteAsync(NodeInvokeRequest request) => + Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = new { secret = true } }); + public INodeCapabilityDeliveryLease? TryAcquireDeliveryLease() => null; + } + + private sealed class FailingCodexCatalogClient : ICodexSessionCatalogClient + { + private const string PrivateFailure = + "TRANSCRIPT_BODY_PRIVATE C:\\Users\\operator\\.codex secret conversation"; + + public Task ListThreadsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException(PrivateFailure); + + public Task ListThreadTurnsAsync( + JsonElement parameters, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException(PrivateFailure); + } + private static McpToolBridge CreateBridge(IReadOnlyList caps) => new(() => caps); @@ -168,6 +195,97 @@ public async Task ToolsList_KnownCommands_GetCuratedDescriptions() Assert.Equal("custom capability: custom.unknown", byName["custom.unknown"]); } + [Fact] + public async Task ToolsList_CodexCatalog_AdvertisesExactlyThreeBoundedReadOnlyCommands() + { + string[] commands = + [ + "codex.appServer.threads.list.v1", + "codex.appServer.threads.history.list.v1", + "codex.appServer.thread.turns.list.v1", + ]; + var bridge = CreateBridge([new FakeCapability("codex-app-server-threads", commands)]); + + var response = await bridge.HandleRequestAsync( + """{"jsonrpc":"2.0","id":1,"method":"tools/list"}"""); + + using var document = JsonDocument.Parse(response!); + var tools = document.RootElement.GetProperty("result").GetProperty("tools"); + Assert.Equal(3, tools.GetArrayLength()); + var descriptions = tools.EnumerateArray().ToDictionary( + tool => tool.GetProperty("name").GetString()!, + tool => tool.GetProperty("description").GetString()!, + StringComparer.Ordinal); + Assert.Equal(commands, descriptions.Keys); + Assert.Contains("read-only", descriptions[commands[0]], StringComparison.OrdinalIgnoreCase); + Assert.Contains("limit 1-100", descriptions[commands[0]], StringComparison.Ordinal); + Assert.Contains("archived (bool, required)", descriptions[commands[1]], StringComparison.Ordinal); + Assert.Contains("limit 1-100", descriptions[commands[1]], StringComparison.Ordinal); + Assert.Contains("read-only", descriptions[commands[2]], StringComparison.OrdinalIgnoreCase); + Assert.Contains("limit 1-50", descriptions[commands[2]], StringComparison.Ordinal); + Assert.All( + descriptions.Values, + description => Assert.Contains( + "Stage 0 Read and steer adds no owner controls", + description, + StringComparison.Ordinal)); + + var codexCommands = McpToolBridge.KnownCommands + .Where(command => command.StartsWith("codex.appServer.", StringComparison.Ordinal)) + .OrderBy(command => command, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(commands.OrderBy(command => command, StringComparer.Ordinal), codexCommands); + } + + [Theory] + [InlineData("codex.appServer.threads.list.v1", "{}", "Codex app-server catalog is unavailable")] + [InlineData("codex.appServer.threads.history.list.v1", "{\"archived\":true}", "Codex app-server catalog is unavailable")] + [InlineData( + "codex.appServer.thread.turns.list.v1", + "{\"threadId\":\"123e4567-e89b-12d3-a456-426614174000\"}", + "Codex app-server transcript is unavailable")] + public async Task ToolsCall_CodexAppServerFailure_UsesStablePrivateDataFreeErrorAndAudit( + string command, + string arguments, + string expectedError) + { + var capability = new CodexSessionCapability( + NullLogger.Instance, + new CodexSessionCatalogService(new FailingCodexCatalogClient())); + var bridge = CreateBridge([capability]); + NodeToolTelemetryCompletion? completion = null; + bridge.ToolTelemetryCompleted += (_, value) => completion = value; + + using var argumentDocument = JsonDocument.Parse(arguments); + var requestBody = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 1, + method = "tools/call", + @params = new + { + name = command, + arguments = argumentDocument.RootElement.Clone(), + }, + }); + var response = await bridge.HandleRequestAsync(requestBody); + + using var document = JsonDocument.Parse(response!); + var result = document.RootElement.GetProperty("result"); + Assert.True(result.GetProperty("isError").GetBoolean()); + Assert.Equal(expectedError, result.GetProperty("content")[0].GetProperty("text").GetString()); + Assert.NotNull(completion); + Assert.Equal(command, completion!.Command); + Assert.Equal(NodeToolOutcome.Failure, completion.Outcome); + Assert.Equal(NodeToolErrorCategory.CapabilityFailure, completion.ErrorCategory); + + var exportedAudit = completion.ToString(); + Assert.DoesNotContain("TRANSCRIPT_BODY_PRIVATE", response, StringComparison.Ordinal); + Assert.DoesNotContain("operator", response, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("TRANSCRIPT_BODY_PRIVATE", exportedAudit, StringComparison.Ordinal); + Assert.DoesNotContain("operator", exportedAudit, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ToolsList_PicksUpNewCapabilityRegisteredAfterStart() { @@ -924,4 +1042,17 @@ public async Task ToolsCall_NullArguments_IsAccepted() Assert.True(doc.RootElement.TryGetProperty("result", out var result)); Assert.False(result.GetProperty("isError").GetBoolean()); } + + [Fact] + public async Task ToolsCall_DeniedDeliveryLease_DoesNotSerializeSuccessfulPayload() + { + var bridge = CreateBridge([new DeniedDeliveryCapability()]); + + var response = await bridge.HandleRequestAsync( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"codex.appServer.threads.list.v1","arguments":{}}}"""); + + using var document = JsonDocument.Parse(response!); + Assert.True(document.RootElement.GetProperty("result").GetProperty("isError").GetBoolean()); + Assert.DoesNotContain("secret", response, StringComparison.Ordinal); + } } diff --git a/tests/OpenClaw.Shared.Tests/WebSocketCloseSerializationTests.cs b/tests/OpenClaw.Shared.Tests/WebSocketCloseSerializationTests.cs index f92f5a788..3a71d73df 100644 --- a/tests/OpenClaw.Shared.Tests/WebSocketCloseSerializationTests.cs +++ b/tests/OpenClaw.Shared.Tests/WebSocketCloseSerializationTests.cs @@ -77,6 +77,38 @@ public async Task CloseWebSocketAsync_DisposeCompletesQueuedCloseWithoutExceptio } } + [Fact] + public async Task SendRawAsync_AuthorizesOnlyAfterTheSharedSendLockIsAcquired() + { + using var server = new LoopbackWebSocketServer(); + await server.StartAsync(); + using var client = new CloseRaceTestClient(server.WebSocketUrl); + await client.ConnectAsync(); + await WaitForConditionAsync(() => server.AcceptedCount == 1, TimeSpan.FromSeconds(2)); + + var sendLock = GetSendLock(client); + await sendLock.WaitAsync(); + var authorize = true; + try + { + var sendTask = client.SendAuthorizedAsync("secret", () => authorize); + Assert.False(sendTask.IsCompleted); + + authorize = false; + sendLock.Release(); + await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); + + using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + await Assert.ThrowsAnyAsync(() => + GetAcceptedSocket(server).ReceiveAsync(new ArraySegment(new byte[128]), timeout.Token)); + } + finally + { + if (sendLock.CurrentCount == 0) + sendLock.Release(); + } + } + private static async Task ReceiveUntilCloseAndAcknowledgeAsync(WebSocket socket) { var buffer = new byte[8192]; @@ -141,6 +173,8 @@ public CloseRaceTestClient(string gatewayUrl) protected override Task ProcessMessageAsync(string json) => Task.CompletedTask; public Task SendAsync(string message) => SendRawAsync(message); + public Task SendAuthorizedAsync(string message, Func authorize) => + SendRawAsync(message, authorize); public Task CloseAsync() => CloseWebSocketAsync(); } } diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs index 647a0855f..141133682 100644 --- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.WebSockets; using System.Reflection; using System.Text.Json; using System.Threading; @@ -17,6 +18,44 @@ namespace OpenClaw.Shared.Tests; [Collection(AppVersionInfoTestCollection.Name)] public class WindowsNodeClientTests { + [Fact] + public async Task CommandDispatch_LegacyEventPath_DoesNotDeliverAfterLeaseRevocationWhileSendIsQueued() + { + using var server = new LoopbackWebSocketServer(); + await server.StartAsync(); + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + using var client = new WindowsNodeClient(server.WebSocketUrl, "test-token", dataPath); + var capability = new RevocableEventCapability(); + client.ReplaceCapabilities([capability]); + await client.ConnectAsync(); + await WaitForConditionAsync(() => server.AcceptedCount == 1, TimeSpan.FromSeconds(2)); + + var sendLock = GetSendLock(client); + await sendLock.WaitAsync(); + var lockHeld = true; + try + { + await InvokeProcessMessageAsync(client, """ + {"type":"event","event":"node.invoke.request","payload":{"requestId":"legacy-revoked","command":"codex.appServer.threads.list.v1","args":{}}} + """); + await capability.Executed.Task.WaitAsync(TimeSpan.FromSeconds(2)); + capability.Revoke(); + sendLock.Release(); + lockHeld = false; + + using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + await Assert.ThrowsAnyAsync(() => + ReceiveMatchingAsync(GetAcceptedSocket(server), "legacy-secret", timeout.Token)); + } + finally + { + if (lockHeld) + sendLock.Release(); + Directory.Delete(dataPath, recursive: true); + } + } + private sealed class CapturingWindowsNodeClient( string gatewayUrl, string token, @@ -3277,6 +3316,69 @@ private static async Task InvokeProcessMessageAsync( await task; } + private static SemaphoreSlim GetSendLock(WindowsNodeClient client) => + (SemaphoreSlim)typeof(WebSocketClientBase) + .GetField("_sendLock", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(client)!; + + private static WebSocket GetAcceptedSocket(LoopbackWebSocketServer server) + { + var sockets = (List)typeof(LoopbackWebSocketServer) + .GetField("_acceptedSockets", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(server)!; + lock (sockets) + return sockets[0]; + } + + private static async Task ReceiveMatchingAsync(WebSocket socket, string marker, CancellationToken cancellationToken) + { + var buffer = new byte[8192]; + while (true) + { + var result = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (result.MessageType != WebSocketMessageType.Text) + continue; + var message = System.Text.Encoding.UTF8.GetString(buffer, 0, result.Count); + if (message.Contains(marker, StringComparison.Ordinal)) + return; + } + } + + private static async Task WaitForConditionAsync(Func predicate, TimeSpan timeout) + { + var started = DateTime.UtcNow; + while (!predicate()) + { + if (DateTime.UtcNow - started > timeout) + throw new TimeoutException("Condition was not met before the timeout."); + await Task.Delay(20); + } + } + + private sealed class RevocableEventCapability : INodeCapability, INodeCapabilityDeliveryLeaseProvider + { + private readonly EventDeliveryLease _lease = new(); + public TaskCompletionSource Executed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public string Category => "codex-app-server-threads"; + public IReadOnlyList Commands => ["codex.appServer.threads.list.v1"]; + public bool CanHandle(string command) => Commands.Contains(command, StringComparer.OrdinalIgnoreCase); + public Task ExecuteAsync(NodeInvokeRequest request) + { + Executed.TrySetResult(); + return Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = new { secret = "legacy-secret" } }); + } + public INodeCapabilityDeliveryLease? TryAcquireDeliveryLease() => _lease; + public void Revoke() => _lease.Revoke(); + + private sealed class EventDeliveryLease : INodeCapabilityDeliveryLease + { + private int _revoked; + public bool TryBeginDelivery() => Volatile.Read(ref _revoked) == 0; + public void Revoke() => Interlocked.Exchange(ref _revoked, 1); + public void Dispose() { } + } + } + private static async Task WaitForSentMessageAsync( CapturingWindowsNodeClient client, Func predicate) diff --git a/tests/OpenClaw.Tray.Tests/ConsentAndSettingsSaveTests.cs b/tests/OpenClaw.Tray.Tests/ConsentAndSettingsSaveTests.cs index a02ee205b..1960d0d67 100644 --- a/tests/OpenClaw.Tray.Tests/ConsentAndSettingsSaveTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConsentAndSettingsSaveTests.cs @@ -1,9 +1,66 @@ using OpenClawTray.Services; +using OpenClaw.Shared.Codex; +using OpenClaw.Tray.Tests.Presentation; namespace OpenClaw.Tray.Tests; public class ConsentAndSettingsSaveTests { + [Fact] + public void SettingsManager_PublicDataPropertiesUseOwnerLockHelpers() + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT") + ?? throw new InvalidOperationException("OPENCLAW_REPO_ROOT must identify the test worktree."); + var source = File.ReadAllText(Path.Combine( + root, + "src", + "OpenClaw.Tray.WinUI", + "Services", + "SettingsManager.cs")); + + Assert.DoesNotMatch( + new System.Text.RegularExpressions.Regex( + @"(?m)^\s*public\s+[^\r\n]*\b_data\b", + System.Text.RegularExpressions.RegexOptions.CultureInvariant), + source); + Assert.Contains("get => ReadData(data =>", source); + Assert.Contains("set => UpdateData(data => data with", source); + Assert.Contains("lock (_saveLock)\n LoadCore();", source.Replace("\r\n", "\n")); + Assert.Contains("ToSettingsData() => ReadData(data => data with", source); + } + + [Fact] + public void CodexSessionAccessUi_IsInteractiveLocalizedAndTransportSourcesHaveNoSettingsAssignment() + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT") + ?? throw new InvalidOperationException("OPENCLAW_REPO_ROOT must identify the test worktree."); + var xaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Pages", "SettingsPage.xaml")); + var resources = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw")); + var app = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + var transportSources = new[] + { + Path.Combine(root, "src", "OpenClaw.Shared", "Mcp", "McpToolBridge.cs"), + Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Services", "NodeCapabilityRegistry.cs"), + Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Services", "NodeService.cs"), + }.Select(File.ReadAllText); + + Assert.Contains("SelectedIndex=\"{Binding CodexSessionAccessIndex, Mode=TwoWay}\"", xaml); + Assert.Contains("SettingsPage_CodexSessionAccess_Off", xaml); + Assert.Contains("SettingsPage_CodexSessionAccess_ReadOnly", xaml); + Assert.Contains("SettingsPage_CodexSessionAccess_ReadAndSteer", xaml); + Assert.Contains("Catalog available", resources); + Assert.Contains("Catalog unavailable", resources); + Assert.Contains("Steering unavailable", resources); + Assert.Contains("owner control", resources, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Stage 0 did not pass validation", resources); + Assert.Contains("does not change Gateway configuration", resources); + Assert.DoesNotContain('\u2014', resources); + Assert.All(transportSources, source => Assert.DoesNotMatch( + new System.Text.RegularExpressions.Regex(@"\bCodexSessionAccess\s*=", System.Text.RegularExpressions.RegexOptions.CultureInvariant), + source)); + Assert.Contains("_nodeService?.RefreshCodexSessionAccess()", app); + } + [Fact] public async Task Save_IsThreadSafe_ConcurrentCallsDoNotCorruptFile() { diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index a07393ec2..3cfaa619f 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -93,6 +93,16 @@ public class LocalizationValidationTests // translation pass lands. They are still present in every locale file // so key parity stays strict; this set only documents deferred copy. "SettingsPage_About.Text", + "SettingsPage_CodexSessionAccess_Header.Text", + "SettingsPage_CodexSessionAccess_Description.Text", + "SettingsPage_CodexSessionAccess_StatusOff.Text", + "SettingsPage_CodexSessionAccess_StatusCatalogAvailable.Text", + "SettingsPage_CodexSessionAccess_StatusCatalogUnavailable.Text", + "SettingsPage_CodexSessionAccess_StatusSteeringUnavailable.Text", + "SettingsPage_CodexSessionAccess_Off.Content", + "SettingsPage_CodexSessionAccess_ReadOnly.Content", + "SettingsPage_CodexSessionAccess_ReadAndSteer.Content", + "CodexSessionAccessComboBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name", "SettingsPage_GatewayInfoExpander.Header", "SettingsPage_GatewayInfoLabel_Version.Text", "SettingsPage_GatewayInfoLabel_Protocol.Text", diff --git a/tests/OpenClaw.Tray.Tests/NodeCapabilityRegistryTests.cs b/tests/OpenClaw.Tray.Tests/NodeCapabilityRegistryTests.cs new file mode 100644 index 000000000..e1673b50d --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/NodeCapabilityRegistryTests.cs @@ -0,0 +1,798 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared; +using OpenClaw.Shared.Capabilities; +using OpenClaw.Shared.Codex; +using OpenClaw.Shared.Mcp; +using OpenClawTray.Presentation; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class NodeCapabilityRegistryTests +{ + private static readonly string[] ExpectedReadCommands = + [ + "codex.appServer.threads.list.v1", + "codex.appServer.threads.history.list.v1", + "codex.appServer.thread.turns.list.v1", + ]; + + [Fact] + public void RealSettingsSave_ImmediatelyAddsAndRevokesCodexCatalogsForMcpAndGateway() + { + using var temp = new Presentation.TempDir(); + var settings = new SettingsManager(temp.Path); + var store = new SettingsStore(settings, new Presentation.RecordingUiDispatcher()); + using var harness = new CodexRegistryProcessHarness(CodexRegistryProcessMode.Success); + var registry = new NodeCapabilityRegistry( + NullLogger.Instance, + () => new CodexLaunchPlan(Path.Combine(harness.RootPath, "codex.exe")), + harness); + using var gateway = new WindowsNodeClient("ws://127.0.0.1:1", "token", temp.Path, NullLogger.Instance); + registry.Rebuild([], settings.CodexSessionAccess); + registry.RegisterGateway(gateway, NullLogger.Instance); + settings.Saved += (_, _) => registry.RefreshCodexSessionAccess( + settings.CodexSessionAccess, + gateway, + NullLogger.Instance); + + store.Update(editor => editor.CodexSessionAccess = CodexSessionAccessMode.ReadOnly); + + Assert.Equal(ExpectedReadCommands, CodexCommands(registry.GetMcpSnapshot())); + Assert.Equal(ExpectedReadCommands, CodexCommands(gateway.Capabilities)); + + store.Update(editor => editor.CodexSessionAccess = CodexSessionAccessMode.Off); + + Assert.Empty(CodexCommands(registry.GetMcpSnapshot())); + Assert.Empty(CodexCommands(gateway.Capabilities)); + } + + [Fact] + public async Task RealMcpAndGatewayCatalogDispatchesExposeOnlyReadsAndDoNotMutateStore() + { + using var temp = new Presentation.TempDir(); + var settings = new SettingsManager(temp.Path) + { + GatewayUrl = "wss://gateway.example.test", + CodexSessionAccess = CodexSessionAccessMode.ReadOnly, + }; + settings.Save(); + using var harness = new CodexRegistryProcessHarness(CodexRegistryProcessMode.Success); + var registry = new NodeCapabilityRegistry( + NullLogger.Instance, + () => new CodexLaunchPlan(Path.Combine(harness.RootPath, "codex.exe")), + harness); + registry.Rebuild([], settings.CodexSessionAccess); + using var gateway = new WindowsNodeClient("ws://127.0.0.1:1", "token", temp.Path, NullLogger.Instance); + registry.RegisterGateway(gateway, NullLogger.Instance); + var bridge = new McpToolBridge(registry.GetMcpSnapshot, NullLogger.Instance); + + var listJson = await bridge.HandleRequestAsync( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + using var listDocument = JsonDocument.Parse(listJson!); + var mcpCommands = listDocument.RootElement.GetProperty("result").GetProperty("tools") + .EnumerateArray().Select(tool => tool.GetProperty("name").GetString()).ToArray(); + Assert.Equal(ExpectedReadCommands, mcpCommands); + Assert.Equal(ExpectedReadCommands, CodexCommands(gateway.Capabilities)); + Assert.DoesNotContain(mcpCommands, command => command is not null && + (command.Contains("steer", StringComparison.OrdinalIgnoreCase) + || command.Contains("resume", StringComparison.OrdinalIgnoreCase) + || command.Contains("interrupt", StringComparison.OrdinalIgnoreCase) + || command.Contains("write", StringComparison.OrdinalIgnoreCase))); + + foreach (var command in ExpectedReadCommands) + { + var arguments = command == CodexSessionCapability.ThreadTurnsListCommand + ? JsonSerializer.SerializeToElement(new { threadId = "123e4567-e89b-12d3-a456-426614174000" }) + : command == CodexSessionCapability.ThreadsHistoryListCommand + ? JsonSerializer.SerializeToElement(new { archived = true }) + : JsonSerializer.SerializeToElement(new { }); + var requestJson = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 2, + method = "tools/call", + @params = new { name = command, arguments }, + }); + var mcpResponse = await bridge.HandleRequestAsync( + requestJson); + using var responseDocument = JsonDocument.Parse(mcpResponse!); + Assert.False(responseDocument.RootElement.GetProperty("result").GetProperty("isError").GetBoolean()); + + var gatewayResponse = await gateway.DispatchRegisteredCommandForTestAsync(new NodeInvokeRequest + { + Id = "gateway-test", + Command = command, + Args = arguments, + }); + Assert.True(gatewayResponse.Ok, gatewayResponse.Error); + } + + var persisted = new SettingsManager(temp.Path); + Assert.Equal(6, harness.StartCount); + Assert.Equal(CodexSessionAccessMode.ReadOnly, persisted.CodexSessionAccess); + Assert.Equal("wss://gateway.example.test", persisted.GatewayUrl); + Assert.Equal(6, harness.RecordedMethods().Count(method => method == "thread/list")); + Assert.Equal(2, harness.RecordedMethods().Count(method => method == "thread/turns/list")); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task GatewayCommandMap_EnableDispatchesAndRevokeReturnsUnavailable() + { + using var temp = new Presentation.TempDir(); + using var harness = new CodexRegistryProcessHarness(CodexRegistryProcessMode.Success); + var registry = new NodeCapabilityRegistry( + NullLogger.Instance, + () => new CodexLaunchPlan(Path.Combine(harness.RootPath, "codex.exe")), + harness); + using var gateway = new WindowsNodeClient("ws://127.0.0.1:1", "token", temp.Path, NullLogger.Instance); + + registry.Rebuild([], CodexSessionAccessMode.ReadOnly); + registry.RegisterGateway(gateway, NullLogger.Instance); + var enabled = await gateway.DispatchRegisteredCommandForTestAsync(new NodeInvokeRequest + { + Id = "enabled", + Command = CodexSessionCapability.ThreadsListCommand, + Args = JsonSerializer.SerializeToElement(new { }), + }); + + registry.RefreshCodexSessionAccess(CodexSessionAccessMode.Off, gateway, NullLogger.Instance); + var revoked = await gateway.DispatchRegisteredCommandForTestAsync(new NodeInvokeRequest + { + Id = "revoked", + Command = CodexSessionCapability.ThreadsListCommand, + Args = JsonSerializer.SerializeToElement(new { }), + }); + + Assert.True(enabled.Ok, enabled.Error); + Assert.False(revoked.Ok); + Assert.Equal($"Command not supported: {CodexSessionCapability.ThreadsListCommand}", revoked.Error); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task ConcurrentReplaceAndHandshakeSnapshot_NeverPublishesTornCatalog() + { + using var temp = new Presentation.TempDir(); + using var gateway = new WindowsNodeClient("ws://127.0.0.1:1", "token", temp.Path, NullLogger.Instance); + var a = new StubCapability("catalog-a", ["catalog.a.one", "catalog.a.two"]); + var b = new StubCapability("catalog-b", ["catalog.b.one"]); + gateway.ReplaceCapabilities([a]); + + using var start = new ManualResetEventSlim(); + var replacing = Task.Run(() => + { + start.Wait(); + for (var i = 0; i < 2_000; i++) + gateway.ReplaceCapabilities(i % 2 == 0 ? [b] : [a]); + }); + start.Set(); + + for (var i = 0; i < 2_000; i++) + { + var snapshot = gateway.GetHandshakeCatalogForTest(); + var isA = snapshot.Capabilities.SequenceEqual(["catalog-a"]) && + snapshot.Commands.SequenceEqual(["catalog.a.one", "catalog.a.two"]); + var isB = snapshot.Capabilities.SequenceEqual(["catalog-b"]) && + snapshot.Commands.SequenceEqual(["catalog.b.one"]); + Assert.True(isA || isB, $"Torn catalog: {string.Join(',', snapshot.Capabilities)} / {string.Join(',', snapshot.Commands)}"); + } + + await replacing.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Rebuild_Off_DoesNotAdvertiseCodexCommands() + { + var registry = CreateRegistry(clientAvailable: true); + + var snapshot = registry.Rebuild([], CodexSessionAccessMode.Off); + + Assert.Empty(CodexCommands(snapshot)); + } + + [Fact] + public void Rebuild_ReadOnlyWithAvailableClient_AdvertisesExactlyThreeReadCommands() + { + var registry = CreateRegistry(clientAvailable: true); + + var snapshot = registry.Rebuild([], CodexSessionAccessMode.ReadOnly); + + Assert.Equal(ExpectedReadCommands, CodexCommands(snapshot)); + } + + [Fact] + public void Rebuild_ReadOnlyWithUnavailableClient_DoesNotAdvertiseCodexCommands() + { + var registry = CreateRegistry(clientAvailable: false); + + var snapshot = registry.Rebuild([], CodexSessionAccessMode.ReadOnly); + + Assert.Empty(CodexCommands(snapshot)); + } + + [Fact] + public void Rebuild_ReadAndSteerWithoutOwnerEndpoint_StillAdvertisesOnlyThreeReads() + { + var registry = CreateRegistry(clientAvailable: true); + + var snapshot = registry.Rebuild([], CodexSessionAccessMode.ReadAndSteer); + + Assert.Equal(ExpectedReadCommands, CodexCommands(snapshot)); + Assert.DoesNotContain(snapshot.SelectMany(capability => capability.Commands), command => + command.Contains("steer", StringComparison.OrdinalIgnoreCase) + || command.Contains("interrupt", StringComparison.OrdinalIgnoreCase) + || command.Contains("resume", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task RefreshCodexSessionAccess_RevokesAnAlreadyDispatchedCatalogCapability() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registry = new NodeCapabilityRegistry(() => new BlockingCapability(started)); + var capability = Assert.Single(registry.Rebuild([], CodexSessionAccessMode.ReadOnly)); + + var execution = capability.ExecuteAsync(new NodeInvokeRequest + { + Id = "revoked-in-flight", + Command = CodexSessionCapability.ThreadsListCommand, + }, CancellationToken.None); + await started.Task.WaitAsync(TimeSpan.FromSeconds(1)); + + registry.RefreshCodexSessionAccess(CodexSessionAccessMode.Off, null, NullLogger.Instance); + + await Assert.ThrowsAnyAsync( + () => execution.WaitAsync(TimeSpan.FromSeconds(1))); + } + + [Fact] + public async Task RefreshCodexSessionAccess_RevokesAnInFlightHistoryCatalogCapability() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registry = new NodeCapabilityRegistry(() => new BlockingCapability(started)); + var capability = Assert.Single(registry.Rebuild([], CodexSessionAccessMode.ReadOnly)); + + var execution = capability.ExecuteAsync(new NodeInvokeRequest + { + Id = "history-revoked-in-flight", + Command = CodexSessionCapability.ThreadsHistoryListCommand, + Args = JsonSerializer.SerializeToElement(new { archived = true }), + }, CancellationToken.None); + await started.Task.WaitAsync(TimeSpan.FromSeconds(1)); + + registry.RefreshCodexSessionAccess(CodexSessionAccessMode.Off, null, NullLogger.Instance); + + await Assert.ThrowsAnyAsync( + () => execution.WaitAsync(TimeSpan.FromSeconds(1))); + } + + [Fact] + public async Task RefreshCodexSessionAccess_RevocationWinsOverAnOlderBlockedRebuild() + { + var factoryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var releaseFactory = new ManualResetEventSlim(); + var registry = new NodeCapabilityRegistry(() => + { + factoryStarted.TrySetResult(); + Assert.True(releaseFactory.Wait(TimeSpan.FromSeconds(5))); + return new StubCapability("codex-app-server-threads", ExpectedReadCommands); + }); + + var staleRebuild = Task.Run(() => registry.Rebuild([], CodexSessionAccessMode.ReadOnly)); + await factoryStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + var revoke = Task.Run(() => registry.RefreshCodexSessionAccess( + CodexSessionAccessMode.Off, + null, + NullLogger.Instance)); + + _ = await Task.WhenAny(revoke, Task.Delay(TimeSpan.FromMilliseconds(500))); + releaseFactory.Set(); + await Task.WhenAll(staleRebuild, revoke).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Empty(CodexCommands(registry.GetMcpSnapshot())); + } + + [Fact] + public async Task RefreshCodexSessionAccess_SuppressesSuccessFromCapabilityThatIgnoresCancellation() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registry = new NodeCapabilityRegistry(() => new IgnoringCancellationCapability(started, release.Task)); + var capability = Assert.Single(registry.Rebuild([], CodexSessionAccessMode.ReadOnly)); + var execution = capability.ExecuteAsync(new NodeInvokeRequest + { + Id = "ignores-cancellation", + Command = CodexSessionCapability.ThreadsListCommand, + }, CancellationToken.None); + await started.Task.WaitAsync(TimeSpan.FromSeconds(1)); + + registry.RefreshCodexSessionAccess(CodexSessionAccessMode.Off, null, NullLogger.Instance); + release.TrySetResult(); + + await Assert.ThrowsAnyAsync(() => execution); + } + + [Fact] + public async Task RefreshCodexSessionAccess_DoesNotWaitForAStalledDeliveryAndRevokesItBeforeWrite() + { + var registry = CreateRegistry(clientAvailable: true); + var capability = Assert.Single(registry.Rebuild([], CodexSessionAccessMode.ReadOnly)); + var leaseProvider = Assert.IsAssignableFrom(capability); + using var stalledDelivery = Assert.IsAssignableFrom( + leaseProvider.TryAcquireDeliveryLease()); + + var stopwatch = Stopwatch.StartNew(); + registry.RefreshCodexSessionAccess(CodexSessionAccessMode.Off, null, NullLogger.Instance); + + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1)); + Assert.False(stalledDelivery.TryBeginDelivery()); + await Task.CompletedTask; + } + + [Fact] + public async Task RefreshCodexSessionAccess_RevokesAnAlreadyPreparedMcpResponseBeforeWrite() + { + var registry = CreateRegistry(clientAvailable: true); + registry.Rebuild([], CodexSessionAccessMode.ReadOnly); + var bridge = new McpToolBridge(registry.GetMcpSnapshot, NullLogger.Instance); + var prepared = await bridge.HandleTransportRequestAsync(JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 1, + method = "tools/call", + @params = new + { + name = CodexSessionCapability.ThreadsListCommand, + arguments = new { }, + }, + }), CancellationToken.None); + + try + { + Assert.NotNull(prepared.Body); + registry.RefreshCodexSessionAccess(CodexSessionAccessMode.Off, null, NullLogger.Instance); + Assert.False(prepared.TryBeginDelivery()); + } + finally + { + prepared.CompleteDelivery(); + } + } + + [Fact] + public async Task RefreshCodexSessionAccess_RevokesAPreparedHistoryMcpResponseBeforeWrite() + { + var registry = CreateRegistry(clientAvailable: true); + registry.Rebuild([], CodexSessionAccessMode.ReadOnly); + var bridge = new McpToolBridge(registry.GetMcpSnapshot, NullLogger.Instance); + var prepared = await bridge.HandleTransportRequestAsync(JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 1, + method = "tools/call", + @params = new + { + name = CodexSessionCapability.ThreadsHistoryListCommand, + arguments = new { archived = true }, + }, + }), CancellationToken.None); + + try + { + Assert.NotNull(prepared.Body); + registry.RefreshCodexSessionAccess(CodexSessionAccessMode.Off, null, NullLogger.Instance); + Assert.False(prepared.TryBeginDelivery()); + } + finally + { + prepared.CompleteDelivery(); + } + } + + [Fact] + public async Task GatewayDispatch_DeniedDeliveryLease_DoesNotReturnSuccessfulPayload() + { + using var temp = new Presentation.TempDir(); + using var gateway = new WindowsNodeClient( + "ws://127.0.0.1:1", + "token", + temp.Path, + NullLogger.Instance); + gateway.ReplaceCapabilities([new DeniedDeliveryCapability()]); + + await Assert.ThrowsAnyAsync(() => + gateway.DispatchRegisteredCommandForTestAsync(new NodeInvokeRequest + { + Id = "denied-delivery", + Command = CodexSessionCapability.ThreadsListCommand, + })); + } + + [Fact] + public async Task DeferredCodexCapability_UsesTrustedCatalogTransportLazilyAndDisposesIt() + { + using var harness = new CodexRegistryProcessHarness(CodexRegistryProcessMode.Success); + var launchPlan = new CodexLaunchPlan(Path.Combine(harness.RootPath, "codex.exe")); + var registry = new NodeCapabilityRegistry( + NullLogger.Instance, + () => launchPlan, + harness); + + var snapshot = registry.Rebuild([], CodexSessionAccessMode.ReadOnly); + var capability = Assert.Single(snapshot); + + Assert.Equal("codex-app-server-threads", capability.Category); + Assert.Equal( + [ + "codex.appServer.threads.list.v1", + "codex.appServer.threads.history.list.v1", + "codex.appServer.thread.turns.list.v1", + ], + capability.Commands); + Assert.Equal(0, harness.StartCount); + + var response = await capability.ExecuteAsync(new NodeInvokeRequest + { + Id = "request-1", + Command = "codex.appServer.threads.list.v1", + Args = JsonSerializer.SerializeToElement(new { limit = 50 }), + }); + + Assert.True(response.Ok, response.Error); + Assert.Equal(1, harness.StartCount); + Assert.Equal(launchPlan.ExecutablePath, Assert.Single(harness.LaunchPlans).ExecutablePath); + Assert.Equal(["initialize", "initialized", "thread/list"], harness.RecordedMethods()); + Assert.Equal(50, harness.RecordedRequest("thread/list").GetProperty("params").GetProperty("limit").GetInt32()); + await harness.AssertAllProcessesExitedAsync(); + } + + [Theory] + [InlineData(CodexRegistryProcessMode.RemoteFailure)] + [InlineData(CodexRegistryProcessMode.FailedInitialization)] + public async Task DeferredCodexCapability_SanitizesFailureAndDisposesFailedProcess( + CodexRegistryProcessMode mode) + { + using var harness = new CodexRegistryProcessHarness(mode); + var registry = new NodeCapabilityRegistry( + NullLogger.Instance, + () => new CodexLaunchPlan(Path.Combine(harness.RootPath, "codex.exe")), + harness); + var capability = Assert.Single(registry.Rebuild([], CodexSessionAccessMode.ReadOnly)); + + var response = await capability.ExecuteAsync(new NodeInvokeRequest + { + Id = "request-1", + Command = "codex.appServer.threads.list.v1", + Args = JsonSerializer.SerializeToElement(new { }), + }); + + Assert.False(response.Ok); + Assert.Equal("Codex app-server catalog is unavailable", response.Error); + Assert.DoesNotContain("operator-secret", response.Error, StringComparison.Ordinal); + await harness.AssertAllProcessesExitedAsync(); + } + + [Fact] + public async Task SharedSnapshot_ActualMcpAndGatewayConsumersApplyTransportPolicy() + { + var root = Path.Combine(Path.GetTempPath(), $"openclaw-registry-gateway-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var shared = new StubCapability("system", ["system.notify"]); + var localOnly = new StubCapability("app", ["app.connection.status"]); + var mcpOnly = new StubCapability("test", ["app.test.only"]); + var registry = CreateRegistry(clientAvailable: true); + var snapshot = registry.Rebuild([shared, localOnly], CodexSessionAccessMode.ReadOnly); + registry.RegisterMcpOnly(mcpOnly); + using var gateway = new WindowsNodeClient( + "ws://127.0.0.1:1", + "test-token", + root, + NullLogger.Instance); + + registry.RegisterGateway(gateway, NullLogger.Instance); + var bridge = new McpToolBridge(registry.GetMcpSnapshot, NullLogger.Instance); + var toolsJson = await bridge.HandleRequestAsync( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + using var toolsDocument = JsonDocument.Parse(toolsJson!); + var mcpCommands = toolsDocument.RootElement.GetProperty("result").GetProperty("tools") + .EnumerateArray() + .Select(tool => tool.GetProperty("name").GetString()) + .ToArray(); + + Assert.Contains("system.notify", gateway.Capabilities.SelectMany(capability => capability.Commands)); + Assert.Contains("codex.appServer.threads.list.v1", gateway.Capabilities.SelectMany(capability => capability.Commands)); + Assert.DoesNotContain("app.connection.status", gateway.Capabilities.SelectMany(capability => capability.Commands)); + Assert.DoesNotContain("app.test.only", gateway.Capabilities.SelectMany(capability => capability.Commands)); + Assert.Contains("system.notify", mcpCommands); + Assert.Contains("codex.appServer.threads.list.v1", mcpCommands); + Assert.Contains("app.connection.status", mcpCommands); + Assert.Contains("app.test.only", mcpCommands); + + var collection = Assert.IsAssignableFrom>(snapshot); + Assert.True(collection.IsReadOnly); + Assert.Throws(() => collection.Add(new StubCapability("write", ["thread.resume"]))); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void NodeService_DoesNotOwnCapabilityRegistryStorageOrRegistration() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine( + root, + "src", + "OpenClaw.Tray.WinUI", + "Services", + "NodeService.cs")); + + Assert.Contains("NodeCapabilityRegistry", source); + Assert.Contains("_capabilityRegistry.RegisterGateway(_nodeClient, _logger)", source); + Assert.Contains("_capabilityRegistry.GetMcpSnapshot", source); + Assert.DoesNotContain("List _capabilities", source); + Assert.DoesNotContain("void Register(INodeCapability capability)", source); + } + + private static NodeCapabilityRegistry CreateRegistry(bool clientAvailable) => + new(() => clientAvailable ? new StubCapability("codex-app-server-threads", ExpectedReadCommands) : null); + + private static string[] CodexCommands(IReadOnlyList snapshot) => + snapshot + .Where(capability => string.Equals( + capability.Category, + "codex-app-server-threads", + StringComparison.Ordinal)) + .SelectMany(capability => capability.Commands) + .ToArray(); + + private sealed class StubCapability(string category, IReadOnlyList commands) : INodeCapability + { + public string Category { get; } = category; + + public IReadOnlyList Commands { get; } = commands; + + public bool CanHandle(string command) => + Commands.Contains(command, StringComparer.OrdinalIgnoreCase); + + public Task ExecuteAsync(NodeInvokeRequest request) => + Task.FromResult(new NodeInvokeResponse + { + Ok = true, + Payload = JsonSerializer.SerializeToElement(new { }), + }); + } + + private sealed class BlockingCapability(TaskCompletionSource started) : INodeCapability + { + public string Category => "codex-app-server-threads"; + + public IReadOnlyList Commands => ExpectedReadCommands; + + public bool CanHandle(string command) => + Commands.Contains(command, StringComparer.OrdinalIgnoreCase); + + public Task ExecuteAsync(NodeInvokeRequest request) => + ExecuteAsync(request, CancellationToken.None); + + public async Task ExecuteAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + started.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new UnreachableException(); + } + } + + private sealed class IgnoringCancellationCapability( + TaskCompletionSource started, + Task release) : INodeCapability + { + public string Category => "codex-app-server-threads"; + public IReadOnlyList Commands => ExpectedReadCommands; + public bool CanHandle(string command) => Commands.Contains(command, StringComparer.OrdinalIgnoreCase); + public Task ExecuteAsync(NodeInvokeRequest request) => + ExecuteAsync(request, CancellationToken.None); + public async Task ExecuteAsync(NodeInvokeRequest request, CancellationToken cancellationToken) + { + started.TrySetResult(); + await release; + return new NodeInvokeResponse { Id = request.Id, Ok = true, Payload = new { secret = true } }; + } + } + + private sealed class DeniedDeliveryCapability : INodeCapability, INodeCapabilityDeliveryLeaseProvider + { + public string Category => "codex-app-server-threads"; + public IReadOnlyList Commands => ExpectedReadCommands; + public bool CanHandle(string command) => Commands.Contains(command, StringComparer.OrdinalIgnoreCase); + public Task ExecuteAsync(NodeInvokeRequest request) => + Task.FromResult(new NodeInvokeResponse { Id = request.Id, Ok = true, Payload = new { secret = true } }); + public INodeCapabilityDeliveryLease? TryAcquireDeliveryLease() => null; + } + + public enum CodexRegistryProcessMode + { + Success, + RemoteFailure, + FailedInitialization, + } + + private sealed class CodexRegistryProcessHarness : ICodexAppServerProcessFactory, IDisposable + { + private const string Script = """ + param([string]$RecordPath, [string]$Mode) + $ErrorActionPreference = 'Stop' + function Read-Message { + $line = [Console]::In.ReadLine() + if ($null -eq $line) { exit 80 } + Add-Content -LiteralPath $RecordPath -Value $line -Encoding utf8 + return $line | ConvertFrom-Json + } + function Write-Message($Value) { + [Console]::Out.WriteLine(($Value | ConvertTo-Json -Compress -Depth 10)) + [Console]::Out.Flush() + } + + $initialize = Read-Message + if ($Mode -eq 'FailedInitialization') { + Write-Message @{ id = [long]$initialize.id; error = @{ code = -32001; message = 'operator-secret failed initialization' } } + Start-Sleep -Seconds 30 + exit 91 + } + Write-Message @{ id = [long]$initialize.id; result = @{} } + $null = Read-Message + $list = Read-Message + if ($Mode -eq 'RemoteFailure') { + Write-Message @{ id = [long]$list.id; error = @{ code = -32000; message = 'operator-secret remote failure' } } + Start-Sleep -Seconds 30 + exit 92 + } + $padding = 'x' * 1200000 + while ($true) { + if ($list.method -eq 'thread/list') { + Write-Message @{ + id = [long]$list.id + result = @{ + data = @(@{ + id = '123e4567-e89b-12d3-a456-426614174000' + name = 'Catalog session' + preview = $padding + status = @{ type = 'idle' } + source = 'cli' + }) + } + } + } elseif ($list.method -eq 'thread/turns/list') { + Write-Message @{ + id = [long]$list.id + result = @{ + data = @(@{ + id = 'turn-1' + status = 'completed' + items = @(@{ id = 'item-1'; type = 'agentMessage'; text = 'bounded answer' }) + }) + } + } + } else { + exit 93 + } + $list = Read-Message + } + Start-Sleep -Seconds 30 + """; + + private readonly string _recordPath; + private readonly string _scriptPath; + private readonly CodexRegistryProcessMode _mode; + private readonly List _processIds = []; + + public CodexRegistryProcessHarness(CodexRegistryProcessMode mode) + { + _mode = mode; + RootPath = Path.Combine(Path.GetTempPath(), $"openclaw-registry-codex-{Guid.NewGuid():N}"); + Directory.CreateDirectory(RootPath); + _recordPath = Path.Combine(RootPath, "requests.jsonl"); + _scriptPath = Path.Combine(RootPath, "fake-app-server.ps1"); + File.WriteAllText(_scriptPath, Script, new UTF8Encoding(false)); + } + + public string RootPath { get; } + + public int StartCount { get; private set; } + + public List LaunchPlans { get; } = []; + + public ICodexAppServerProcess Start(CodexLaunchPlan launchPlan) + { + StartCount++; + LaunchPlans.Add(launchPlan); + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var argument in new[] + { + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", _scriptPath, _recordPath, _mode.ToString(), + }) + { + startInfo.ArgumentList.Add(argument); + } + var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Fake Codex App Server did not start."); + _processIds.Add(process.Id); + return new CodexAppServerProcess(process); + } + + public IReadOnlyList RecordedMethods() => + RecordedRequests().Select(request => request.GetProperty("method").GetString()).ToArray(); + + public JsonElement RecordedRequest(string method) => + RecordedRequests().Single(request => string.Equals( + request.GetProperty("method").GetString(), + method, + StringComparison.Ordinal)); + + public async Task AssertAllProcessesExitedAsync() + { + var timeout = Stopwatch.StartNew(); + while (timeout.Elapsed < TimeSpan.FromSeconds(2) && _processIds.Any(IsRunning)) + await Task.Delay(20); + Assert.All(_processIds, processId => Assert.False(IsRunning(processId))); + } + + public void Dispose() + { + foreach (var processId in _processIds.Where(IsRunning)) + { + try + { + using var process = Process.GetProcessById(processId); + process.Kill(entireProcessTree: true); + process.WaitForExit(2_000); + } + catch (ArgumentException) + { + } + } + if (Directory.Exists(RootPath)) + Directory.Delete(RootPath, recursive: true); + } + + private JsonElement[] RecordedRequests() => + File.Exists(_recordPath) + ? File.ReadAllLines(_recordPath) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray() + : []; + + private static bool IsRunning(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 64331f648..2d42243af 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -88,6 +88,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs index 7df6ae0d7..60a8c46a0 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs @@ -1,3 +1,4 @@ +using OpenClaw.Shared.Codex; using OpenClawTray.Presentation; using OpenClawTray.Services; @@ -16,14 +17,162 @@ private static SettingsPageViewModel NewVm( out SettingsManager settings, out FakeAppCommands appCommands, out RecordingUiDispatcher dispatcher, - out TempDir temp) + out TempDir temp, + bool codexExecutableAvailable = false) { temp = new TempDir(); settings = new SettingsManager(temp.Path); appCommands = new FakeAppCommands(); dispatcher = new RecordingUiDispatcher(); var store = new SettingsStore(settings, dispatcher); - return new SettingsPageViewModel(store, appCommands); + return new SettingsPageViewModel(store, appCommands, () => codexExecutableAvailable); + } + + [Theory] + [InlineData(CodexSessionAccessMode.Off)] + [InlineData(CodexSessionAccessMode.ReadOnly)] + [InlineData(CodexSessionAccessMode.ReadAndSteer)] + public void CodexSessionAccess_AllChoicesPersistThroughSettingsStore(CodexSessionAccessMode mode) + { + var vm = NewVm(out var settings, out var appCommands, out _, out var temp); + using (temp) + { + settings.CodexSessionAccess = mode == CodexSessionAccessMode.Off + ? CodexSessionAccessMode.ReadOnly + : CodexSessionAccessMode.Off; + vm.Activate(null); + + vm.CodexSessionAccess = mode; + + Assert.Equal(mode, settings.CodexSessionAccess); + Assert.Equal(mode, new SettingsManager(temp.Path).CodexSessionAccess); + Assert.Equal(1, appCommands.NotifySettingsSavedCount); + } + } + + [Theory] + [InlineData(0, CodexSessionAccessMode.Off)] + [InlineData(1, CodexSessionAccessMode.ReadOnly)] + [InlineData(2, CodexSessionAccessMode.ReadAndSteer)] + public void CodexSessionAccessIndex_BoundOrderPersistsExactMode( + int selectedIndex, + CodexSessionAccessMode expectedMode) + { + var vm = NewVm(out _, out _, out _, out var temp); + using (temp) + { + vm.Activate(null); + vm.CodexSessionAccessIndex = selectedIndex == 0 ? 1 : 0; + + vm.CodexSessionAccessIndex = selectedIndex; + + Assert.Equal(selectedIndex, vm.CodexSessionAccessIndex); + Assert.Equal(expectedMode, new SettingsManager(temp.Path).CodexSessionAccess); + } + } + + [Fact] + public void CodexSessionAccessIndex_PersistenceFailureRestoresModeAndDoesNotClaimSave() + { + using var temp = new TempDir(); + var settingsDirectory = Path.Combine(temp.Path, "settings"); + var settings = new SettingsManager(settingsDirectory) + { + CodexSessionAccess = CodexSessionAccessMode.ReadOnly, + }; + settings.SaveOrThrow(); + var settingsFile = Path.Combine(settingsDirectory, "settings.json"); + File.SetAttributes(settingsFile, FileAttributes.ReadOnly); + var appCommands = new FakeAppCommands(); + var vm = new SettingsPageViewModel( + new SettingsStore(settings, new RecordingUiDispatcher()), + appCommands, + () => true); + vm.Activate(null); + + try + { + var exception = Record.Exception(() => vm.CodexSessionAccessIndex = 0); + + Assert.Null(exception); + Assert.Equal(CodexSessionAccessMode.ReadOnly, vm.CodexSessionAccess); + Assert.Equal(CodexSessionAccessMode.ReadOnly, settings.CodexSessionAccess); + Assert.Equal(CodexSessionAccessMode.ReadOnly, new SettingsManager(settingsDirectory).CodexSessionAccess); + Assert.Equal(0, appCommands.NotifySettingsSavedCount); + } + finally + { + File.SetAttributes(settingsFile, FileAttributes.Normal); + } + } + + [Fact] + public void ExternalModeAndAvailabilityChange_RaisesEachDerivedStatusOnce() + { + var executableAvailable = false; + using var temp = new TempDir(); + var settings = new SettingsManager(temp.Path); + var store = new SettingsStore(settings, new RecordingUiDispatcher()); + var vm = new SettingsPageViewModel(store, new FakeAppCommands(), () => executableAvailable); + vm.Activate(null); + var notifications = new Dictionary(StringComparer.Ordinal); + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName?.StartsWith("IsCodex", StringComparison.Ordinal) == true) + notifications[e.PropertyName] = notifications.GetValueOrDefault(e.PropertyName) + 1; + }; + + executableAvailable = true; + settings.CodexSessionAccess = CodexSessionAccessMode.ReadOnly; + settings.Save(); + + Assert.Equal(1, notifications[nameof(SettingsPageViewModel.IsCodexAccessOff)]); + Assert.Equal(1, notifications[nameof(SettingsPageViewModel.IsCodexCatalogAvailable)]); + Assert.Equal(1, notifications[nameof(SettingsPageViewModel.IsCodexCatalogUnavailable)]); + Assert.Equal(1, notifications[nameof(SettingsPageViewModel.IsCodexSteeringUnavailable)]); + } + + [Fact] + public void CodexSessionAccess_ExternalChangeReloadsWithoutEcho() + { + var vm = NewVm(out var settings, out var appCommands, out _, out var temp); + using (temp) + { + vm.Activate(null); + + settings.CodexSessionAccess = CodexSessionAccessMode.ReadAndSteer; + settings.Save(); + + Assert.Equal(CodexSessionAccessMode.ReadAndSteer, vm.CodexSessionAccess); + Assert.Equal(0, appCommands.NotifySettingsSavedCount); + } + } + + [Theory] + [InlineData(CodexSessionAccessMode.Off, true, false, false, false)] + [InlineData(CodexSessionAccessMode.ReadOnly, true, true, false, false)] + [InlineData(CodexSessionAccessMode.ReadOnly, false, false, true, false)] + [InlineData(CodexSessionAccessMode.ReadAndSteer, true, true, false, true)] + [InlineData(CodexSessionAccessMode.ReadAndSteer, false, false, true, true)] + public void CodexSessionAccess_StatusSeparatesCatalogAvailabilityFromUnavailableSteering( + CodexSessionAccessMode mode, + bool executableAvailable, + bool catalogAvailable, + bool catalogUnavailable, + bool steeringUnavailable) + { + var vm = NewVm(out var settings, out _, out _, out var temp, executableAvailable); + using (temp) + { + settings.CodexSessionAccess = mode; + + vm.Activate(null); + + Assert.Equal(mode == CodexSessionAccessMode.Off, vm.IsCodexAccessOff); + Assert.Equal(catalogAvailable, vm.IsCodexCatalogAvailable); + Assert.Equal(catalogUnavailable, vm.IsCodexCatalogUnavailable); + Assert.Equal(steeringUnavailable, vm.IsCodexSteeringUnavailable); + } } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs index 716f5b355..ad9aaa877 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs @@ -1,5 +1,6 @@ using OpenClawTray.Presentation; using OpenClawTray.Services; +using OpenClaw.Shared.Codex; namespace OpenClaw.Tray.Tests.Presentation; @@ -148,4 +149,153 @@ public void Update_ThrowingEdit_ResetsSelfWriteDepth_SoLaterExternalSaveRepublis Assert.Equal(1, changed); } } + + [Fact] + public async Task ConcurrentAtomicWriters_PreservePermissionRevocationAndGatewayChange() + { + var store = NewStore(out var settings, out _, out var temp); + using (temp) + { + settings.CodexSessionAccess = CodexSessionAccessMode.ReadOnly; + settings.GatewayUrl = "wss://old.example.test"; + settings.Save(); + using var firstEntered = new ManualResetEventSlim(); + using var releaseFirst = new ManualResetEventSlim(); + using var gatewayAttempted = new ManualResetEventSlim(); + using var gatewayCompleted = new ManualResetEventSlim(); + + var revoke = Task.Run(() => store.Update(editor => + { + editor.CodexSessionAccess = CodexSessionAccessMode.Off; + firstEntered.Set(); + Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5))); + })); + Assert.True(firstEntered.Wait(TimeSpan.FromSeconds(5))); + + var gatewayChange = Task.Run(() => + { + gatewayAttempted.Set(); + settings.UpdateAndSave(manager => manager.GatewayUrl = "wss://new.example.test"); + gatewayCompleted.Set(); + }); + Assert.True(gatewayAttempted.Wait(TimeSpan.FromSeconds(5))); + try + { + Assert.False(gatewayCompleted.Wait(TimeSpan.FromMilliseconds(500))); + } + finally + { + releaseFirst.Set(); + } + await Task.WhenAll(revoke, gatewayChange).WaitAsync(TimeSpan.FromSeconds(5)); + + var snapshot = store.Current; + var persisted = new SettingsManager(temp.Path); + Assert.Equal(CodexSessionAccessMode.Off, snapshot.CodexSessionAccess); + Assert.Equal(CodexSessionAccessMode.Off, persisted.CodexSessionAccess); + Assert.Equal("wss://new.example.test", persisted.GatewayUrl); + } + } + + [Fact] + public async Task DirectSetterAndSave_WaitForStoreTransactionAndPreserveBothChanges() + { + var store = NewStore(out var settings, out _, out var temp); + using (temp) + { + settings.CodexSessionAccess = CodexSessionAccessMode.ReadOnly; + settings.GatewayUrl = "wss://old.example.test"; + settings.Save(); + using var editEntered = new ManualResetEventSlim(); + using var releaseEdit = new ManualResetEventSlim(); + using var setterCompleted = new ManualResetEventSlim(); + + var revoke = Task.Run(() => store.Update(editor => + { + editEntered.Set(); + Assert.True(releaseEdit.Wait(TimeSpan.FromSeconds(5))); + editor.CodexSessionAccess = CodexSessionAccessMode.Off; + })); + Assert.True(editEntered.Wait(TimeSpan.FromSeconds(5))); + + var directWriter = Task.Run(() => + { + settings.GatewayUrl = "wss://new.example.test"; + setterCompleted.Set(); + settings.Save(); + }); + try + { + Assert.False(setterCompleted.Wait(TimeSpan.FromMilliseconds(500))); + } + finally + { + releaseEdit.Set(); + } + + await Task.WhenAll(revoke, directWriter).WaitAsync(TimeSpan.FromSeconds(5)); + var persisted = new SettingsManager(temp.Path); + Assert.Equal(CodexSessionAccessMode.Off, persisted.CodexSessionAccess); + Assert.Equal("wss://new.example.test", persisted.GatewayUrl); + } + } + + [Fact] + public async Task PersistenceFailure_DoesNotEscapeAndReleasesOwnerLock() + { + using var temp = new TempDir(); + var invalidDirectory = Path.Combine(temp.Path, "not-a-directory"); + File.WriteAllText(invalidDirectory, "occupied"); + var settings = new SettingsManager(invalidDirectory); + var store = new SettingsStore(settings, new RecordingUiDispatcher()); + + var exception = Record.Exception(() => + store.Update(editor => editor.CodexSessionAccess = CodexSessionAccessMode.ReadOnly)); + + Assert.Null(exception); + Assert.Equal(CodexSessionAccessMode.ReadOnly, store.Current.CodexSessionAccess); + await Task.Run(() => settings.GatewayUrl = "wss://after-failure.example.test") + .WaitAsync(TimeSpan.FromSeconds(2)); + Assert.Equal("wss://after-failure.example.test", settings.GatewayUrl); + } + + [Fact] + public void CodexPermissionUpdate_PartialTempWriteFailurePreservesDurableAndInMemoryMode() + { + using var temp = new TempDir(); + var settings = new SettingsManager(temp.Path) + { + CodexSessionAccess = CodexSessionAccessMode.ReadOnly, + }; + settings.SaveOrThrow(); + var originalJson = File.ReadAllText(Path.Combine(temp.Path, "settings.json")); + settings.FileOperations = new PartialWriteFailureSettingsFileOperations(); + var store = new SettingsStore(settings, new RecordingUiDispatcher()); + + var saved = store.TryUpdateCodexSessionAccess(CodexSessionAccessMode.Off); + + Assert.False(saved); + Assert.Equal(CodexSessionAccessMode.ReadOnly, store.Current.CodexSessionAccess); + Assert.Equal(originalJson, File.ReadAllText(Path.Combine(temp.Path, "settings.json"))); + Assert.Equal(CodexSessionAccessMode.ReadOnly, new SettingsManager(temp.Path).CodexSessionAccess); + Assert.Empty(Directory.EnumerateFiles(temp.Path, "*.tmp")); + } + + private sealed class PartialWriteFailureSettingsFileOperations : ISettingsFileOperations + { + public bool Exists(string path) => File.Exists(path); + + public void WriteAllText(string path, string contents) + { + File.WriteAllText(path, contents[..Math.Min(16, contents.Length)]); + throw new IOException("simulated partial temp write"); + } + + public void Replace(string source, string destination, string backup) => + File.Replace(source, destination, backup, ignoreMetadataErrors: true); + + public void Move(string source, string destination) => File.Move(source, destination); + + public void Delete(string path) => File.Delete(path); + } } diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index 1dc58374a..d8b68c183 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -1,11 +1,37 @@ using System.Text.Json; using OpenClaw.Shared; +using OpenClaw.Shared.Codex; using OpenClawTray.Services; namespace OpenClaw.Tray.Tests; public class SettingsRoundTripTests { + [Fact] + public void CodexSessionAccess_DefaultsAndValidModesRoundTrip() + { + Assert.Equal(CodexSessionAccessMode.Off, new SettingsData().CodexSessionAccess); + Assert.Equal(CodexSessionAccessMode.ReadOnly, RoundTrip(CodexSessionAccessMode.ReadOnly)); + Assert.Equal(CodexSessionAccessMode.ReadAndSteer, RoundTrip(CodexSessionAccessMode.ReadAndSteer)); + } + + [Fact] + public void CodexSessionAccess_UndefinedValueIsRejected() + { + Assert.Null(SettingsData.FromJson("{\"CodexSessionAccess\":99}")); + } + + private static CodexSessionAccessMode RoundTrip(CodexSessionAccessMode mode) + { + var restored = SettingsData.FromJson(new SettingsData + { + CodexSessionAccess = mode, + }.ToJson()); + + Assert.NotNull(restored); + return restored.CodexSessionAccess; + } + [Fact] public void RoundTrip_AllFields_Preserved() { diff --git a/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs index 5a87d40cf..38ca26449 100644 --- a/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs +++ b/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs @@ -52,6 +52,28 @@ public void SkillMd_command_set_matches_capability_registry() } } + [Fact] + public void SkillMd_documents_exactly_the_three_read_only_codex_catalog_commands() + { + var documented = ParseCommandHeadings(File.ReadAllText(LocateSkillMd())); + var codexCommands = documented + .Where(command => command.StartsWith("codex.appServer.", StringComparison.Ordinal)) + .OrderBy(command => command, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + new[] + { + "codex.appServer.thread.turns.list.v1", + "codex.appServer.threads.history.list.v1", + "codex.appServer.threads.list.v1", + }, + codexCommands); + Assert.DoesNotContain("codex.appServer.thread.resume.v1", documented); + Assert.DoesNotContain("codex.appServer.turn.steer.v1", documented); + Assert.DoesNotContain("codex.appServer.turn.interrupt.v1", documented); + } + /// /// skill.md lists each command under its own H3 heading like /// ### system.notify. Anything matching ### <dotted.name> @@ -100,6 +122,9 @@ private static HashSet GetCapabilityCommands() commands.Add(command); } } + commands.Add(CodexSessionCapability.ThreadsListCommand); + commands.Add(CodexSessionCapability.ThreadsHistoryListCommand); + commands.Add(CodexSessionCapability.ThreadTurnsListCommand); return commands; }