mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-12 09:11:14 +08:00
Compare commits
3 Commits
fix/extern
...
fix-codex-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54429aa15d | ||
|
|
b4bdd50c64 | ||
|
|
690c521704 |
@@ -19,11 +19,9 @@ or validating a change without wasting hours.
|
||||
Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
|
||||
1. Inspect the diff and classify the touched surface:
|
||||
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed`
|
||||
- normal source checkout, tests only: `pnpm test:changed`
|
||||
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
|
||||
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
|
||||
- Codex worktree or linked/sparse checkout, changed gates or anything broad: `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... --shell -- "pnpm check:changed"`
|
||||
- source: `pnpm changed:lanes --json`, then `pnpm check:changed`
|
||||
- tests only: `pnpm test:changed`
|
||||
- one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
|
||||
2. Reproduce narrowly before fixing.
|
||||
@@ -38,12 +36,6 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
|
||||
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
|
||||
- If deps are missing, run `pnpm install`, retry once, then report the first actionable error.
|
||||
- In a Codex worktree or linked/sparse checkout, do not run direct local
|
||||
`pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, or `scripts/committer` until
|
||||
you have verified pnpm will not reconcile or reinstall dependencies. Use
|
||||
`node scripts/run-vitest.mjs` for tiny local proof, `node
|
||||
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
|
||||
after the relevant remote or node-wrapper proof is already clean.
|
||||
- For Blacksmith Testbox proof, use Crabbox first. `pnpm crabbox:run -- --provider
|
||||
blacksmith-testbox --timing-json -- <command...>` warms, claims, syncs, runs,
|
||||
reports, and cleans up one-shot boxes. Reuse only an id/slug created in this
|
||||
@@ -63,14 +55,6 @@ OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
|
||||
|
||||
Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
|
||||
`pnpm test` wrapper so project routing, workers, and setup stay correct.
|
||||
When the checkout is a Codex worktree, prefer the direct node harness instead:
|
||||
|
||||
```bash
|
||||
node scripts/run-vitest.mjs <path-or-filter>
|
||||
```
|
||||
|
||||
That keeps the test scoped without giving pnpm a chance to run dependency
|
||||
status checks or install reconciliation in a linked worktree.
|
||||
|
||||
## Command Semantics
|
||||
|
||||
|
||||
@@ -47,10 +47,8 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Package manager/runtime: repo defaults only. No swaps without approval.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
|
||||
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
|
||||
- Checks in a normal source checkout: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox, not locally.
|
||||
- Tests: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- Checks: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
|
||||
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
|
||||
@@ -59,8 +57,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
## Validation
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
|
||||
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -6,55 +6,15 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Control UI/WebChat: add a persisted auto-scroll mode selector so users can keep the current near-bottom behavior, always follow streaming output, or turn automatic streaming scroll off and use the New messages button manually. Fixes #7648 and #81287. Thanks @BunsDev.
|
||||
- ACP: add `acp.fallbacks` so ACP turns can try configured backup runtime backends when the primary backend is unavailable before any output is emitted. (#69542) Thanks @kaseonedge.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI tables: preserve muted/color styling on wrapped continuation lines after multiline cells, keeping `openclaw plugins list` descriptions readable.
|
||||
- Web: honor explicitly configured global `web_search` providers during provider ownership resolution while keeping sandboxed `web_fetch` limited to bundled providers.
|
||||
- iOS: restore first-use Contacts, Calendar, and Reminders permission prompts and add Privacy & Access status/actions in Settings. Thanks @BunsDev.
|
||||
- Canvas: return not found for malformed percent-encoded Canvas/A2UI/document asset paths and keep decoded parent traversal blocked before path normalization.
|
||||
- Telegram: allow trusted local Bot API media files whose filenames start with dots instead of falling back to remote download.
|
||||
- Agents: remap injected context files under dot-dot-prefixed workspace directories when a run switches to an effective sandbox workspace.
|
||||
- Agents: allow dot-dot-prefixed filenames such as `..note.txt` through sandbox FS bridge, remote sandbox reads, and apply_patch summaries without mistaking the name for parent traversal.
|
||||
- CLI/migrate: hide per-item source/plugin hints on non-conflicting Codex skill and plugin selection prompts, keeping the hint text reserved for rows that actually need attention. Thanks @sjf.
|
||||
- Codex harness: treat high-confidence app-server OAuth refresh invalidation as a terminal auth-profile failure, stopping repeated raw token-refresh errors without turning entitlement or usage-limit payloads into re-auth prompts.
|
||||
- CLI/migrate: humanize Codex conflict-status messaging across the migrate UI so selection prompts and plan/result rows say "Codex skill already installed in workspace" instead of surfacing internal `MIGRATION_REASON_*` codes. Thanks @sjf.
|
||||
- CLI/migrate: render migrate result rows with distinct glyphs for manual-review (🔍) and archive (📖) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf.
|
||||
- CLI/migrate: split Codex migrate output into separate preview and result phases so the Before plan and After result render through clack with independently tunable copy. Thanks @sjf.
|
||||
- Codex app-server: project bundle and user MCP servers into Codex threads, rotate threads when an MCP server is disabled, scope bundle MCP injection to bundled servers, and resend user MCP config on resume so MCP changes take effect mid-session without restarting the agent. (#81551) Thanks @jalehman.
|
||||
- Codex migration: invoke the managed Codex binary instead of a stale system `codex` for source-config migration plans, so users running the bundled Codex runtime get plan output that matches the binary the gateway will actually use. (#81582) Thanks @fuller-stack-dev.
|
||||
- Subagents/maintenance: preserve pending subagent registry sessions during session-store cleanup, pruning, and disk-budget enforcement so in-flight subagent runs are not deleted by background maintenance before they complete. (#81498) Thanks @ai-hpc.
|
||||
- Plugin SDK: restore the deprecated `openclaw/plugin-sdk/memory-core` package subpath as an alias of `memory-host-core`, so published memory companion plugins that still import it resolve on current hosts.
|
||||
- Control UI/chat: reconcile terminal and reconnect run cleanup with cached session activity, stale compaction/fallback indicators, and a compact composer run-status chip so completed or interrupted turns do not leave Stop active. Fixes #76874 and #64220; refs #71630. Thanks @BunsDev.
|
||||
- Maintainer tooling: clarify which pnpm test/check commands are safe locally versus inside Codex worktrees, routing linked-worktree gates through node wrappers and Crabbox/Testbox.
|
||||
- Auto-reply: preserve same-key ordering when debounced inbound work falls back to immediate flushes, so follow-up turns cannot overtake an active buffered flush.
|
||||
- Telegram/WhatsApp: keep Telegram same-chat replies ordered behind active no-delay turns without blocking WhatsApp follow-up message dispatch.
|
||||
- Codex migration: avoid duplicate cached plugin bundle warnings when app-server plugin inventory is available.
|
||||
- Agents: suppress aborted embedded assistant partials, reasoning text, reply directives, and stale prior replies before user-facing delivery while preserving clean timeout/error payloads. Fixes #48241. Thanks @BunsDev, @andyliu, and @yassinebkr.
|
||||
- Agents: allow dot-dot-prefixed filenames such as `..file.txt` inside workspace and sandbox path policy while still rejecting real parent traversal.
|
||||
- Native image input: detect Windows drive image paths in plain prompts so `C:\...\screenshot.png` references are not missed.
|
||||
- Media: normalize Windows-style filename hints before staging attachments, remote media, audio transcodes, and saved-media display names, so POSIX hosts do not preserve drive or directory text in generated filenames.
|
||||
- Media references: resolve first-level inbound media files whose IDs start with dots instead of treating names like `..photo.png` as parent traversal.
|
||||
- iOS/chat: resize PhotosPicker image attachments to capped JPEGs before staging and sending, stripping source metadata and keeping oversized camera photos under the chat upload budget. Fixes #68524. Thanks @BunsDev.
|
||||
- Control UI: keep shared form, config, and usage text-entry controls at 16px on touch-primary devices while preserving chat composer input sizing, so iOS Safari no longer auto-zooms focused fields. Fixes #64651; carries forward #64673. Thanks @NianJiuZst and @BunsDev.
|
||||
- Codex harness: classify native app-server token-refresh logout and relogin failures as authentication refresh errors, so users get re-authentication guidance instead of a raw runtime failure.
|
||||
- Agents/trajectory: make the trajectory flush cleanup timeout configurable with `OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS`, preserving the 10s default while slower stores drain. Refs #75839. Thanks @BunsDev.
|
||||
- Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly.
|
||||
- Agents: preserve source-reply delivery metadata when merging tool-returned media into the final reply, keeping message-tool-only replies deliverable and mirrored. Thanks @pashpashpash and @vincentkoc.
|
||||
- Replies: treat rich presentation, interactive controls, and channel-native payload data as outbound content across follow-up, heartbeat, cron, ACP, and block-streaming delivery paths, preventing card/button-only replies from being dropped as empty.
|
||||
- macOS/companion: require system TLS trust before pinning a first-use direct `wss://` gateway certificate and honor `gateway.remote.tlsFingerprint` as the explicit pin for remote node-mode sessions, so fresh endpoints fail closed when macOS cannot trust the certificate unless configured out of band. Fixes #50642. Thanks @BunsDev.
|
||||
- Update: snapshot config before update-time repair and restart writes, preserve plugin install records through doctor cleanup, and keep update-time config size drops from blocking the update while pointing users to the pre-update backup. Fixes #80077. (#80257) Thanks @Jerry-Xin and @vincentkoc.
|
||||
- WebChat/TUI: route Codex `tools.message` source replies to the active internal UI turn and mirror them to session history, so message-tool-only harness replies, including rich presentation and button-only replies, no longer disappear while WebChat and TUI remain non-targetable outbound channels. (#81586) Thanks @pashpashpash.
|
||||
- Codex auth: accept OAuth profiles backed by `oauthRef` during runtime auth selection, so official Codex OAuth logins are used by app-server agent runs. (#81633) Thanks @obviyus.
|
||||
- Sessions/status: classify ACP spawn-child sessions as `kind: "spawn-child"` instead of `"direct"` in `openclaw sessions` and status output; extract the duplicated session-kind classifier into a shared helper (`src/sessions/classify-session-kind.ts`) so both surfaces stay in sync. Fixes catalog #19. (#79544)
|
||||
- Sessions/Gateway: report `agentRuntime.id: "acpx"` (or stored backend id) with `source: "session-key"` for ACP control-plane session rows in `openclaw sessions --json`, `openclaw status`, and Gateway session RPC responses instead of the incorrect `"auto"` / `"pi"` implicit fallback. Fixes catalog #18. (#79550)
|
||||
- Telegram: delete tool-progress-only draft bubbles before rotating to the real answer, preventing orphaned progress messages in streamed replies.
|
||||
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
|
||||
- ACP: preserve redacted numeric JSON-RPC `RequestError` details in runtime failure text, so backend diagnostics are visible instead of only `Internal error`. Fixes #81126. (#81188) Thanks @vyctorbrzezowski.
|
||||
- Agents: cache unchanged PI model discovery stores and model lookups, reducing repeated model-resolution startup latency under large model configs. Fixes #78851.
|
||||
- Onboarding: carry returned Codex plugin migration config through the OpenAI model wizard so accepted plugin migrations are saved with the final config write.
|
||||
- Security/Windows ACL audit: classify Anonymous Logon, Guests, Interactive, Local, and Network SIDs as world-equivalent principals so broadly writable paths stay critical instead of being downgraded to group-writable. Fixes #74350. (#74383) Thanks @dwc1997.
|
||||
- Media-understanding: retry transient remote attachment fetch failures before audio or vision processing, so Discord voice notes are not lost after one network/CDN blip. Fixes #74316. Thanks @vyctorbrzezowski and @gabrielexito-stack.
|
||||
- Control UI: order timestamped live stream and tool items before untimestamped history fallbacks, keeping chat history in visible time order. Fixes #80759. (#81016) Thanks @akrimm702.
|
||||
@@ -97,7 +57,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: keep active reply runs visible to stuck-session diagnostics and clear no-active-work recovery state, preventing stale queued lanes after compaction or tool failures. Fixes #80677. (#81302)
|
||||
- Codex app-server: rotate incompatible context-engine-managed native threads so Lossless-managed sessions do not resume stale hidden Codex history. (#81223) Thanks @jalehman.
|
||||
- Codex cron: execute scheduled command-style automation payloads before workspace bootstrap or memory review, preserving existing isolated cron jobs after Codex harness migration. (#81510) Thanks @jalehman.
|
||||
- Plugin LLM completions: honor Codex agent-runtime policy for canonical OpenAI model refs, so context-engine summarizers can use Codex OAuth instead of requiring direct `OPENAI_API_KEY` auth. (#81511) Thanks @jalehman.
|
||||
- Gateway/OpenAI HTTP: return OpenAI-compatible 400 errors for invalid sampling params and provider validation failures instead of collapsing them to 500s. (#81275) Thanks @Lellansin.
|
||||
- Telegram: publish plugin and skill command description localizations to native command menus while filtering unsupported locale codes and preserving Telegram command limits. (#81351) Thanks @jzakirov.
|
||||
- Limit hook CLI tool authority [AI]. (#81065) Thanks @pgondhi987.
|
||||
@@ -155,7 +114,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins doctor: report stale plugin config warnings and avoid claiming full plugin health when config warnings remain. (#81515) Thanks @BKF-Gitty.
|
||||
- Sessions: display `model: "<agentId>-acp"` / `modelProvider: "acpx"` (ACP-runtime sentinel) for ACP control-plane sessions in `openclaw sessions` output, instead of the agent's configured model which was misleading. Catalog finding 20. (#79543)
|
||||
- Slack: normalize message read `before` and `after` timestamp bounds before calling Slack history or thread reply APIs. Fixes #80835. (#81338) Thanks @honor2030.
|
||||
- Gateway: throttle assistant/thinking agent event fanout during streaming bursts without dropping buffered deltas. (#80335) Thanks @samzong.
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -615,7 +573,6 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/migrate: show native Codex plugin names before truncated plan items and prompt for plugin activation explicitly during interactive Codex migration instead of silently keeping every planned plugin. Thanks @kevinslin.
|
||||
- CLI/migrate: leave already configured target Codex plugins unchecked in the interactive plugin selector and show a `plugin exists` conflict hint while keeping new plugin activations selected by default. Thanks @kevinslin.
|
||||
- CLI/migrate: return cleanly without apply confirmation when interactive Codex migration leaves both skill copies and native plugin activations unselected. Thanks @kevinslin.
|
||||
- Gateway/sessions: extend the per-call sessions-list `rowContext` cache with memoization for `resolveSessionDisplayModelIdentityRef`, thinking metadata, and `resolveModelCostConfig` so deterministic per-row resolvers run once per unique `(provider, model[, agentId])` tuple instead of once per session. Cuts CPU on `sessions.list` for stores with many sessions sharing a small set of model tuples; behavior is unchanged for callers that pass no `rowContext`. Thanks @rolandrscheel.
|
||||
- Cron CLI: add `openclaw cron list --agent <id>`, normalize the requested agent id, and include jobs without a stored agent id under the configured default agent while keeping `cron list` unfiltered when no agent is supplied. Fixes #77118. Thanks @zhanggttry.
|
||||
- Slack/performance: reduce message preparation, stream recipient lookup, and thread-context allocation overhead on Slack reply hot paths. Thanks @vincentkoc.
|
||||
- Control UI/chat: strip untrusted sender metadata from live streams and transcript display, preserve canvas preview anchors, and stop operator UI clients from injecting their internal client id as sender identity. Fixes #78739. Thanks @tmimmanuel, @guguangxin-eng, @hclsys, and @BunsDev.
|
||||
|
||||
@@ -4,19 +4,15 @@ import OpenClawKit
|
||||
|
||||
final class CalendarService: CalendarServicing {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized: Bool = if status == .notDetermined || status == .writeOnly {
|
||||
await Self.requestFullEventAccess()
|
||||
} else {
|
||||
EventKitAuthorization.allowsRead(status: status)
|
||||
}
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
])
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let (start, end) = Self.resolveRange(
|
||||
startISO: params.startISO,
|
||||
endISO: params.endISO)
|
||||
@@ -41,19 +37,15 @@ final class CalendarService: CalendarServicing {
|
||||
}
|
||||
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized: Bool = if status == .notDetermined {
|
||||
await Self.requestWriteOnlyEventAccess()
|
||||
} else {
|
||||
EventKitAuthorization.allowsWrite(status: status)
|
||||
}
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
])
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw NSError(domain: "Calendar", code: 3, userInfo: [
|
||||
@@ -103,24 +95,6 @@ final class CalendarService: CalendarServicing {
|
||||
return OpenClawCalendarAddPayload(event: payload)
|
||||
}
|
||||
|
||||
private static func requestFullEventAccess() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToEvents { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestWriteOnlyEventAccess() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestWriteOnlyAccessToEvents { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveCalendar(
|
||||
store: EKEventStore,
|
||||
calendarId: String?,
|
||||
|
||||
@@ -97,17 +97,14 @@ final class ContactsService: ContactsServicing {
|
||||
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(status: CNAuthorizationStatus) async -> Bool {
|
||||
private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
return true
|
||||
case .notDetermined:
|
||||
return await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
// Don’t prompt during node.invoke; the caller should instruct the user to grant permission.
|
||||
// Prompts block the invoke and lead to timeouts in headless flows.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
@@ -116,14 +113,15 @@ final class ContactsService: ContactsServicing {
|
||||
}
|
||||
|
||||
private static func authorizedStore() async throws -> CNContactStore {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(status: status)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
return CNContactStore()
|
||||
return store
|
||||
}
|
||||
|
||||
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
|
||||
|
||||
@@ -52,14 +52,6 @@
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
|
||||
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to add events when you enable calendar access.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>OpenClaw uses your contacts so you can search and reference people while using the assistant.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>OpenClaw discovers and connects to your OpenClaw gateway on the local network.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
@@ -72,8 +64,6 @@
|
||||
<string>OpenClaw may use motion data to support device-aware interactions and automations.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>OpenClaw needs photo library access when you choose existing photos to share with your assistant.</string>
|
||||
<key>NSRemindersFullAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayOnboardingView: View {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum PermissionRequestBridge {
|
||||
final class Box: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Bool, Never>?
|
||||
private var hasResumed = false
|
||||
|
||||
func install(_ continuation: CheckedContinuation<Bool, Never>) -> Bool {
|
||||
self.lock.lock()
|
||||
if self.hasResumed {
|
||||
self.lock.unlock()
|
||||
continuation.resume(returning: false)
|
||||
return false
|
||||
}
|
||||
self.continuation = continuation
|
||||
self.lock.unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func resume(_ value: Bool) {
|
||||
self.lock.lock()
|
||||
guard !self.hasResumed else {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.hasResumed = true
|
||||
let continuation = self.continuation
|
||||
self.continuation = nil
|
||||
self.lock.unlock()
|
||||
continuation?.resume(returning: value)
|
||||
}
|
||||
|
||||
func canStartRequest() -> Bool {
|
||||
self.lock.lock()
|
||||
let canStart = !self.hasResumed
|
||||
self.lock.unlock()
|
||||
return canStart
|
||||
}
|
||||
}
|
||||
|
||||
static func awaitRequest(
|
||||
_ start: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool
|
||||
{
|
||||
let box = Box()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation(isolation: nil) { continuation in
|
||||
guard !Task.isCancelled else {
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
}
|
||||
guard box.install(continuation) else { return }
|
||||
Task { @MainActor in
|
||||
guard box.canStartRequest() else { return }
|
||||
start { granted in
|
||||
box.resume(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
box.resume(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,15 @@ import OpenClawKit
|
||||
|
||||
final class RemindersService: RemindersServicing {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized: Bool = if status == .notDetermined || status == .writeOnly {
|
||||
await Self.requestFullReminderAccess()
|
||||
} else {
|
||||
EventKitAuthorization.allowsRead(status: status)
|
||||
}
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
])
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let limit = max(1, min(params.limit ?? 50, 500))
|
||||
let statusFilter = params.status ?? .incomplete
|
||||
|
||||
@@ -52,19 +48,15 @@ final class RemindersService: RemindersServicing {
|
||||
}
|
||||
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized: Bool = if status == .notDetermined {
|
||||
await Self.requestFullReminderAccess()
|
||||
} else {
|
||||
EventKitAuthorization.allowsWrite(status: status)
|
||||
}
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
])
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw NSError(domain: "Reminders", code: 3, userInfo: [
|
||||
@@ -108,15 +100,6 @@ final class RemindersService: RemindersServicing {
|
||||
return OpenClawRemindersAddPayload(reminder: payload)
|
||||
}
|
||||
|
||||
private static func requestFullReminderAccess() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToReminders { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveList(
|
||||
store: EKEventStore,
|
||||
listId: String?,
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import Contacts
|
||||
import EventKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct PrivacyAccessSectionView: View {
|
||||
@State private var contactsStatus: CNAuthorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||
@State private var calendarStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
@State private var remindersStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup("Privacy & Access") {
|
||||
self.permissionRow(
|
||||
title: "Contacts",
|
||||
icon: "person.crop.circle",
|
||||
status: self.statusText(for: self.contactsStatus),
|
||||
detail: "Search and add contacts from the assistant.",
|
||||
actionTitle: self.actionTitle(for: self.contactsStatus),
|
||||
action: self.handleContactsAction)
|
||||
|
||||
self.permissionRow(
|
||||
title: "Calendar (Add Events)",
|
||||
icon: "calendar.badge.plus",
|
||||
status: self.calendarWriteStatusText,
|
||||
detail: "Add events with least privilege.",
|
||||
actionTitle: self.calendarWriteActionTitle,
|
||||
action: self.handleCalendarWriteAction)
|
||||
|
||||
self.permissionRow(
|
||||
title: "Calendar (View Events)",
|
||||
icon: "calendar",
|
||||
status: self.calendarReadStatusText,
|
||||
detail: "List and read calendar events.",
|
||||
actionTitle: self.calendarReadActionTitle,
|
||||
action: self.handleCalendarReadAction)
|
||||
|
||||
self.permissionRow(
|
||||
title: "Reminders",
|
||||
icon: "checklist",
|
||||
status: self.remindersStatusText,
|
||||
detail: "List, add, and complete reminders.",
|
||||
actionTitle: self.remindersActionTitle,
|
||||
action: self.handleRemindersAction)
|
||||
}
|
||||
.onAppear { self.refreshAll() }
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
self.refreshAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func permissionRow(
|
||||
title: String,
|
||||
icon: String,
|
||||
status: String,
|
||||
detail: String,
|
||||
actionTitle: String?,
|
||||
action: (() -> Void)?) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Label(title, systemImage: icon)
|
||||
Spacer()
|
||||
Text(status)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(self.statusColor(for: status))
|
||||
}
|
||||
Text(detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let actionTitle, let action {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.footnote)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func statusColor(for status: String) -> Color {
|
||||
switch status {
|
||||
case "Allowed":
|
||||
.green
|
||||
case "Not Set":
|
||||
.orange
|
||||
case "Add-Only":
|
||||
.yellow
|
||||
default:
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private func statusText(for cnStatus: CNAuthorizationStatus) -> String {
|
||||
switch cnStatus {
|
||||
case .authorized, .limited:
|
||||
"Allowed"
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func actionTitle(for cnStatus: CNAuthorizationStatus) -> String? {
|
||||
switch cnStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleContactsAction() {
|
||||
switch self.contactsStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
_ = await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarWriteStatusText: String {
|
||||
switch self.calendarStatus {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
"Allowed"
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarWriteActionTitle: String? {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCalendarWriteAction() {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
_ = await self.requestCalendarWriteOnly()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarReadStatusText: String {
|
||||
switch self.calendarStatus {
|
||||
case .authorized, .fullAccess:
|
||||
"Allowed"
|
||||
case .writeOnly:
|
||||
"Add-Only"
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarReadActionTitle: String? {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
"Request Full Access"
|
||||
case .writeOnly:
|
||||
"Upgrade to Full Access"
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCalendarReadAction() {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
_ = await self.requestCalendarFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var remindersStatusText: String {
|
||||
switch self.remindersStatus {
|
||||
case .authorized, .fullAccess:
|
||||
"Allowed"
|
||||
case .writeOnly:
|
||||
"Add-Only"
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private var remindersActionTitle: String? {
|
||||
switch self.remindersStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
case .writeOnly:
|
||||
"Upgrade to Full Access"
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRemindersAction() {
|
||||
switch self.remindersStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
_ = await self.requestRemindersFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
self.contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||
self.calendarStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
self.remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||
}
|
||||
|
||||
private func requestCalendarWriteOnly() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestWriteOnlyAccessToEvents { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestCalendarFull() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToEvents { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestRemindersFull() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToReminders { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
@@ -405,8 +405,6 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
AnyView(PrivacyAccessSectionView())
|
||||
|
||||
DisclosureGroup("Device Info") {
|
||||
TextField("Name", text: self.$displayName)
|
||||
Text(self.instanceId)
|
||||
@@ -421,7 +419,16 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.modifier(SettingsCloseToolbar())
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
@@ -481,91 +488,90 @@ struct SettingsTab: View {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore
|
||||
.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
||||
self.appModel.refreshLastShareEventFromRelay()
|
||||
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||
self.gatewayExpanded = !self.isGatewayConnected
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
if self.isGatewayConnected {
|
||||
self.appModel.reloadTalkConfig()
|
||||
}
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
||||
self.appModel.refreshLastShareEventFromRelay()
|
||||
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||
self.gatewayExpanded = !self.isGatewayConnected
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
if self.isGatewayConnected {
|
||||
self.appModel.reloadTalkConfig()
|
||||
}
|
||||
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||
if newValue != self.selectedAgentPickerId {
|
||||
self.selectedAgentPickerId = newValue
|
||||
}
|
||||
}
|
||||
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||
if newValue != self.selectedAgentPickerId {
|
||||
self.selectedAgentPickerId = newValue
|
||||
}
|
||||
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
||||
}
|
||||
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
return
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
|
||||
if self.manualGatewayEnabled {
|
||||
self.setupStatusText = self.appModel.gatewayStatusText
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.setupStatusText = trimmed
|
||||
}
|
||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationEnabledModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
return
|
||||
}
|
||||
if self.manualGatewayEnabled {
|
||||
self.setupStatusText = self.appModel.gatewayStatusText
|
||||
}
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.setupStatusText = trimmed
|
||||
}
|
||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationEnabledModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
}
|
||||
await MainActor.run {
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
@@ -1132,21 +1138,4 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsCloseToolbar: ViewModifier {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
@@ -40,7 +40,6 @@ Sources/Onboarding/OnboardingStateStore.swift
|
||||
Sources/Onboarding/OnboardingWizardView.swift
|
||||
Sources/Onboarding/QRScannerView.swift
|
||||
Sources/OpenClawApp.swift
|
||||
Sources/Permissions/PermissionRequestBridge.swift
|
||||
Sources/Push/ExecApprovalNotificationBridge.swift
|
||||
Sources/Push/BackgroundAliveBeacon.swift
|
||||
Sources/Push/PushBuildConfig.swift
|
||||
@@ -61,7 +60,6 @@ Sources/Services/WatchConnectivityTransport.swift
|
||||
Sources/Services/WatchMessagingPayloadCodec.swift
|
||||
Sources/Services/WatchMessagingService.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Settings/PrivacyAccessSectionView.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/SettingsTab.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct PermissionRequestBridgeTests {
|
||||
@Test func `box resumes immediately when cancelled before install`() async {
|
||||
let box = PermissionRequestBridge.Box()
|
||||
box.resume(false)
|
||||
let granted: Bool = await withCheckedContinuation { continuation in
|
||||
_ = box.install(continuation)
|
||||
}
|
||||
#expect(granted == false)
|
||||
#expect(box.canStartRequest() == false)
|
||||
}
|
||||
|
||||
@Test func `box resumes installed continuation once`() async {
|
||||
let box = PermissionRequestBridge.Box()
|
||||
|
||||
let granted: Bool = await withCheckedContinuation { continuation in
|
||||
_ = box.install(continuation)
|
||||
box.resume(true)
|
||||
box.resume(false)
|
||||
}
|
||||
|
||||
#expect(granted == true)
|
||||
}
|
||||
}
|
||||
@@ -136,16 +136,11 @@ targets:
|
||||
NSBonjourServices:
|
||||
- _openclaw-gw._tcp
|
||||
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
|
||||
NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
|
||||
NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
|
||||
NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access.
|
||||
NSContactsUsageDescription: OpenClaw uses your contacts so you can search and reference people while using the assistant.
|
||||
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
|
||||
NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations.
|
||||
NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant.
|
||||
NSRemindersFullAccessUsageDescription: OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
|
||||
@@ -69,17 +69,6 @@ enum GatewayRemoteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveTLSFingerprint(root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["tlsFingerprint"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
||||
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
||||
return self.normalizeGatewayUrl(raw)
|
||||
|
||||
@@ -83,9 +83,7 @@ final class MacNodeModeCoordinator {
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: InstanceIdentity.displayName)
|
||||
let sessionBox = self.buildSessionBox(
|
||||
url: config.url,
|
||||
connectionMode: AppStateStore.shared.connectionMode)
|
||||
let sessionBox = self.buildSessionBox(url: config.url)
|
||||
|
||||
try await self.session.connect(
|
||||
url: config.url,
|
||||
@@ -245,35 +243,15 @@ final class MacNodeModeCoordinator {
|
||||
return true
|
||||
}
|
||||
|
||||
nonisolated static func tlsParams(
|
||||
for url: URL,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
root: [String: Any],
|
||||
storedFingerprint: String?) -> GatewayTLSParams?
|
||||
{
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let configuredFingerprint = connectionMode == .remote
|
||||
? GatewayRemoteConfig.resolveTLSFingerprint(root: root)
|
||||
: nil
|
||||
let expectedFingerprint = configuredFingerprint ?? storedFingerprint
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: expectedFingerprint,
|
||||
allowTOFU: expectedFingerprint == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private func buildSessionBox(url: URL, connectionMode: AppState.ConnectionMode) -> WebSocketSessionBox? {
|
||||
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
guard let params = Self.tlsParams(
|
||||
for: url,
|
||||
connectionMode: connectionMode,
|
||||
root: OpenClawConfigFile.loadDict(),
|
||||
storedFingerprint: stored)
|
||||
else { return nil }
|
||||
let params = GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
let session = GatewayTLSPinningSession(params: params)
|
||||
return WebSocketSessionBox(session: session)
|
||||
}
|
||||
|
||||
@@ -287,36 +287,4 @@ struct GatewayEndpointStoreTests {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example")
|
||||
#expect(url == nil)
|
||||
}
|
||||
|
||||
@Test func `resolve tls fingerprint trims remote config value`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": " sha256:ABC123 ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: root) == "sha256:ABC123")
|
||||
}
|
||||
|
||||
@Test func `resolve tls fingerprint ignores blank or non string values`() {
|
||||
let blank: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": " ",
|
||||
],
|
||||
],
|
||||
]
|
||||
let nonString: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": 123,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: blank) == nil)
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: nonString) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,60 +35,6 @@ struct MacNodeModeCoordinatorTests {
|
||||
#expect(MacNodeModeCoordinator.tlsPinStoreKey(for: url) == "gateway.example.ts.net:443")
|
||||
}
|
||||
|
||||
@Test func `remote tls params prefer configured fingerprint over stored pin`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": "sha256:configured",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: root,
|
||||
storedFingerprint: "stored"))
|
||||
|
||||
#expect(params.expectedFingerprint == "sha256:configured")
|
||||
#expect(params.allowTOFU == false)
|
||||
#expect(params.storeKey == "gateway.example.com:443")
|
||||
}
|
||||
|
||||
@Test func `remote tls params allow first use only when no configured or stored pin exists`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: [:],
|
||||
storedFingerprint: nil))
|
||||
|
||||
#expect(params.expectedFingerprint == nil)
|
||||
#expect(params.allowTOFU == true)
|
||||
}
|
||||
|
||||
@Test func `local tls params ignore remote configured fingerprint`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": "sha256:remote",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .local,
|
||||
root: root,
|
||||
storedFingerprint: "stored-local"))
|
||||
|
||||
#expect(params.expectedFingerprint == "stored-local")
|
||||
#expect(params.allowTOFU == false)
|
||||
}
|
||||
|
||||
@Test func `auto repairs trusted tailscale serve pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
|
||||
@@ -17,7 +17,6 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
|
||||
// swiftlint:disable:next type_body_length
|
||||
public final class OpenClawChatViewModel {
|
||||
public static let defaultModelSelectionID = "__default__"
|
||||
private static let maxAttachmentBytes = 5_000_000
|
||||
|
||||
public private(set) var messages: [OpenClawChatMessage] = []
|
||||
public var input: String = ""
|
||||
@@ -1299,6 +1298,11 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
private func addImageAttachment(url: URL?, data: Data, fileName: String, mimeType: String) async {
|
||||
if data.count > 5_000_000 {
|
||||
self.errorText = "Attachment \(fileName) exceeds 5 MB limit"
|
||||
return
|
||||
}
|
||||
|
||||
let uti: UTType = {
|
||||
if let url {
|
||||
return UTType(filenameExtension: url.pathExtension) ?? .data
|
||||
@@ -1310,33 +1314,13 @@ public final class OpenClawChatViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let processed: Data
|
||||
do {
|
||||
processed = try await Task.detached(priority: .userInitiated) {
|
||||
try ChatImageProcessor.processForUpload(data: data)
|
||||
}.value
|
||||
} catch {
|
||||
self.errorText = "Could not process \(fileName): \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
if processed.count > Self.maxAttachmentBytes {
|
||||
self.errorText = "Attachment \(fileName) exceeds 5 MB limit after resizing"
|
||||
return
|
||||
}
|
||||
|
||||
let outputFileName: String = {
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
return baseName.isEmpty ? "image.jpg" : "\(baseName).jpg"
|
||||
}()
|
||||
|
||||
let preview = Self.previewImage(data: processed)
|
||||
let preview = Self.previewImage(data: data)
|
||||
self.attachments.append(
|
||||
OpenClawPendingAttachment(
|
||||
url: url,
|
||||
data: processed,
|
||||
fileName: outputFileName,
|
||||
mimeType: "image/jpeg",
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
preview: preview))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Chat-specific image upload policy built on the shared JPEG transcoder.
|
||||
public enum ChatImageProcessor {
|
||||
public static let maxLongEdgePx = 1600
|
||||
public static let jpegQuality = 0.8
|
||||
public static let maxPayloadBytes = 3_500_000
|
||||
|
||||
public enum ProcessError: Error, LocalizedError, Sendable {
|
||||
case notAnImage
|
||||
case decodeFailed
|
||||
case encodeFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAnImage:
|
||||
"The data is not a recognizable image."
|
||||
case .decodeFailed:
|
||||
"The image could not be decoded."
|
||||
case .encodeFailed:
|
||||
"The image could not be resized to fit the chat upload limit."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func processForUpload(data: Data) throws -> Data {
|
||||
do {
|
||||
let result = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: data,
|
||||
maxLongEdgePx: self.maxLongEdgePx,
|
||||
quality: self.jpegQuality,
|
||||
maxBytes: self.maxPayloadBytes)
|
||||
return result.data
|
||||
} catch JPEGTranscodeError.decodeFailed {
|
||||
throw ProcessError.notAnImage
|
||||
} catch JPEGTranscodeError.propertiesMissing {
|
||||
throw ProcessError.decodeFailed
|
||||
} catch JPEGTranscodeError.sizeLimitExceeded {
|
||||
throw ProcessError.encodeFailed
|
||||
} catch {
|
||||
throw ProcessError.encodeFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
{
|
||||
return link
|
||||
}
|
||||
return self.fromGatewayURLString(
|
||||
return fromGatewayURLString(
|
||||
trimmed,
|
||||
bootstrapToken: nil,
|
||||
token: nil,
|
||||
@@ -89,7 +89,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
{
|
||||
return link
|
||||
}
|
||||
for candidate in self.setupCodeCandidates(in: trimmed) where candidate != trimmed {
|
||||
for candidate in setupCodeCandidates(in: trimmed) where candidate != trimmed {
|
||||
if let data = decodeBase64Url(candidate),
|
||||
let link = decodeSetupPayload(from: data)
|
||||
{
|
||||
@@ -104,7 +104,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
if let urlString = payload.url?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!urlString.isEmpty
|
||||
{
|
||||
return self.fromGatewayURLString(
|
||||
return fromGatewayURLString(
|
||||
urlString,
|
||||
bootstrapToken: payload.bootstrapToken,
|
||||
token: payload.token,
|
||||
|
||||
@@ -79,12 +79,6 @@ public protocol GatewayDeviceTokenRetryTrustProviding: AnyObject {
|
||||
var allowsDeviceTokenRetryAuth: Bool { get }
|
||||
}
|
||||
|
||||
enum GatewayTLSFirstUsePolicy {
|
||||
static func allowsFirstUsePin(systemTrustOk: Bool) -> Bool {
|
||||
systemTrustOk
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayTLSStore {
|
||||
private static let keychainService = "ai.openclaw.tls-pinning"
|
||||
|
||||
@@ -165,8 +159,7 @@ public enum GatewayTLSStore {
|
||||
}
|
||||
}
|
||||
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate,
|
||||
GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, @unchecked Sendable {
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, @unchecked Sendable {
|
||||
private let params: GatewayTLSParams
|
||||
private let failureLock = NSLock()
|
||||
private var lastTLSFailure: GatewayTLSValidationFailure?
|
||||
@@ -245,14 +238,12 @@ GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, @unchecked Se
|
||||
return
|
||||
}
|
||||
if self.params.allowTOFU {
|
||||
if GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: systemTrustOk) {
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
}
|
||||
self.clearTLSFailure()
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
}
|
||||
self.clearTLSFailure()
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,26 +37,6 @@ public struct JPEGTranscoder: Sendable {
|
||||
maxWidthPx: Int?,
|
||||
quality: Double,
|
||||
maxBytes: Int? = nil) throws -> (data: Data, widthPx: Int, heightPx: Int)
|
||||
{
|
||||
try self.transcodeToJPEG(
|
||||
imageData: imageData,
|
||||
maxWidthPx: maxWidthPx,
|
||||
maxLongEdgePx: nil,
|
||||
quality: quality,
|
||||
maxBytes: maxBytes)
|
||||
}
|
||||
|
||||
/// Re-encodes image data to JPEG, optionally downscaling so the *oriented* longest edge is <= `maxLongEdgePx`.
|
||||
///
|
||||
/// When `maxLongEdgePx` is provided it takes precedence over `maxWidthPx`.
|
||||
/// - Important: This normalizes EXIF orientation (the output pixels are rotated if needed; orientation tag is not
|
||||
/// relied on).
|
||||
public static func transcodeToJPEG(
|
||||
imageData: Data,
|
||||
maxWidthPx: Int? = nil,
|
||||
maxLongEdgePx: Int?,
|
||||
quality: Double,
|
||||
maxBytes: Int? = nil) throws -> (data: Data, widthPx: Int, heightPx: Int)
|
||||
{
|
||||
guard let src = CGImageSourceCreateWithData(imageData as CFData, nil) else {
|
||||
throw JPEGTranscodeError.decodeFailed
|
||||
@@ -83,10 +63,6 @@ public struct JPEGTranscoder: Sendable {
|
||||
|
||||
let maxDim = max(orientedWidth, orientedHeight)
|
||||
var targetMaxPixelSize: Int = {
|
||||
if let maxLongEdgePx, maxLongEdgePx > 0 {
|
||||
guard maxDim > maxLongEdgePx else { return maxDim } // never upscale
|
||||
return maxLongEdgePx
|
||||
}
|
||||
guard let maxWidthPx, maxWidthPx > 0 else { return maxDim }
|
||||
guard orientedWidth > maxWidthPx else { return maxDim } // never upscale
|
||||
|
||||
@@ -105,7 +81,6 @@ public struct JPEGTranscoder: Sendable {
|
||||
guard let img = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOpts as CFDictionary) else {
|
||||
throw JPEGTranscodeError.decodeFailed
|
||||
}
|
||||
let opaqueImage = Self.flattenAlphaIfNeeded(img)
|
||||
|
||||
let out = NSMutableData()
|
||||
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
@@ -113,12 +88,12 @@ public struct JPEGTranscoder: Sendable {
|
||||
}
|
||||
let q = self.clampQuality(quality)
|
||||
let encodeProps = [kCGImageDestinationLossyCompressionQuality: q] as CFDictionary
|
||||
CGImageDestinationAddImage(dest, opaqueImage, encodeProps)
|
||||
CGImageDestinationAddImage(dest, img, encodeProps)
|
||||
guard CGImageDestinationFinalize(dest) else {
|
||||
throw JPEGTranscodeError.encodeFailed
|
||||
}
|
||||
|
||||
return (out as Data, opaqueImage.width, opaqueImage.height)
|
||||
return (out as Data, img.width, img.height)
|
||||
}
|
||||
|
||||
guard let maxBytes, maxBytes > 0 else {
|
||||
@@ -157,34 +132,4 @@ public struct JPEGTranscoder: Sendable {
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/// JPEG cannot store alpha. Flatten transparent sources over white before encoding so ImageIO does not composite
|
||||
/// transparent pixels onto black by default.
|
||||
private static func flattenAlphaIfNeeded(_ image: CGImage) -> CGImage {
|
||||
switch image.alphaInfo {
|
||||
case .none, .noneSkipFirst, .noneSkipLast:
|
||||
return image
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
|
||||
else {
|
||||
return image
|
||||
}
|
||||
|
||||
let rect = CGRect(x: 0, y: 0, width: image.width, height: image.height)
|
||||
context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
|
||||
context.fill(rect)
|
||||
context.draw(image, in: rect)
|
||||
return context.makeImage() ?? image
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
@testable import OpenClawKit
|
||||
|
||||
struct ChatImageProcessorTests {
|
||||
private func syntheticJPEG(width: Int, height: Int) throws -> Data {
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 1)
|
||||
}
|
||||
|
||||
context.setFillColor(CGColor(red: 0.8, green: 0.2, blue: 0.4, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.setFillColor(CGColor(red: 0.1, green: 0.7, blue: 0.3, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width / 2, height: height / 2))
|
||||
|
||||
guard let image = context.makeImage() else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 2)
|
||||
}
|
||||
|
||||
let data = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)
|
||||
else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 3)
|
||||
}
|
||||
|
||||
let properties: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: 0.95,
|
||||
kCGImagePropertyExifDictionary: [
|
||||
kCGImagePropertyExifDateTimeOriginal: "2026:04:20 16:30:00",
|
||||
kCGImagePropertyExifLensModel: "Leaky Lens 50mm f/1.4",
|
||||
] as CFDictionary,
|
||||
kCGImagePropertyGPSDictionary: [
|
||||
kCGImagePropertyGPSLatitude: 60.02,
|
||||
kCGImagePropertyGPSLatitudeRef: "N",
|
||||
kCGImagePropertyGPSLongitude: 10.95,
|
||||
kCGImagePropertyGPSLongitudeRef: "E",
|
||||
] as CFDictionary,
|
||||
kCGImagePropertyTIFFDictionary: [
|
||||
kCGImagePropertyTIFFMake: "LeakCorp",
|
||||
kCGImagePropertyTIFFModel: "Privacy-Leaker-1",
|
||||
] as CFDictionary,
|
||||
]
|
||||
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 4)
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private func syntheticPNGWithAlpha(width: Int, height: Int) throws -> Data {
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 5)
|
||||
}
|
||||
|
||||
context.clear(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1))
|
||||
context.fill(CGRect(x: width / 4, y: height / 4, width: width / 2, height: height / 2))
|
||||
|
||||
guard let image = context.makeImage() else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 6)
|
||||
}
|
||||
|
||||
let data = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil)
|
||||
else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 7)
|
||||
}
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 8)
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private func properties(for data: Data) -> [CFString: Any] {
|
||||
guard
|
||||
let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
private func dimensions(for data: Data) -> (width: Int, height: Int)? {
|
||||
let properties = self.properties(for: data)
|
||||
guard
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? NSNumber,
|
||||
let height = properties[kCGImagePropertyPixelHeight] as? NSNumber
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return (width.intValue, height.intValue)
|
||||
}
|
||||
|
||||
@Test func `resizes landscape long edge to upload limit`() throws {
|
||||
let source = try self.syntheticJPEG(width: 4000, height: 3000)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let dimensions = try #require(self.dimensions(for: output))
|
||||
|
||||
#expect(max(dimensions.width, dimensions.height) <= ChatImageProcessor.maxLongEdgePx)
|
||||
#expect(abs((Double(dimensions.width) / Double(dimensions.height)) - (4000.0 / 3000.0)) <= 0.02)
|
||||
}
|
||||
|
||||
@Test func `resizes portrait long edge to upload limit`() throws {
|
||||
let source = try self.syntheticJPEG(width: 3000, height: 4000)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let dimensions = try #require(self.dimensions(for: output))
|
||||
|
||||
#expect(max(dimensions.width, dimensions.height) <= ChatImageProcessor.maxLongEdgePx)
|
||||
#expect(abs((Double(dimensions.width) / Double(dimensions.height)) - (3000.0 / 4000.0)) <= 0.02)
|
||||
}
|
||||
|
||||
@Test func `resizes narrow tall long edge to upload limit`() throws {
|
||||
let source = try self.syntheticJPEG(width: 1080, height: 2400)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let dimensions = try #require(self.dimensions(for: output))
|
||||
|
||||
#expect(max(dimensions.width, dimensions.height) <= ChatImageProcessor.maxLongEdgePx)
|
||||
#expect(abs((Double(dimensions.width) / Double(dimensions.height)) - (1080.0 / 2400.0)) <= 0.02)
|
||||
}
|
||||
|
||||
@Test func `small image is not upscaled`() throws {
|
||||
let source = try self.syntheticJPEG(width: 400, height: 300)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let dimensions = try #require(self.dimensions(for: output))
|
||||
|
||||
#expect(max(dimensions.width, dimensions.height) <= 400)
|
||||
}
|
||||
|
||||
@Test func `output fits payload budget`() throws {
|
||||
let source = try self.syntheticJPEG(width: 4000, height: 3000)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
|
||||
#expect(output.count <= ChatImageProcessor.maxPayloadBytes)
|
||||
}
|
||||
|
||||
@Test func `rejects non image data`() {
|
||||
let garbage = Data("not an image".utf8)
|
||||
|
||||
#expect(throws: ChatImageProcessor.ProcessError.self) {
|
||||
_ = try ChatImageProcessor.processForUpload(data: garbage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `strips source metadata from output`() throws {
|
||||
let source = try self.syntheticJPEG(width: 3000, height: 2000)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let properties = self.properties(for: output)
|
||||
let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any] ?? [:]
|
||||
|
||||
#expect(gps.isEmpty)
|
||||
for needle in ["Leaky Lens", "LeakCorp", "Privacy-Leaker", "2026:04:20"] {
|
||||
#expect(output.range(of: Data(needle.utf8)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `flattens transparent sources to opaque JPEG`() throws {
|
||||
let source = try self.syntheticPNGWithAlpha(width: 800, height: 600)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let imageSource = try #require(CGImageSourceCreateWithData(output as CFData, nil))
|
||||
let image = try #require(CGImageSourceCreateImageAtIndex(imageSource, 0, nil))
|
||||
|
||||
#expect([.none, .noneSkipFirst, .noneSkipLast].contains(image.alphaInfo))
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import OpenClawKit
|
||||
import UniformTypeIdentifiers
|
||||
import XCTest
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
private struct AttachmentProcessingTransport: OpenClawChatTransport {
|
||||
func requestHistory(sessionKey _: String) async throws -> OpenClawChatHistoryPayload {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 1)
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey _: String,
|
||||
message _: String,
|
||||
thinking _: String,
|
||||
idempotencyKey _: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 2)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
AsyncStream { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
private func makeChatAttachmentJPEG(width: Int, height: Int) throws -> Data {
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 3)
|
||||
}
|
||||
|
||||
context.setFillColor(CGColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.setFillColor(CGColor(red: 0.9, green: 0.5, blue: 0.1, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width / 2, height: height / 2))
|
||||
|
||||
guard let image = context.makeImage() else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 4)
|
||||
}
|
||||
|
||||
let data = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 5)
|
||||
}
|
||||
CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.95] as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 6)
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private func chatAttachmentDimensions(for data: Data) -> (width: Int, height: Int)? {
|
||||
guard
|
||||
let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? NSNumber,
|
||||
let height = properties[kCGImagePropertyPixelHeight] as? NSNumber
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return (width.intValue, height.intValue)
|
||||
}
|
||||
|
||||
final class ChatViewModelAttachmentTests: XCTestCase {
|
||||
func testImageAttachmentsAreProcessedBeforeStaging() async throws {
|
||||
let imageData = try makeChatAttachmentJPEG(width: 3000, height: 4000)
|
||||
let viewModel = await MainActor.run {
|
||||
OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport())
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
viewModel.addImageAttachment(data: imageData, fileName: "camera.heic", mimeType: "image/jpeg")
|
||||
}
|
||||
|
||||
try await waitUntil("attachment processed") {
|
||||
await MainActor.run { !viewModel.attachments.isEmpty || viewModel.errorText != nil }
|
||||
}
|
||||
|
||||
let attachment = try await MainActor.run {
|
||||
guard let attachment = viewModel.attachments.first else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 7)
|
||||
}
|
||||
return (attachment.fileName, attachment.mimeType, attachment.data)
|
||||
}
|
||||
let dimensions = try XCTUnwrap(chatAttachmentDimensions(for: attachment.2))
|
||||
|
||||
XCTAssertEqual(attachment.0, "camera.jpg")
|
||||
XCTAssertEqual(attachment.1, "image/jpeg")
|
||||
XCTAssertLessThanOrEqual(attachment.2.count, ChatImageProcessor.maxPayloadBytes)
|
||||
XCTAssertLessThanOrEqual(max(dimensions.width, dimensions.height), ChatImageProcessor.maxLongEdgePx)
|
||||
let errorText = await MainActor.run { viewModel.errorText }
|
||||
XCTAssertNil(errorText)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
struct GatewayTLSPinningTests {
|
||||
@Test func `first use pinning requires system trust`() {
|
||||
#expect(GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: true))
|
||||
#expect(!GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: false))
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
1cf7ca2ee1db3bf44682c487c780c6b1c47bbce27e74fb6f455cef445544c84f plugin-sdk-api-baseline.json
|
||||
24b8e3e4773579e5a184dd5f91a5ad2f8e92519b6fe314820a94d7a64bd1141e plugin-sdk-api-baseline.jsonl
|
||||
e8c15cff96a0a869cfe3de29679d4296603a16bfa4676940845a484c23db8e56 plugin-sdk-api-baseline.json
|
||||
a6cbb8dc21b3ed16e0abd23c60a817ddedd65f336427c9fa565a43ca5dcc9a85 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -23,17 +23,17 @@ Production-ready for DMs and channels via Slack app integrations. Default mode i
|
||||
|
||||
Both transports are production-ready and reach feature parity for messaging, slash commands, App Home, and interactivity. Pick by deployment shape, not features.
|
||||
|
||||
| Concern | Socket Mode (default) | HTTP Request URLs |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Public Gateway URL | Not required | Required (DNS, TLS, reverse proxy or tunnel) |
|
||||
| Outbound network | Outbound WSS to `wss-primary.slack.com` must be reachable | No outbound WS; inbound HTTPS only |
|
||||
| Tokens needed | Bot token (`xoxb-...`) + App-Level Token (`xapp-...`) with `connections:write` | Bot token (`xoxb-...`) + Signing Secret |
|
||||
| Dev laptop / behind firewall | Works as-is | Needs a public tunnel (ngrok, Cloudflare Tunnel, Tailscale Funnel) or staging Gateway |
|
||||
| Horizontal scaling | One Socket Mode session per app per host; multiple Gateways need separate Slack apps | Stateless POST handler; multiple Gateway replicas can share one app behind a load balancer |
|
||||
| Multi-account on one Gateway | Supported; each account opens its own WS | Supported; each account needs a unique `webhookPath` (default `/slack/events`) so registrations do not collide |
|
||||
| Slash command transport | Delivered over the WS connection; `slash_commands[].url` is ignored | Slack POSTs to `slash_commands[].url`; field is required for the command to dispatch |
|
||||
| Request signing | Not used (auth is the App-Level Token) | Slack signs every request; OpenClaw verifies with `signingSecret` |
|
||||
| Recovery on connection drop | Slack SDK auto-reconnect is enabled; OpenClaw also restarts failed Socket Mode sessions with bounded backoff. Pong-timeout transport tuning applies. | No persistent connection to drop; retries are per-request from Slack |
|
||||
| Concern | Socket Mode (default) | HTTP Request URLs |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Public Gateway URL | Not required | Required (DNS, TLS, reverse proxy or tunnel) |
|
||||
| Outbound network | Outbound WSS to `wss-primary.slack.com` must be reachable | No outbound WS; inbound HTTPS only |
|
||||
| Tokens needed | Bot token (`xoxb-...`) + App-Level Token (`xapp-...`) with `connections:write` | Bot token (`xoxb-...`) + Signing Secret |
|
||||
| Dev laptop / behind firewall | Works as-is | Needs a public tunnel (ngrok, Cloudflare Tunnel, Tailscale Funnel) or staging Gateway |
|
||||
| Horizontal scaling | One Socket Mode session per app per host; multiple Gateways need separate Slack apps | Stateless POST handler; multiple Gateway replicas can share one app behind a load balancer |
|
||||
| Multi-account on one Gateway | Supported; each account opens its own WS | Supported; each account needs a unique `webhookPath` (default `/slack/events`) so registrations do not collide |
|
||||
| Slash command transport | Delivered over the WS connection; `slash_commands[].url` is ignored | Slack POSTs to `slash_commands[].url`; field is required for the command to dispatch |
|
||||
| Request signing | Not used (auth is the App-Level Token) | Slack signs every request; OpenClaw verifies with `signingSecret` |
|
||||
| Recovery on connection drop | Slack SDK auto-reconnects; the gateway's pong-timeout transport tuning applies | No persistent connection to drop; retries are per-request from Slack |
|
||||
|
||||
<Note>
|
||||
**Pick Socket Mode** for single-Gateway hosts, dev laptops, and on-prem networks that can reach `*.slack.com` outbound but cannot accept inbound HTTPS.
|
||||
@@ -462,13 +462,6 @@ OpenClaw sets the Slack SDK client pong timeout to 15 seconds by default for Soc
|
||||
|
||||
Use this only for Socket Mode workspaces that log Slack websocket pong/server-ping timeouts or run on hosts with known event-loop starvation. `clientPingTimeout` is the pong wait after the SDK sends a client ping; `serverPingTimeout` is the wait for Slack server pings. App messages and events remain application state, not transport liveness signals.
|
||||
|
||||
Notes:
|
||||
|
||||
- `socketMode` is ignored in HTTP Request URL mode.
|
||||
- Base `channels.slack.socketMode` settings apply to all Slack accounts unless overridden. Per-account overrides use `channels.slack.accounts.<accountId>.socketMode`; because this is an object override, include every socket tuning field you want for that account.
|
||||
- Only `clientPingTimeout` has an OpenClaw default (`15000`). `serverPingTimeout` and `pingPongLoggingEnabled` are passed to the Slack SDK only when configured.
|
||||
- Socket Mode restart backoff starts around 2 seconds and caps around 30 seconds. Consecutive recoverable start/start-wait failures stop after 12 attempts; after a successful connection, later recoverable disconnects start a fresh retry cycle. Non-recoverable Slack auth errors such as `invalid_auth`, revoked tokens, or missing scopes fail fast instead of retrying forever.
|
||||
|
||||
## Manifest and scope checklist
|
||||
|
||||
The base Slack app manifest is the same for Socket Mode and HTTP Request URLs. Only the `settings` block (and the slash command `url`) differs.
|
||||
@@ -938,9 +931,8 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
|
||||
- Slack route bindings accept raw peer IDs plus Slack target forms such as `channel:C12345678`, `user:U12345678`, and `<@U12345678>`.
|
||||
- With default `session.dmScope=main`, Slack DMs collapse to agent main session.
|
||||
- Channel sessions: `agent:<agentId>:slack:channel:<channelId>`.
|
||||
- Ordinary top-level channel messages stay on the per-channel session, even when `replyToMode` is non-`off`.
|
||||
- Slack thread replies use the parent Slack `thread_ts` for session suffixes (`:thread:<threadTs>`), even when outbound reply threading is disabled with `replyToMode="off"`.
|
||||
- OpenClaw seeds an eligible top-level channel root into `agent:<agentId>:slack:channel:<channelId>:thread:<rootTs>` when that root is expected to start a visible Slack thread, so the root and later thread replies share one OpenClaw session. This applies to `app_mention` events, explicit bot or configured mention-pattern matches, and `requireMention: false` channels with non-`off` `replyToMode`.
|
||||
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
|
||||
- In channels where OpenClaw handles top-level messages without requiring an explicit mention, non-`off` `replyToMode` routes each handled root into `agent:<agentId>:slack:channel:<channelId>:thread:<rootTs>` so the visible Slack thread maps to one OpenClaw session from the first turn.
|
||||
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
|
||||
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
|
||||
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
|
||||
@@ -961,7 +953,7 @@ For explicit Slack thread replies from the `message` tool, set `replyBroadcast:
|
||||
When a `message` tool call runs inside a Slack thread and targets the same channel, OpenClaw normally inherits the current Slack thread according to `replyToMode`. Set `topLevel: true` on `action: "send"` or `action: "upload-file"` to force a new parent-channel message instead. `threadId: null` is accepted as the same top-level opt-out.
|
||||
|
||||
<Note>
|
||||
`replyToMode="off"` disables outbound Slack reply threading, including explicit `[[reply_to_*]]` tags. It does not flatten inbound Slack thread sessions: messages already posted inside a Slack thread still route to the `:thread:<threadTs>` session. This differs from Telegram, where explicit tags are still honored in `"off"` mode. Slack threads hide messages from the channel while Telegram replies stay visible inline.
|
||||
`replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode. Slack threads hide messages from the channel while Telegram replies stay visible inline.
|
||||
</Note>
|
||||
|
||||
## Ack reactions
|
||||
@@ -1266,17 +1258,6 @@ Primary reference: [Configuration reference - Slack](/gateway/config-channels#sl
|
||||
- channel allowlist (`channels.slack.channels`) — **keys must be channel IDs** (`C12345678`), not names (`#channel-name`). Name-based keys silently fail under `groupPolicy: "allowlist"` because channel routing is ID-first by default. To find an ID: right-click the channel in Slack → **Copy link** — the `C...` value at the end of the URL is the channel ID.
|
||||
- `requireMention`
|
||||
- per-channel `users` allowlist
|
||||
- `messages.groupChat.visibleReplies`: if it is `"message_tool"` and logs show assistant text with no `message(action=send)` call, the turn was processed but the final answer was kept private. Set it to `"automatic"` if you want normal assistant final replies posted back to Slack channels.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
groupChat: {
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
@@ -1293,8 +1274,7 @@ openclaw doctor
|
||||
|
||||
- `channels.slack.dm.enabled`
|
||||
- `channels.slack.dmPolicy` (or legacy `channels.slack.dm.policy`)
|
||||
- pairing approvals / allowlist entries (`dmPolicy: "open"` still requires `channels.slack.allowFrom: ["*"]`)
|
||||
- group DMs use MPIM handling; enable `channels.slack.dm.groupEnabled` and, if configured, include the MPIM in `channels.slack.dm.groupChannels`
|
||||
- pairing approvals / allowlist entries
|
||||
- Slack Assistant DM events: verbose logs mentioning `drop message_changed`
|
||||
usually mean Slack sent an edited Assistant-thread event without a
|
||||
recoverable human sender in message metadata
|
||||
@@ -1307,19 +1287,12 @@ openclaw pairing list slack
|
||||
|
||||
<Accordion title="Socket mode not connecting">
|
||||
Validate bot + app tokens and Socket Mode enablement in Slack app settings.
|
||||
The `xapp-...` App-Level Token needs `connections:write`, and the `xoxb-...`
|
||||
bot token must belong to the same Slack app/workspace as the app token.
|
||||
|
||||
If `openclaw channels status --probe --json` shows `botTokenStatus` or
|
||||
`appTokenStatus: "configured_unavailable"`, the Slack account is
|
||||
configured but the current runtime could not resolve the SecretRef-backed
|
||||
value.
|
||||
|
||||
Logs such as `slack socket mode failed to start; retry ...` are recoverable
|
||||
start failures. Missing scopes, revoked tokens, and invalid auth fail fast
|
||||
instead. A `slack token mismatch ...` log means the bot token and app token
|
||||
appear to belong to different Slack apps; fix the Slack app credentials.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="HTTP mode not receiving events">
|
||||
@@ -1329,16 +1302,11 @@ openclaw pairing list slack
|
||||
- webhook path
|
||||
- Slack Request URLs (Events + Interactivity + Slash Commands)
|
||||
- unique `webhookPath` per HTTP account
|
||||
- the public URL terminates TLS and forwards requests to the Gateway path
|
||||
- the Slack app `request_url` path exactly matches `channels.slack.webhookPath` (default `/slack/events`)
|
||||
|
||||
If `signingSecretStatus: "configured_unavailable"` appears in account
|
||||
snapshots, the HTTP account is configured but the current runtime could not
|
||||
resolve the SecretRef-backed signing secret.
|
||||
|
||||
A repeated `slack: webhook path ... already registered` log means two HTTP
|
||||
accounts are using the same `webhookPath`; give each account a distinct path.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Native/slash commands not firing">
|
||||
@@ -1347,14 +1315,7 @@ openclaw pairing list slack
|
||||
- native command mode (`channels.slack.commands.native: true`) with matching slash commands registered in Slack
|
||||
- or single slash command mode (`channels.slack.slashCommand.enabled: true`)
|
||||
|
||||
Slack does not create or remove slash commands automatically. `commands.native: "auto"` does not enable Slack native commands; use `true` and create the matching commands in the Slack app. In HTTP mode, every Slack slash command must include the Gateway URL. In Socket Mode, command payloads arrive over the websocket and Slack ignores `slash_commands[].url`.
|
||||
|
||||
Also check `commands.useAccessGroups`, DM authorization, channel allowlists,
|
||||
and per-channel `users` allowlists. Slack returns ephemeral errors for
|
||||
blocked slash-command senders, including:
|
||||
|
||||
- `This channel is not allowed.`
|
||||
- `You are not authorized to use this command here.`
|
||||
Also check `commands.useAccessGroups` and channel/user allowlists.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -517,11 +517,7 @@ Before a first run, check the wrapper from the repo root:
|
||||
pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
```
|
||||
|
||||
The repo wrapper refuses a stale Crabbox binary that does not advertise `blacksmith-testbox`. Pass the provider explicitly even though `.crabbox.yaml` has owned-cloud defaults. In Codex worktrees or linked/sparse checkouts, avoid the local `pnpm crabbox:run` script because pnpm may reconcile dependencies before Crabbox starts; invoke the node wrapper directly instead:
|
||||
|
||||
```bash
|
||||
node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox --timing-json --shell -- "pnpm test <path-or-filter>"
|
||||
```
|
||||
The repo wrapper refuses a stale Crabbox binary that does not advertise `blacksmith-testbox`. Pass the provider explicitly even though `.crabbox.yaml` has owned-cloud defaults.
|
||||
|
||||
Changed gate:
|
||||
|
||||
|
||||
@@ -476,7 +476,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
|
||||
- **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env fallback).
|
||||
- **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
|
||||
- `socketMode` passes Slack SDK Socket Mode transport tuning through to the public Bolt receiver API. Use it only when investigating ping/pong timeout or stale websocket behavior. `clientPingTimeout` defaults to `15000`; `serverPingTimeout` and `pingPongLoggingEnabled` are passed only when configured.
|
||||
- `socketMode` passes Slack SDK Socket Mode transport tuning through to the public Bolt receiver API. Use it only when investigating ping/pong timeout or stale websocket behavior.
|
||||
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
|
||||
strings or SecretRef objects.
|
||||
- Slack account snapshots expose per-credential source/status fields such as
|
||||
|
||||
@@ -148,7 +148,7 @@ Short version: **keep the Gateway loopback-only** unless you're sure you need a
|
||||
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
|
||||
- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
|
||||
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
|
||||
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`, including macOS direct mode. Without a configured or previously stored pin, macOS only pins a first-use certificate after normal system trust passes; self-signed or private-CA gateways that macOS does not already trust need an explicit fingerprint or Remote over SSH.
|
||||
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
||||
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
|
||||
headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints do not
|
||||
use that Tailscale header auth and instead follow the gateway's normal HTTP
|
||||
|
||||
@@ -81,7 +81,7 @@ node.
|
||||
- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`openclaw status --json`).
|
||||
- **Web Chat stuck**: confirm the gateway is running on the remote host and the forwarded port matches the gateway WS port; the UI requires a healthy WS connection.
|
||||
- **Node IP shows 127.0.0.1**: expected with the SSH tunnel. Switch **Transport** to **Direct (ws/wss)** if you want the gateway to see the real client IP.
|
||||
- **Dashboard works but Mac capabilities are offline**: this means the app's operator/control connection is healthy, but the companion node connection is not connected or is missing its command surface. Open the menu bar device section and check whether the Mac is `paired · disconnected`. For `wss://*.ts.net` Tailscale Serve endpoints, the app detects stale legacy TLS leaf pins after certificate rotation, clears the stale pin when macOS trusts the new certificate, and retries automatically. If the certificate is not system-trusted or the host is not a Tailscale Serve name, set `gateway.remote.tlsFingerprint` to the expected certificate fingerprint, review the certificate, or switch to **Remote over SSH**.
|
||||
- **Dashboard works but Mac capabilities are offline**: this means the app's operator/control connection is healthy, but the companion node connection is not connected or is missing its command surface. Open the menu bar device section and check whether the Mac is `paired · disconnected`. For `wss://*.ts.net` Tailscale Serve endpoints, the app detects stale legacy TLS leaf pins after certificate rotation, clears the stale pin when macOS trusts the new certificate, and retries automatically. If the certificate is not system-trusted or the host is not a Tailscale Serve name, review the certificate or switch to **Remote over SSH**.
|
||||
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
|
||||
|
||||
## Notification sounds
|
||||
|
||||
@@ -41,13 +41,6 @@ but new code should not add imports from them: `agent-runtime-test-contracts`,
|
||||
`text-runtime`, and `zod`. Import `zod` directly from `zod` in new plugin code.
|
||||
`plugin-test-runtime` is still an active focused test helper subpath.
|
||||
|
||||
### Reserved bundled plugin helper subpaths
|
||||
|
||||
These subpaths are plugin-owned compatibility surfaces reserved for their owning
|
||||
bundled plugin, not general SDK APIs: `plugin-sdk/codex-mcp-projection` and
|
||||
`plugin-sdk/codex-native-task-runtime`. Cross-owner extension imports are blocked
|
||||
by package contract guardrails.
|
||||
|
||||
### Deprecated unused public subpaths
|
||||
|
||||
These public subpaths existed for at least one month and currently have no
|
||||
@@ -222,8 +215,6 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
|
||||
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
|
||||
| `plugin-sdk/browser-config` | Supported browser config facade for normalized profile/defaults, CDP URL parsing, and browser-control auth helpers |
|
||||
| `plugin-sdk/codex-mcp-projection` | Reserved bundled Codex helper for projecting user MCP server config into Codex thread config; not for third-party plugins |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Reserved bundled Codex helper for native task mirror/runtime wiring; not for third-party plugins |
|
||||
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
|
||||
| `plugin-sdk/matrix` | Deprecated Matrix compatibility facade for older third-party channel packages; new plugins should import `plugin-sdk/run-command` directly |
|
||||
| `plugin-sdk/mattermost` | Deprecated Mattermost compatibility facade for older third-party channel packages; new plugins should import generic SDK subpaths directly |
|
||||
@@ -370,18 +361,10 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Reserved bundled-helper subpaths">
|
||||
Reserved bundled-helper SDK subpaths are narrow owner-specific surfaces for
|
||||
bundled plugin code. They are tracked in the SDK inventory so package
|
||||
builds and aliasing stay deterministic, but they are not general plugin
|
||||
authoring APIs. New reusable host contracts should use generic SDK subpaths
|
||||
such as `plugin-sdk/gateway-runtime`, `plugin-sdk/security-runtime`, and
|
||||
`plugin-sdk/plugin-config-runtime`.
|
||||
|
||||
| Subpath | Owner and purpose |
|
||||
| --- | --- |
|
||||
| `plugin-sdk/codex-mcp-projection` | Bundled Codex plugin helper for projecting user MCP server config into Codex app-server thread config |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Bundled Codex plugin helper for mirroring Codex app-server native subagents into OpenClaw task state |
|
||||
|
||||
There are currently no reserved bundled-helper SDK subpaths. Owner-specific
|
||||
helpers live inside the owning plugin package, while reusable host contracts
|
||||
use generic SDK subpaths such as `plugin-sdk/gateway-runtime`,
|
||||
`plugin-sdk/security-runtime`, and `plugin-sdk/plugin-config-runtime`.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ title: "Tests"
|
||||
- `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed`: explicit broad changed test run. Use it when a test harness/config/package edit should fall back to Vitest's broader changed-test behavior.
|
||||
- `pnpm changed:lanes`: shows the architectural lanes triggered by the diff against `origin/main`.
|
||||
- `pnpm check:changed`: runs the smart changed check gate for the diff against `origin/main`. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
|
||||
- Codex worktrees and linked/sparse checkouts: avoid direct local `pnpm test*`, `pnpm check*`, and `pnpm crabbox:run` unless you have verified pnpm will not reconcile dependencies. For tiny explicit-file proof use `node scripts/run-vitest.mjs <path-or-filter>`; for changed gates or broad proof use `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox.
|
||||
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs use fixed shard groups and expand to leaf configs for local parallel execution; the extension group always expands to the per-extension shard configs instead of one giant root-project process.
|
||||
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.
|
||||
|
||||
@@ -168,20 +168,6 @@ This disables runtime trajectory capture. `/export-trajectory` can still export
|
||||
the transcript branch, but runtime-only files such as compiled context,
|
||||
provider artifacts, and prompt metadata may be missing.
|
||||
|
||||
## Tune flush timeout
|
||||
|
||||
OpenClaw flushes runtime trajectory sidecars during agent cleanup. The default
|
||||
cleanup timeout is 10,000 ms. On slow disks or large stores, set
|
||||
`OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS` before starting OpenClaw:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS=30000
|
||||
```
|
||||
|
||||
This controls when OpenClaw logs a `pi-trajectory-flush` timeout and continues.
|
||||
It does not change the trajectory size caps. To tune all agent cleanup steps
|
||||
that do not pass an explicit timeout, set `OPENCLAW_AGENT_CLEANUP_TIMEOUT_MS`.
|
||||
|
||||
## Privacy and limits
|
||||
|
||||
Trajectory bundles are designed for support and debugging, not public posting.
|
||||
|
||||
@@ -73,7 +73,6 @@ Notes:
|
||||
## Sending + delivery
|
||||
|
||||
- Messages are sent to the Gateway; delivery to providers is off by default.
|
||||
- The TUI is an internal source surface like WebChat, not a generic outbound channel. Harnesses that require `tools.message` for visible replies can satisfy the active TUI turn with a targetless `message.send`; explicit provider delivery still uses normal configured channels and never falls back to `lastChannel`.
|
||||
- Turn delivery on:
|
||||
- `/deliver on`
|
||||
- or the Settings panel
|
||||
|
||||
@@ -51,7 +51,6 @@ WebChat has two separate data paths:
|
||||
|
||||
- The session JSONL file is the durable model/runtime transcript. For normal agent runs, Pi persists model-visible `user`, `assistant`, and `toolResult` messages through its session manager. WebChat does not write arbitrary delivery, status, or helper text into that transcript.
|
||||
- Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session log.
|
||||
- Harnesses that require visible replies through `tools.message` still use WebChat as a current-run internal source reply sink. A targetless `message.send` from that active WebChat run is projected into the same chat and mirrored to the session transcript; WebChat does not become a reusable outbound channel and never inherits `lastChannel`.
|
||||
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal Pi assistant turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
|
||||
- `chat.history` reads the stored session transcript and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the raw JSONL contains the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.
|
||||
|
||||
|
||||
@@ -116,14 +116,6 @@ describe("active-memory plugin", () => {
|
||||
config: {
|
||||
current: () => configFile,
|
||||
loadConfig: () => configFile,
|
||||
mutateConfigFile: vi.fn(
|
||||
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
|
||||
const draft = structuredClone(configFile);
|
||||
mutate(draft);
|
||||
configFile = draft;
|
||||
return { changed: true, config: configFile };
|
||||
},
|
||||
),
|
||||
replaceConfigFile: vi.fn(
|
||||
async ({ nextConfig }: { nextConfig: Record<string, unknown> }) => {
|
||||
configFile = nextConfig;
|
||||
@@ -484,7 +476,7 @@ describe("active-memory plugin", () => {
|
||||
});
|
||||
|
||||
expect(offResult.text).toBe("Active Memory: off globally.");
|
||||
expect(api.runtime.config.mutateConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(api.runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
requireRecord(
|
||||
requireRecord(requireRecord(configFile.plugins, "plugins").entries, "entries")[
|
||||
@@ -584,7 +576,7 @@ describe("active-memory plugin", () => {
|
||||
expect(result.text).toContain("global enable/disable changes require operator.admin");
|
||||
}
|
||||
|
||||
expect(api.runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows admin-scoped gateway callers to change global active-memory config", async () => {
|
||||
@@ -603,7 +595,7 @@ describe("active-memory plugin", () => {
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Active Memory: off globally.");
|
||||
expect(api.runtime.config.mutateConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(api.runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
requireRecord(
|
||||
requireRecord(requireRecord(configFile.plugins, "plugins").entries, "entries")[
|
||||
|
||||
@@ -239,26 +239,4 @@ describe("canvas documents", () => {
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed encoded hosted canvas document paths", async () => {
|
||||
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
|
||||
tempDirs.push(stateDir);
|
||||
const documentId = "cv_malformed";
|
||||
const documentDir = resolveCanvasDocumentDir(documentId, { stateDir });
|
||||
await mkdir(documentDir, { recursive: true });
|
||||
await writeFile(path.join(documentDir, "%E0%A4%A.html"), "literal-percent-name", "utf8");
|
||||
|
||||
expect(
|
||||
resolveCanvasHttpPathToLocalPath(
|
||||
`/__openclaw__/canvas/documents/${documentId}/%E0%A4%A.html`,
|
||||
{ stateDir },
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCanvasHttpPathToLocalPath(
|
||||
`/__openclaw__/canvas/documents/${documentId}/%25E0%25A4%25A.html`,
|
||||
{ stateDir },
|
||||
),
|
||||
).toBe(path.join(documentDir, "%E0%A4%A.html"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,17 +153,16 @@ export function resolveCanvasHttpPathToLocalPath(
|
||||
}
|
||||
const pathWithoutQuery = trimmed.replace(/[?#].*$/, "");
|
||||
const relative = pathWithoutQuery.slice(prefix.length);
|
||||
const segments: string[] = [];
|
||||
for (const segment of relative.split("/")) {
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
segments.push(decodeURIComponent(segment));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const segments = relative
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (segments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,15 +47,7 @@ describe("resolveFileWithinRoot", () => {
|
||||
|
||||
it("rejects traversal paths", async () => {
|
||||
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
|
||||
await fs.writeFile(path.join(root, "outside.txt"), "inside-root", "utf8");
|
||||
await expect(resolveFileWithinRoot(root, "/../outside.txt")).resolves.toBeNull();
|
||||
await expect(resolveFileWithinRoot(root, "/%2e%2e%2foutside.txt")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed URL encoding as a missing file", async () => {
|
||||
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
|
||||
await expect(resolveFileWithinRoot(root, "/%E0%A4%A")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,46 +9,11 @@ export function normalizeUrlPath(rawPath: string): string {
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
function pathEscapesRoot(decodedPath: string): boolean {
|
||||
let depth = 0;
|
||||
for (const segment of decodedPath.split("/")) {
|
||||
if (segment === "" || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (depth === 0) {
|
||||
return true;
|
||||
}
|
||||
depth--;
|
||||
continue;
|
||||
}
|
||||
depth++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function tryNormalizeUrlPath(rawPath: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawPath || "/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (pathEscapesRoot(decoded)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = path.posix.normalize(decoded);
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
export async function resolveFileWithinRoot(
|
||||
rootReal: string,
|
||||
urlPath: string,
|
||||
): Promise<CanvasOpenResult | null> {
|
||||
const normalized = tryNormalizeUrlPath(urlPath);
|
||||
if (normalized === null) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeUrlPath(urlPath);
|
||||
const rel = normalized.replace(/^\/+/, "");
|
||||
if (rel.split("/").some((p) => p === "..")) {
|
||||
return null;
|
||||
|
||||
@@ -232,10 +232,6 @@ describe("canvas host", () => {
|
||||
expect(response.body).toContain("v1");
|
||||
expect(response.body).toContain(CANVAS_WS_PATH);
|
||||
|
||||
const malformed = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/%E0%A4%A`);
|
||||
expect(malformed.status).toBe(404);
|
||||
expect(malformed.body).toBe("not found");
|
||||
|
||||
const miss = await captureHandlerResponse(handler, "/");
|
||||
expect(miss.handled).toBe(false);
|
||||
|
||||
@@ -400,9 +396,6 @@ describe("canvas host", () => {
|
||||
const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`);
|
||||
expect(traversalRes.status).toBe(404);
|
||||
expect(traversalRes.body).toBe("not found");
|
||||
const malformedRes = await captureA2uiResponse(`${A2UI_PATH}/%E0%A4%A`);
|
||||
expect(malformedRes.status).toBe(404);
|
||||
expect(malformedRes.body).toBe("not found");
|
||||
const symlinkRes = await captureA2uiResponse(`${A2UI_PATH}/${linkName}`);
|
||||
expect(symlinkRes.status).toBe(404);
|
||||
expect(symlinkRes.body).toBe("not found");
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
bridgeCodexAppServerStartOptions,
|
||||
refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerAuthProfileId,
|
||||
resolveCodexAppServerHomeDir,
|
||||
resolveCodexAppServerNativeHomeDir,
|
||||
} from "./auth-bridge.js";
|
||||
@@ -652,65 +651,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("selects an oauthRef-backed Codex profile for app-server login", () => {
|
||||
expect(
|
||||
resolveCodexAppServerAuthProfileId({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "",
|
||||
refresh: "",
|
||||
expires: Date.now() + 60_000,
|
||||
oauthRef: {
|
||||
source: "openclaw-credentials",
|
||||
provider: "openai-codex",
|
||||
id: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("openai-codex:default");
|
||||
});
|
||||
|
||||
it("answers refresh requests from a discovered oauthRef-backed Codex profile", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
|
||||
access: "refreshed-ref-backed-access-token",
|
||||
refresh: "refreshed-ref-backed-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "account-ref-backed-refreshed",
|
||||
});
|
||||
try {
|
||||
upsertAuthProfile({
|
||||
agentDir,
|
||||
profileId: "openai-codex:default",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "ref-backed-access-token",
|
||||
refresh: "ref-backed-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "account-ref-backed",
|
||||
email: "codex@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(refreshCodexAppServerAuthTokens({ agentDir })).resolves.toEqual({
|
||||
accessToken: "refreshed-ref-backed-access-token",
|
||||
chatgptAccountId: "account-ref-backed-refreshed",
|
||||
chatgptPlanType: null,
|
||||
});
|
||||
|
||||
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("ref-backed-refresh-token");
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies native Codex CLI OAuth when no OpenClaw auth profile exists", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const agentDir = path.join(root, "agent");
|
||||
|
||||
@@ -127,42 +127,6 @@ describe("CodexAppServerClient", () => {
|
||||
await expect(request).rejects.toHaveProperty("message", "Method not found");
|
||||
});
|
||||
|
||||
it("surfaces relogin details from Codex app-server RPC errors", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
const request = harness.client.request("thread/start", {});
|
||||
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
error: {
|
||||
code: -32602,
|
||||
message: "failed to load configuration",
|
||||
data: {
|
||||
reason: "cloudRequirements",
|
||||
errorCode: "Auth",
|
||||
action: "relogin",
|
||||
statusCode: 401,
|
||||
detail:
|
||||
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(request).rejects.toHaveProperty(
|
||||
"message",
|
||||
"failed to load configuration: Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
);
|
||||
await expect(request).rejects.toHaveProperty("data", {
|
||||
reason: "cloudRequirements",
|
||||
errorCode: "Auth",
|
||||
action: "relogin",
|
||||
statusCode: 401,
|
||||
detail:
|
||||
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects timed-out requests and ignores late responses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createClientHarness();
|
||||
|
||||
@@ -42,39 +42,13 @@ export class CodexAppServerRpcError extends Error {
|
||||
readonly data?: JsonValue;
|
||||
|
||||
constructor(error: { code?: number; message: string; data?: JsonValue }, method: string) {
|
||||
super(formatCodexAppServerRpcErrorMessage(error, method));
|
||||
super(error.message || `${method} failed`);
|
||||
this.name = "CodexAppServerRpcError";
|
||||
this.code = error.code;
|
||||
this.data = error.data;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCodexAppServerRpcErrorMessage(
|
||||
error: { message: string; data?: JsonValue },
|
||||
method: string,
|
||||
): string {
|
||||
const message = error.message || `${method} failed`;
|
||||
const detail = readCodexAppServerRpcReloginDetail(error.data);
|
||||
return detail && !message.includes(detail) ? `${message}: ${detail}` : message;
|
||||
}
|
||||
|
||||
function readCodexAppServerRpcReloginDetail(data: JsonValue | undefined): string | undefined {
|
||||
const record = isJsonObject(data) ? data : undefined;
|
||||
const nested = isJsonObject(record?.error) ? record.error : record;
|
||||
if (!nested) {
|
||||
return undefined;
|
||||
}
|
||||
const isRelogin =
|
||||
nested.action === "relogin" ||
|
||||
(nested.reason === "cloudRequirements" && nested.errorCode === "Auth");
|
||||
const detail = typeof nested.detail === "string" ? nested.detail.trim() : "";
|
||||
return isRelogin && detail ? detail : undefined;
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is { [key: string]: JsonValue } {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function isCodexAppServerConnectionClosedError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
|
||||
@@ -320,37 +320,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("records internal UI source replies separately from outbound messaging evidence", async () => {
|
||||
const toolResult = textToolResult("Sent to current chat.", {
|
||||
status: "ok",
|
||||
deliveryStatus: "sent",
|
||||
sourceReplySink: "internal-ui",
|
||||
sourceReply: {
|
||||
text: "visible reply",
|
||||
mediaUrls: ["/tmp/reply.png"],
|
||||
},
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", toolResult);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "<think>private</think>visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent to current chat."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(bridge.telemetry.messagingToolSentTexts).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSentMediaUrls).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSourceReplyPayloads).toEqual([
|
||||
{
|
||||
text: "visible reply",
|
||||
mediaUrl: "/tmp/reply.png",
|
||||
mediaUrls: ["/tmp/reply.png"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
type AnyAgentTool,
|
||||
type HeartbeatToolResponse,
|
||||
type MessagingToolSend,
|
||||
type MessagingToolSourceReplyPayload,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexDynamicToolsLoading } from "./config.js";
|
||||
@@ -48,7 +47,6 @@ export type CodexDynamicToolBridge = {
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
messagingToolSourceReplyPayloads: MessagingToolSourceReplyPayload[];
|
||||
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||
toolMediaUrls: string[];
|
||||
toolAudioAsVoice: boolean;
|
||||
@@ -79,7 +77,6 @@ export function createCodexDynamicToolBridge(params: {
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
messagingToolSourceReplyPayloads: [],
|
||||
toolMediaUrls: [],
|
||||
toolAudioAsVoice: false,
|
||||
};
|
||||
@@ -282,11 +279,6 @@ function collectToolTelemetry(params: {
|
||||
return;
|
||||
}
|
||||
params.telemetry.didSendViaMessagingTool = true;
|
||||
const sourceReplyPayload = extractInternalSourceReplyPayload(params.result?.details);
|
||||
if (sourceReplyPayload) {
|
||||
params.telemetry.messagingToolSourceReplyPayloads.push(sourceReplyPayload);
|
||||
return;
|
||||
}
|
||||
const text = readFirstString(params.args, ["text", "message", "body", "content"]);
|
||||
if (text) {
|
||||
params.telemetry.messagingToolSentTexts.push(text);
|
||||
@@ -304,41 +296,6 @@ function collectToolTelemetry(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function extractInternalSourceReplyPayload(
|
||||
details: unknown,
|
||||
): MessagingToolSourceReplyPayload | undefined {
|
||||
if (!isRecord(details) || details.sourceReplySink !== "internal-ui") {
|
||||
return undefined;
|
||||
}
|
||||
const rawPayload = details.sourceReply;
|
||||
if (!isRecord(rawPayload)) {
|
||||
return undefined;
|
||||
}
|
||||
const text = readFirstString(rawPayload, ["text", "message"]);
|
||||
const mediaUrls = collectMediaUrls(rawPayload);
|
||||
const mediaUrl =
|
||||
typeof rawPayload.mediaUrl === "string" && rawPayload.mediaUrl.trim()
|
||||
? rawPayload.mediaUrl.trim()
|
||||
: mediaUrls[0];
|
||||
const payload: MessagingToolSourceReplyPayload = {
|
||||
...(text ? { text } : {}),
|
||||
...(mediaUrl ? { mediaUrl } : {}),
|
||||
...(mediaUrls.length > 0 ? { mediaUrls } : {}),
|
||||
...(rawPayload.audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||
...(isRecord(rawPayload.presentation)
|
||||
? { presentation: rawPayload.presentation as never }
|
||||
: {}),
|
||||
...(isRecord(rawPayload.interactive) ? { interactive: rawPayload.interactive as never } : {}),
|
||||
...(isRecord(rawPayload.channelData) ? { channelData: rawPayload.channelData } : {}),
|
||||
...(typeof details.idempotencyKey === "string" && details.idempotencyKey.trim()
|
||||
? { idempotencyKey: details.idempotencyKey.trim() }
|
||||
: {}),
|
||||
};
|
||||
return text || mediaUrls.length > 0 || payload.presentation || payload.interactive
|
||||
? payload
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
type EmbeddedRunAttemptResult,
|
||||
type HeartbeatToolResponse,
|
||||
type MessagingToolSend,
|
||||
type MessagingToolSourceReplyPayload,
|
||||
type ToolProgressDetailMode,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
@@ -47,7 +46,6 @@ export type CodexAppServerToolTelemetry = {
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
|
||||
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||
toolMediaUrls?: string[];
|
||||
toolAudioAsVoice?: boolean;
|
||||
@@ -322,7 +320,6 @@ export class CodexAppServerEventProjector {
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
messagingToolSourceReplyPayloads: toolTelemetry.messagingToolSourceReplyPayloads ?? [],
|
||||
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
|
||||
toolMediaUrls: this.buildToolMediaUrls(toolTelemetry),
|
||||
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
|
||||
|
||||
@@ -651,109 +651,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes MCP server config through to Codex thread/start", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
config: {
|
||||
mcp_servers: {
|
||||
search: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
const startRequest = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
expect((startRequest?.[1] as { config?: unknown } | undefined)?.config).toMatchObject({
|
||||
mcp_servers: {
|
||||
search: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.mcpServersFingerprint).toBe("mcp-v1");
|
||||
});
|
||||
|
||||
it("starts a new Codex thread when the MCP server fingerprint changes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "old-thread",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: JSON.stringify([]),
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("new-thread");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
mcpServersFingerprint: "mcp-v2",
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding.threadId).toBe("new-thread");
|
||||
expect(binding.mcpServersFingerprint).toBe("mcp-v2");
|
||||
});
|
||||
|
||||
it("starts a no-MCP Codex thread when MCP config is evaluated empty", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "old-thread",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: JSON.stringify([]),
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("new-thread");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding.threadId).toBe("new-thread");
|
||||
expect(binding.mcpServersFingerprint).toBeUndefined();
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.mcpServersFingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not expose OpenClaw Tool Search controls through Codex dynamic tools", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
formatErrorMessage,
|
||||
isActiveHarnessContextEngine,
|
||||
isSubagentSessionKey,
|
||||
loadCodexBundleMcpThreadConfig,
|
||||
normalizeAgentRuntimeTools,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
@@ -88,7 +87,6 @@ import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js";
|
||||
import {
|
||||
buildCodexPluginThreadConfig,
|
||||
buildCodexPluginThreadConfigInputFingerprint,
|
||||
mergeCodexThreadConfigs,
|
||||
shouldBuildCodexPluginThreadConfig,
|
||||
} from "./plugin-thread-config.js";
|
||||
import {
|
||||
@@ -519,16 +517,6 @@ export async function runCodexAppServerAttempt(
|
||||
: resolveCodexAppServerEnvApiKeyCacheKey({
|
||||
startOptions: appServer.start,
|
||||
});
|
||||
const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
cfg: params.config,
|
||||
toolsEnabled: supportsModelTools(params.model),
|
||||
disableTools: params.disableTools,
|
||||
toolsAllow: params.toolsAllow,
|
||||
});
|
||||
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
|
||||
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
|
||||
}
|
||||
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
|
||||
? params.contextEngine
|
||||
: undefined;
|
||||
@@ -725,10 +713,7 @@ export async function runCodexAppServerAttempt(
|
||||
: options.nativeHookRelay?.enabled === false
|
||||
? buildCodexNativeHookRelayDisabledConfig()
|
||||
: undefined;
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
bundleMcpThreadConfig?.configPatch as JsonObject | undefined,
|
||||
);
|
||||
const threadConfig = nativeHookRelayConfig;
|
||||
const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig);
|
||||
const pluginAppCacheKey = buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
@@ -787,8 +772,6 @@ export async function runCodexAppServerAttempt(
|
||||
appServer: pluginAppServer,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
config: threadConfig,
|
||||
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
|
||||
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
|
||||
pluginThreadConfig: pluginThreadConfigEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -1916,7 +1899,6 @@ function buildCodexTurnStartFailureResult(params: {
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
messagingToolSourceReplyPayloads: [],
|
||||
cloudCodeAssistFormatError: false,
|
||||
replayMetadata: {
|
||||
hadPotentialSideEffects: false,
|
||||
|
||||
@@ -41,7 +41,6 @@ export type CodexAppServerThreadBinding = {
|
||||
serviceTier?: CodexServiceTier;
|
||||
dynamicToolsFingerprint?: string;
|
||||
userMcpServersFingerprint?: string;
|
||||
mcpServersFingerprint?: string;
|
||||
pluginAppsFingerprint?: string;
|
||||
pluginAppsInputFingerprint?: string;
|
||||
pluginAppPolicyContext?: PluginAppPolicyContext;
|
||||
@@ -105,8 +104,6 @@ export async function readCodexAppServerBinding(
|
||||
typeof parsed.userMcpServersFingerprint === "string"
|
||||
? parsed.userMcpServersFingerprint
|
||||
: undefined,
|
||||
mcpServersFingerprint:
|
||||
typeof parsed.mcpServersFingerprint === "string" ? parsed.mcpServersFingerprint : undefined,
|
||||
pluginAppsFingerprint:
|
||||
typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined,
|
||||
pluginAppsInputFingerprint:
|
||||
@@ -152,7 +149,6 @@ export async function writeCodexAppServerBinding(
|
||||
serviceTier: binding.serviceTier,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint: binding.userMcpServersFingerprint,
|
||||
mcpServersFingerprint: binding.mcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
|
||||
@@ -73,8 +73,6 @@ export async function startOrResumeThread(params: {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
mcpServersFingerprint?: string;
|
||||
mcpServersFingerprintEvaluated?: boolean;
|
||||
pluginThreadConfig?: CodexPluginThreadConfigProvider;
|
||||
}): Promise<CodexAppServerThreadLifecycleBinding> {
|
||||
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
|
||||
@@ -114,17 +112,6 @@ export async function startOrResumeThread(params: {
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (binding?.threadId) {
|
||||
let pluginBindingStale = isCodexPluginThreadBindingStale({
|
||||
codexPluginsEnabled: params.pluginThreadConfig?.enabled ?? false,
|
||||
@@ -159,17 +146,6 @@ export async function startOrResumeThread(params: {
|
||||
binding = undefined;
|
||||
}
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (binding?.threadId) {
|
||||
// `/codex resume <thread>` writes a binding before the next turn can know
|
||||
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
|
||||
@@ -203,7 +179,6 @@ export async function startOrResumeThread(params: {
|
||||
} else {
|
||||
try {
|
||||
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
|
||||
const resumeConfig = mergeCodexThreadConfigs(params.config, userMcpServersConfigPatch);
|
||||
const response = assertCodexThreadResumeResponse(
|
||||
await params.client.request(
|
||||
"thread/resume",
|
||||
@@ -212,7 +187,7 @@ export async function startOrResumeThread(params: {
|
||||
authProfileId,
|
||||
appServer: params.appServer,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config: resumeConfig,
|
||||
config: params.config,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -224,10 +199,6 @@ export async function startOrResumeThread(params: {
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
});
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true
|
||||
? params.mcpServersFingerprint
|
||||
: binding.mcpServersFingerprint;
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
@@ -238,7 +209,6 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
@@ -260,7 +230,6 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
@@ -307,8 +276,6 @@ export async function startOrResumeThread(params: {
|
||||
config: params.params.config,
|
||||
});
|
||||
const createdAt = new Date().toISOString();
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
|
||||
if (!preserveExistingBinding) {
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
@@ -320,7 +287,6 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
@@ -344,7 +310,6 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
|
||||
});
|
||||
});
|
||||
|
||||
it("resends user MCP config when resuming a thread with the matching fingerprint", async () => {
|
||||
it("resumes a thread with the matching user MCP fingerprint without resending ignored MCP config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const config = {
|
||||
@@ -252,8 +252,6 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
|
||||
config?: { mcp_servers?: Record<string, unknown> };
|
||||
};
|
||||
expect(resumeCall).toBeDefined();
|
||||
expect(resumeParams?.config?.mcp_servers).toMatchObject({
|
||||
notes: { command: "node", args: ["/opt/notes-mcp/dist/index.js"] },
|
||||
});
|
||||
expect(resumeParams?.config?.mcp_servers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
|
||||
// Keep this in sync with the Codex CLI live-test package pin.
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.130.0";
|
||||
|
||||
@@ -52,7 +52,6 @@ import { resolveCodexMigrationTargets } from "./targets.js";
|
||||
|
||||
const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required";
|
||||
const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration";
|
||||
const CODEX_CONFIG_PATCH_MODE_RETURN = "return";
|
||||
|
||||
class CodexPluginConfigConflictError extends Error {
|
||||
constructor(readonly reason: string) {
|
||||
@@ -61,10 +60,6 @@ class CodexPluginConfigConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldReturnCodexPluginConfigPatch(ctx: MigrationProviderContext): boolean {
|
||||
return ctx.providerOptions?.configPatchMode === CODEX_CONFIG_PATCH_MODE_RETURN;
|
||||
}
|
||||
|
||||
export async function applyCodexMigrationPlan(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
plan?: MigrationPlan;
|
||||
@@ -225,33 +220,15 @@ async function applyCodexPluginConfigItem(
|
||||
if (entries.length === 0) {
|
||||
return markMigrationItemSkipped(item, "no selected Codex plugins");
|
||||
}
|
||||
const returnPatch = shouldReturnCodexPluginConfigPatch(ctx);
|
||||
const configApi = ctx.runtime?.config;
|
||||
const currentConfig = returnPatch
|
||||
? ctx.config
|
||||
: (configApi?.current?.() as MigrationProviderContext["config"] | undefined);
|
||||
if (!currentConfig) {
|
||||
if (!configApi?.current || !configApi.mutateConfigFile) {
|
||||
return markMigrationItemError(item, "config runtime unavailable");
|
||||
}
|
||||
const currentConfig = configApi.current() as MigrationProviderContext["config"];
|
||||
const value = buildCodexPluginsConfigValue(entries, { config: currentConfig });
|
||||
if (!ctx.overwrite && hasCodexPluginConfigConflict(currentConfig, value)) {
|
||||
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
|
||||
}
|
||||
const migratedItem: MigrationItem = {
|
||||
...item,
|
||||
status: "migrated",
|
||||
details: {
|
||||
...item.details,
|
||||
path: [...CODEX_PLUGIN_CONFIG_PATH],
|
||||
value,
|
||||
},
|
||||
};
|
||||
if (returnPatch) {
|
||||
return migratedItem;
|
||||
}
|
||||
if (!configApi?.mutateConfigFile) {
|
||||
return markMigrationItemError(item, "config runtime unavailable");
|
||||
}
|
||||
try {
|
||||
await configApi.mutateConfigFile({
|
||||
base: "runtime",
|
||||
@@ -263,7 +240,15 @@ async function applyCodexPluginConfigItem(
|
||||
writeMigrationConfigPath(draft as Record<string, unknown>, CODEX_PLUGIN_CONFIG_PATH, value);
|
||||
},
|
||||
});
|
||||
return migratedItem;
|
||||
return {
|
||||
...item,
|
||||
status: "migrated",
|
||||
details: {
|
||||
...item.details,
|
||||
path: [...CODEX_PLUGIN_CONFIG_PATH],
|
||||
value,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CodexPluginConfigConflictError) {
|
||||
return markMigrationItemConflict(item, error.reason);
|
||||
|
||||
@@ -41,7 +41,6 @@ function makeContext(params: {
|
||||
workspaceDir: string;
|
||||
overwrite?: boolean;
|
||||
verifyPluginApps?: boolean;
|
||||
providerOptions?: MigrationProviderContext["providerOptions"];
|
||||
reportDir?: string;
|
||||
config?: MigrationProviderContext["config"];
|
||||
runtime?: MigrationProviderContext["runtime"];
|
||||
@@ -60,8 +59,7 @@ function makeContext(params: {
|
||||
source: params.source,
|
||||
stateDir: params.stateDir,
|
||||
overwrite: params.overwrite,
|
||||
providerOptions:
|
||||
params.providerOptions ?? (params.verifyPluginApps ? { verifyPluginApps: true } : undefined),
|
||||
providerOptions: params.verifyPluginApps ? { verifyPluginApps: true } : undefined,
|
||||
reportDir: params.reportDir,
|
||||
logger,
|
||||
};
|
||||
@@ -142,7 +140,7 @@ function sourceAppCacheKey(fixture: { codexHome: string }): string {
|
||||
start: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
commandSource: "managed",
|
||||
commandSource: "config",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
env: {
|
||||
@@ -212,6 +210,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
status: "planned",
|
||||
});
|
||||
expect(plan.items.some((item) => item.id === "skill:system-skill")).toBe(false);
|
||||
expect((plan.warnings ?? []).some((warning) => warning.includes("cached plugin bundles"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("plans source-installed curated plugins without installing during dry-run", async () => {
|
||||
@@ -241,14 +242,6 @@ describe("buildCodexMigrationProvider", () => {
|
||||
method: "plugin/list",
|
||||
requestParams: { cwds: [] },
|
||||
});
|
||||
expectRecordFields((mockCallArg(appServerRequest) as { startOptions?: unknown }).startOptions, {
|
||||
command: "codex",
|
||||
commandSource: "managed",
|
||||
env: {
|
||||
CODEX_HOME: fixture.codexHome,
|
||||
HOME: path.dirname(fixture.codexHome),
|
||||
},
|
||||
});
|
||||
expect(
|
||||
appServerRequest.mock.calls.some(
|
||||
([arg]) => (arg as { method?: string }).method === "plugin/install",
|
||||
@@ -376,6 +369,7 @@ describe("buildCodexMigrationProvider", () => {
|
||||
expect(plan.warnings).toEqual([
|
||||
"Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.",
|
||||
"Codex app-backed plugins were planned without source app accessibility verification. Re-run with --verify-plugin-apps to force a fresh source app/list check before planning native plugin activation.",
|
||||
"Codex cached plugin bundles remain manual-review only.",
|
||||
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
|
||||
]);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
@@ -432,6 +426,7 @@ describe("buildCodexMigrationProvider", () => {
|
||||
},
|
||||
]);
|
||||
expect(plan.warnings).toEqual([
|
||||
"Codex cached plugin bundles remain manual-review only.",
|
||||
"Codex app-backed plugin migration requires the Codex app-server source account to be logged in with a ChatGPT subscription account. Log in to the Codex app with subscription auth; OpenClaw auth or API-key auth does not satisfy Codex app connector access.",
|
||||
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
|
||||
]);
|
||||
@@ -1076,93 +1071,6 @@ describe("buildCodexMigrationProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns Codex plugin config patches without mutating config in return mode", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: { sandbox: "workspace-write" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
if (method === "skills/list") {
|
||||
return { data: [] } satisfies v2.SkillsListResponse;
|
||||
}
|
||||
if (method === "hooks/list") {
|
||||
return { data: [] } satisfies v2.HooksListResponse;
|
||||
}
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const mutateConfigFile = vi.fn(async () => {
|
||||
throw new Error("mutateConfigFile should not be called in return mode");
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
runtime: {
|
||||
config: {
|
||||
current: () => configState,
|
||||
mutateConfigFile,
|
||||
},
|
||||
} as unknown as MigrationProviderContext["runtime"],
|
||||
});
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
config: configState,
|
||||
providerOptions: { configPatchMode: "return" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toBeUndefined();
|
||||
const configItem = findItem(result.items, "config:codex-plugins");
|
||||
expectRecordFields(configItem, { status: "migrated" });
|
||||
const configDetails = configItem.details as Record<string, unknown>;
|
||||
expectRecordFields(configDetails, {
|
||||
path: ["plugins", "entries", "codex"],
|
||||
});
|
||||
expect(configDetails.value).toEqual({
|
||||
enabled: true,
|
||||
config: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: true,
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
enabled: true,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges migrated plugin config with existing Codex plugins when entries do not conflict", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const sourceKey = sourceAppCacheKey(fixture);
|
||||
|
||||
@@ -245,7 +245,7 @@ function sourceCodexAppServerStartOptions(codexHome: string): CodexAppServerStar
|
||||
return {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
commandSource: "managed",
|
||||
commandSource: "config",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
env: {
|
||||
@@ -576,7 +576,7 @@ export async function discoverCodexSource(
|
||||
const hooksPath = path.join(codexHome, "hooks", "hooks.json");
|
||||
const codexSkills = await discoverSkillDirs({
|
||||
root: codexSkillsDir,
|
||||
sourceLabel: "Codex skill",
|
||||
sourceLabel: "Codex CLI skill",
|
||||
excludeSystem: true,
|
||||
});
|
||||
const personalAgentSkills = await discoverSkillDirs({
|
||||
|
||||
@@ -28,20 +28,6 @@ function createHarness(initialConfig: OpenClawConfig = {}) {
|
||||
config: {
|
||||
current: vi.fn(() => runtimeConfig),
|
||||
loadConfig: vi.fn(() => runtimeConfig),
|
||||
mutateConfigFile: vi.fn(async ({ mutate }: { mutate: (draft: OpenClawConfig) => void }) => {
|
||||
const draft = structuredClone(runtimeConfig);
|
||||
mutate(draft);
|
||||
runtimeConfig = draft;
|
||||
return {
|
||||
path: "/tmp/openclaw.json",
|
||||
previousHash: null,
|
||||
snapshot: {},
|
||||
nextConfig: runtimeConfig,
|
||||
afterWrite: { mode: "auto" },
|
||||
followUp: { mode: "auto", requiresRestart: false },
|
||||
result: undefined,
|
||||
};
|
||||
}),
|
||||
replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: OpenClawConfig }) => {
|
||||
runtimeConfig = nextConfig;
|
||||
}),
|
||||
@@ -130,7 +116,7 @@ describe("memory-core /dreaming command", () => {
|
||||
|
||||
const result = await command.handler(createCommandContext("off"));
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1);
|
||||
const storedDreaming = resolveStoredDreaming(getRuntimeConfig());
|
||||
expect(storedDreaming.enabled).toBe(false);
|
||||
expect(storedDreaming.frequency).toBe("0 */6 * * *");
|
||||
@@ -147,7 +133,7 @@ describe("memory-core /dreaming command", () => {
|
||||
);
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks write-scoped gateway callers from persisting dreaming config", async () => {
|
||||
@@ -160,7 +146,7 @@ describe("memory-core /dreaming command", () => {
|
||||
);
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows admin-scoped gateway callers to persist dreaming config", async () => {
|
||||
@@ -172,7 +158,7 @@ describe("memory-core /dreaming command", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(resolveStoredDreaming(getRuntimeConfig()).enabled).toBe(true);
|
||||
expect(result.text).toContain("Dreaming enabled.");
|
||||
});
|
||||
@@ -203,7 +189,7 @@ describe("memory-core /dreaming command", () => {
|
||||
expect(result.text).toContain("- enabled: off (America/Los_Angeles)");
|
||||
expect(result.text).toContain("- sweep cadence: 15 */8 * * *");
|
||||
expect(result.text).toContain("- promotion policy: score>=0.8, recalls>=3, uniqueQueries>=3");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows usage for invalid args and does not mutate config", async () => {
|
||||
@@ -211,6 +197,6 @@ describe("memory-core /dreaming command", () => {
|
||||
const result = await command.handler(createCommandContext("unknown-mode"));
|
||||
|
||||
expect(result.text).toContain("Usage: /dreaming status");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,25 +31,6 @@ vi.mock("../runtime.js", () => ({
|
||||
},
|
||||
channel: {
|
||||
media: {
|
||||
saveResponseMedia: vi.fn(
|
||||
async (
|
||||
response: Response,
|
||||
options?: { fallbackContentType?: string; maxBytes?: number },
|
||||
) => {
|
||||
const length = Number(response.headers.get("content-length"));
|
||||
if (
|
||||
Number.isFinite(length) &&
|
||||
options?.maxBytes !== undefined &&
|
||||
length > options.maxBytes
|
||||
) {
|
||||
throw new Error("content length exceeds maxBytes");
|
||||
}
|
||||
return {
|
||||
path: "/tmp/saved.png",
|
||||
contentType: options?.fallbackContentType ?? "image/png",
|
||||
};
|
||||
},
|
||||
),
|
||||
saveMediaBuffer: vi.fn(async (_buf: Buffer, ct: string) => ({
|
||||
path: "/tmp/saved.png",
|
||||
contentType: ct ?? "image/png",
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("nvidia onboard", () => {
|
||||
legacyModelName: "Custom",
|
||||
});
|
||||
expect(provider?.models.map((model) => model.id)).toEqual([
|
||||
"nvidia/custom-model",
|
||||
"custom-model",
|
||||
"nvidia/nemotron-3-super-120b-a12b",
|
||||
"moonshotai/kimi-k2.5",
|
||||
"minimaxai/minimax-m2.5",
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
|
||||
const CODEX_CLI_DEFAULT_MODEL_REF = "codex-cli/gpt-5.5";
|
||||
// Keep this in sync with MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION in the Codex plugin.
|
||||
const CODEX_CLI_NPM_PACKAGE = "@openai/codex@0.130.0";
|
||||
const CODEX_CLI_NPM_PACKAGE = "@openai/codex@0.129.0";
|
||||
|
||||
export function buildOpenAICodexCliBackend(): CliBackendPlugin {
|
||||
return {
|
||||
|
||||
@@ -31,24 +31,6 @@ function createApi(params: {
|
||||
},
|
||||
config: {
|
||||
current: () => params.getConfig(),
|
||||
mutateConfigFile: async ({
|
||||
mutate,
|
||||
}: {
|
||||
mutate: (draft: Record<string, unknown>) => void;
|
||||
}) => {
|
||||
const nextConfig = structuredClone(params.getConfig());
|
||||
mutate(nextConfig);
|
||||
await params.writeConfig(nextConfig);
|
||||
return {
|
||||
path: "/tmp/openclaw.json",
|
||||
previousHash: null,
|
||||
snapshot: {},
|
||||
nextConfig,
|
||||
afterWrite: { mode: "auto" },
|
||||
followUp: { mode: "auto", requiresRestart: false },
|
||||
result: undefined,
|
||||
};
|
||||
},
|
||||
replaceConfigFile: ({ nextConfig }: { nextConfig: unknown }) =>
|
||||
params.writeConfig(nextConfig as Record<string, unknown>),
|
||||
},
|
||||
|
||||
@@ -9,22 +9,6 @@ function createHarness(config: Record<string, unknown>) {
|
||||
config: {
|
||||
current: vi.fn(() => config),
|
||||
loadConfig: vi.fn(() => config),
|
||||
mutateConfigFile: vi.fn(
|
||||
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
|
||||
const draft = structuredClone(config);
|
||||
mutate(draft);
|
||||
config = draft;
|
||||
return {
|
||||
path: "/tmp/openclaw.json",
|
||||
previousHash: null,
|
||||
snapshot: {},
|
||||
nextConfig: config,
|
||||
afterWrite: { mode: "auto" },
|
||||
followUp: { mode: "auto", requiresRestart: false },
|
||||
result: undefined,
|
||||
};
|
||||
},
|
||||
),
|
||||
replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: Record<string, unknown> }) => {
|
||||
config = nextConfig;
|
||||
}),
|
||||
@@ -203,20 +187,19 @@ describe("talk-voice plugin", () => {
|
||||
createCommandContext("set Claudia", "webchat", ["operator.admin"]),
|
||||
);
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalledWith({
|
||||
expect(runtime.config.replaceConfigFile).toHaveBeenCalledWith({
|
||||
afterWrite: { mode: "auto" },
|
||||
mutate: expect.any(Function),
|
||||
});
|
||||
expect(runtime.config.current()).toEqual({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
voiceId: "voice-a",
|
||||
nextConfig: {
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
voiceId: "voice-a",
|
||||
},
|
||||
},
|
||||
voiceId: "voice-a",
|
||||
},
|
||||
voiceId: "voice-a",
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -237,16 +220,15 @@ describe("talk-voice plugin", () => {
|
||||
|
||||
await command.handler(createCommandContext("set Ava", "webchat", ["operator.admin"]));
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalledWith({
|
||||
expect(runtime.config.replaceConfigFile).toHaveBeenCalledWith({
|
||||
afterWrite: { mode: "auto" },
|
||||
mutate: expect.any(Function),
|
||||
});
|
||||
expect(runtime.config.current()).toEqual({
|
||||
talk: {
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {
|
||||
voiceId: "en-US-AvaNeural",
|
||||
nextConfig: {
|
||||
talk: {
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {
|
||||
voiceId: "en-US-AvaNeural",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,7 +240,7 @@ describe("talk-voice plugin", () => {
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects /voice set from non-webchat gateway callers missing operator.admin", async () => {
|
||||
@@ -266,14 +248,14 @@ describe("talk-voice plugin", () => {
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set from gateway client with operator.admin scope", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.admin"]);
|
||||
const result = await run();
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
@@ -282,14 +264,14 @@ describe("talk-voice plugin", () => {
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set from non-gateway channels without operator.admin", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram");
|
||||
const result = await run();
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
@@ -297,7 +279,7 @@ describe("talk-voice plugin", () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]);
|
||||
const result = await run();
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
|
||||
expect(runtime.config.replaceConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
||||
import { buildChannelTurnContext } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
createChannelMessageReplyPipeline,
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
recordInboundSession,
|
||||
upsertChannelPairingRequest,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { loadSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime";
|
||||
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
|
||||
@@ -30,12 +23,6 @@ export type TelegramBotDeps = {
|
||||
getRuntimeConfig: typeof getRuntimeConfig;
|
||||
resolveStorePath: typeof resolveStorePath;
|
||||
loadSessionStore?: typeof loadSessionStore;
|
||||
readSessionUpdatedAt?: typeof readSessionUpdatedAt;
|
||||
recordInboundSession?: typeof recordInboundSession;
|
||||
recordChannelActivity?: typeof recordChannelActivity;
|
||||
resolveInboundLastRouteSessionKey?: typeof resolveInboundLastRouteSessionKey;
|
||||
resolvePinnedMainDmOwnerFromAllowlist?: typeof resolvePinnedMainDmOwnerFromAllowlist;
|
||||
buildChannelTurnContext?: typeof buildChannelTurnContext;
|
||||
readChannelAllowFromStore: typeof readChannelAllowFromStore;
|
||||
upsertChannelPairingRequest: typeof upsertChannelPairingRequest;
|
||||
enqueueSystemEvent: typeof enqueueSystemEvent;
|
||||
@@ -67,24 +54,6 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
|
||||
get loadSessionStore() {
|
||||
return loadSessionStore;
|
||||
},
|
||||
get readSessionUpdatedAt() {
|
||||
return readSessionUpdatedAt;
|
||||
},
|
||||
get recordInboundSession() {
|
||||
return recordInboundSession;
|
||||
},
|
||||
get recordChannelActivity() {
|
||||
return recordChannelActivity;
|
||||
},
|
||||
get resolveInboundLastRouteSessionKey() {
|
||||
return resolveInboundLastRouteSessionKey;
|
||||
},
|
||||
get resolvePinnedMainDmOwnerFromAllowlist() {
|
||||
return resolvePinnedMainDmOwnerFromAllowlist;
|
||||
},
|
||||
get buildChannelTurnContext() {
|
||||
return buildChannelTurnContext;
|
||||
},
|
||||
get upsertChannelPairingRequest() {
|
||||
return upsertChannelPairingRequest;
|
||||
},
|
||||
|
||||
@@ -391,7 +391,6 @@ export const registerTelegramHandlers = ({
|
||||
|
||||
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
|
||||
debounceMs,
|
||||
serializeImmediate: true,
|
||||
resolveDebounceMs: (entry) =>
|
||||
entry.debounceLane === "forward" ? FORWARD_BURST_DEBOUNCE_MS : debounceMs,
|
||||
buildKey: (entry) => entry.debounceKey,
|
||||
|
||||
@@ -72,29 +72,6 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
|
||||
telegramDeps,
|
||||
opts,
|
||||
} = deps;
|
||||
const sessionRuntime = {
|
||||
...(telegramDeps.buildChannelTurnContext
|
||||
? { buildChannelTurnContext: telegramDeps.buildChannelTurnContext }
|
||||
: {}),
|
||||
...(telegramDeps.readSessionUpdatedAt
|
||||
? { readSessionUpdatedAt: telegramDeps.readSessionUpdatedAt }
|
||||
: {}),
|
||||
...(telegramDeps.recordInboundSession
|
||||
? { recordInboundSession: telegramDeps.recordInboundSession }
|
||||
: {}),
|
||||
...(telegramDeps.resolveInboundLastRouteSessionKey
|
||||
? { resolveInboundLastRouteSessionKey: telegramDeps.resolveInboundLastRouteSessionKey }
|
||||
: {}),
|
||||
...(telegramDeps.resolvePinnedMainDmOwnerFromAllowlist
|
||||
? {
|
||||
resolvePinnedMainDmOwnerFromAllowlist: telegramDeps.resolvePinnedMainDmOwnerFromAllowlist,
|
||||
}
|
||||
: {}),
|
||||
resolveStorePath: telegramDeps.resolveStorePath,
|
||||
};
|
||||
const contextRuntime = telegramDeps.recordChannelActivity
|
||||
? { recordChannelActivity: telegramDeps.recordChannelActivity }
|
||||
: undefined;
|
||||
|
||||
return async (
|
||||
primaryCtx: TelegramContext,
|
||||
@@ -135,8 +112,6 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
|
||||
resolveTelegramGroupConfig,
|
||||
sendChatActionHandler,
|
||||
loadFreshConfig,
|
||||
runtime: contextRuntime,
|
||||
sessionRuntime,
|
||||
upsertPairingRequest: telegramDeps.upsertChannelPairingRequest,
|
||||
});
|
||||
if (!context) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import { buildChannelTurnContext } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { MockFn } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
@@ -14,8 +13,6 @@ type LoadSessionStoreFn =
|
||||
typeof import("openclaw/plugin-sdk/session-store-runtime").loadSessionStore;
|
||||
type ResolveStorePathFn =
|
||||
typeof import("openclaw/plugin-sdk/session-store-runtime").resolveStorePath;
|
||||
type ReadSessionUpdatedAtFn =
|
||||
typeof import("openclaw/plugin-sdk/session-store-runtime").readSessionUpdatedAt;
|
||||
type SessionStore = ReturnType<LoadSessionStoreFn>;
|
||||
type TelegramBotRuntimeForTest = NonNullable<
|
||||
Parameters<typeof import("./bot.js").setTelegramBotRuntimeForTest>[0]
|
||||
@@ -49,34 +46,24 @@ vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
||||
loadWebMedia,
|
||||
}));
|
||||
|
||||
const {
|
||||
getRuntimeConfig,
|
||||
loadSessionStoreMock,
|
||||
readSessionUpdatedAtMock,
|
||||
recordInboundSessionMock,
|
||||
resolveStorePathMock,
|
||||
sessionStoreEntries,
|
||||
} = vi.hoisted(
|
||||
(): {
|
||||
getRuntimeConfig: MockFn<GetRuntimeConfigFn>;
|
||||
loadSessionStoreMock: MockFn<LoadSessionStoreFn>;
|
||||
readSessionUpdatedAtMock: MockFn<ReadSessionUpdatedAtFn>;
|
||||
recordInboundSessionMock: MockFn<NonNullable<TelegramBotDeps["recordInboundSession"]>>;
|
||||
resolveStorePathMock: MockFn<ResolveStorePathFn>;
|
||||
sessionStoreEntries: { value: SessionStore };
|
||||
} => ({
|
||||
getRuntimeConfig: vi.fn<GetRuntimeConfigFn>(() => ({})),
|
||||
loadSessionStoreMock: vi.fn<LoadSessionStoreFn>(
|
||||
(_storePath, _opts) => sessionStoreEntries.value,
|
||||
),
|
||||
resolveStorePathMock: vi.fn<ResolveStorePathFn>(
|
||||
(storePath?: string) => storePath ?? sessionStorePath,
|
||||
),
|
||||
readSessionUpdatedAtMock: vi.fn<ReadSessionUpdatedAtFn>(() => undefined),
|
||||
recordInboundSessionMock: vi.fn(async () => undefined),
|
||||
sessionStoreEntries: { value: {} as SessionStore },
|
||||
}),
|
||||
);
|
||||
const { getRuntimeConfig, loadSessionStoreMock, resolveStorePathMock, sessionStoreEntries } =
|
||||
vi.hoisted(
|
||||
(): {
|
||||
getRuntimeConfig: MockFn<GetRuntimeConfigFn>;
|
||||
loadSessionStoreMock: MockFn<LoadSessionStoreFn>;
|
||||
resolveStorePathMock: MockFn<ResolveStorePathFn>;
|
||||
sessionStoreEntries: { value: SessionStore };
|
||||
} => ({
|
||||
getRuntimeConfig: vi.fn<GetRuntimeConfigFn>(() => ({})),
|
||||
loadSessionStoreMock: vi.fn<LoadSessionStoreFn>(
|
||||
(_storePath, _opts) => sessionStoreEntries.value,
|
||||
),
|
||||
resolveStorePathMock: vi.fn<ResolveStorePathFn>(
|
||||
(storePath?: string) => storePath ?? sessionStorePath,
|
||||
),
|
||||
sessionStoreEntries: { value: {} as SessionStore },
|
||||
}),
|
||||
);
|
||||
|
||||
export function getLoadConfigMock(): AnyMock {
|
||||
return getRuntimeConfig;
|
||||
@@ -382,13 +369,6 @@ export const telegramBotDepsForTest: TelegramBotDeps = {
|
||||
getRuntimeConfig,
|
||||
loadSessionStore: loadSessionStoreMock as TelegramBotDeps["loadSessionStore"],
|
||||
resolveStorePath: resolveStorePathMock,
|
||||
readSessionUpdatedAt: readSessionUpdatedAtMock,
|
||||
recordInboundSession: recordInboundSessionMock as TelegramBotDeps["recordInboundSession"],
|
||||
recordChannelActivity: vi.fn() as TelegramBotDeps["recordChannelActivity"],
|
||||
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
|
||||
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
|
||||
resolvePinnedMainDmOwnerFromAllowlist: () => null,
|
||||
buildChannelTurnContext,
|
||||
readChannelAllowFromStore:
|
||||
readChannelAllowFromStore as TelegramBotDeps["readChannelAllowFromStore"],
|
||||
upsertChannelPairingRequest:
|
||||
@@ -486,10 +466,6 @@ beforeEach(() => {
|
||||
loadSessionStoreMock.mockImplementation(() => sessionStoreEntries.value);
|
||||
resolveStorePathMock.mockReset();
|
||||
resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath);
|
||||
readSessionUpdatedAtMock.mockReset();
|
||||
readSessionUpdatedAtMock.mockReturnValue(undefined);
|
||||
recordInboundSessionMock.mockReset();
|
||||
recordInboundSessionMock.mockResolvedValue(undefined);
|
||||
loadWebMedia.mockReset();
|
||||
readChannelAllowFromStore.mockReset();
|
||||
readChannelAllowFromStore.mockResolvedValue([]);
|
||||
|
||||
@@ -568,45 +568,6 @@ describe("resolveMedia getFile retry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("copies trusted local file paths whose names start with dots", async () => {
|
||||
const getFile = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/..photo.jpg" });
|
||||
rootRead.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("image-data"),
|
||||
realPath: "/var/lib/telegram-bot-api/..photo.jpg",
|
||||
stat: { size: 10 },
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/inbound/photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
const result = await resolveMediaWithDefaults(
|
||||
makeCtx("document", getFile, { file_name: "..photo.jpg", mime_type: "image/jpeg" }),
|
||||
{ trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] },
|
||||
);
|
||||
|
||||
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||
expect(rootRead).toHaveBeenCalledWith({
|
||||
rootDir: "/var/lib/telegram-bot-api",
|
||||
relativePath: "..photo.jpg",
|
||||
maxBytes: MAX_MEDIA_BYTES,
|
||||
});
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
Buffer.from("image-data"),
|
||||
"image/jpeg",
|
||||
"inbound",
|
||||
MAX_MEDIA_BYTES,
|
||||
"..photo.jpg",
|
||||
);
|
||||
expectResolvedMediaFields(result, "trusted local dot-prefixed document", {
|
||||
path: "/tmp/inbound/photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
placeholder: "<media:document>",
|
||||
});
|
||||
});
|
||||
|
||||
it("copies trusted local absolute file paths into inbound media storage for sticker downloads", async () => {
|
||||
const getFile = vi
|
||||
.fn()
|
||||
|
||||
@@ -177,12 +177,7 @@ function resolveTrustedLocalTelegramRoot(
|
||||
}
|
||||
for (const rootDir of trustedLocalFileRoots ?? []) {
|
||||
const relativePath = path.relative(rootDir, filePath);
|
||||
if (
|
||||
relativePath === "" ||
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
return { rootDir, relativePath };
|
||||
|
||||
@@ -1019,10 +1019,6 @@
|
||||
"types": "./dist/plugin-sdk/qa-runner-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/qa-runner-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/memory-core": {
|
||||
"types": "./dist/plugin-sdk/memory-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/memory-core.js"
|
||||
},
|
||||
"./plugin-sdk/memory-core-engine-runtime": {
|
||||
"types": "./dist/plugin-sdk/memory-core-engine-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/memory-core-engine-runtime.js"
|
||||
|
||||
@@ -27,19 +27,6 @@ function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function readTrackedJson(cwd, relativePath) {
|
||||
const filePath = path.join(cwd, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return readJson(filePath);
|
||||
}
|
||||
return JSON.parse(
|
||||
execFileSync("git", ["show", `:${relativePath}`], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedPinnedSpec(spec) {
|
||||
if (typeof spec !== "string") {
|
||||
return false;
|
||||
@@ -59,7 +46,7 @@ function isAllowedPinnedSpec(spec) {
|
||||
function collectPackageJsonViolations(cwd) {
|
||||
const violations = [];
|
||||
for (const relativePath of listTrackedPackageJsonFiles(cwd)) {
|
||||
const packageJson = readTrackedJson(cwd, relativePath);
|
||||
const packageJson = readJson(path.join(cwd, relativePath));
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
for (const [name, spec] of Object.entries(packageJson[section] ?? {})) {
|
||||
if (!isAllowedPinnedSpec(spec)) {
|
||||
@@ -109,7 +96,7 @@ export function collectDependencyPinAudit(cwd = process.cwd()) {
|
||||
const packageJsonFiles = listTrackedPackageJsonFiles(cwd);
|
||||
let packageSpecCount = 0;
|
||||
for (const relativePath of packageJsonFiles) {
|
||||
const packageJson = readTrackedJson(cwd, relativePath);
|
||||
const packageJson = readJson(path.join(cwd, relativePath));
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
packageSpecCount += Object.keys(packageJson[section] ?? {}).length;
|
||||
}
|
||||
|
||||
@@ -87,13 +87,6 @@ function writeWorkspacePnpmConfig(file, keptPatches) {
|
||||
lines[allowUnusedIndex] = "allowUnusedPatches: true";
|
||||
}
|
||||
|
||||
const minimumReleaseAgeIndex = lines.findIndex((line) => /^minimumReleaseAge:\s*/.test(line));
|
||||
if (minimumReleaseAgeIndex === -1) {
|
||||
lines.push("minimumReleaseAge: 0");
|
||||
} else {
|
||||
lines[minimumReleaseAgeIndex] = "minimumReleaseAge: 0";
|
||||
}
|
||||
|
||||
fs.writeFileSync(file, `${lines.join("\n")}${hadTrailingNewline ? "\n" : ""}`);
|
||||
}
|
||||
|
||||
@@ -135,7 +128,6 @@ function prepareGitFixture(root) {
|
||||
writeWorkspacePnpmConfig(pnpmWorkspacePath, keptPatches);
|
||||
} else {
|
||||
pnpmConfig.allowUnusedPatches = true;
|
||||
pnpmConfig.minimumReleaseAge = 0;
|
||||
if (Object.keys(keptPatches).length > 0) {
|
||||
pnpmConfig.patchedDependencies = keptPatches;
|
||||
} else {
|
||||
|
||||
@@ -112,8 +112,6 @@ export function hasProofOverride(labels) {
|
||||
}
|
||||
|
||||
export function extractRealBehaviorProofSection(body = "") {
|
||||
// Normalize CRLF → LF so regexes and section slicing see GitHub web-editor PR
|
||||
// bodies the same way as locally-authored Markdown.
|
||||
const normalizedBody = normalizeLineEndings(body);
|
||||
const headingRegex = /^#{2,6}\s+real behavior proof\b[^\n]*$/gim;
|
||||
const match = headingRegex.exec(normalizedBody);
|
||||
|
||||
@@ -13,8 +13,6 @@ const COMPAT_CONFIG_API_FILES = new Set([
|
||||
"src/plugin-sdk/config-runtime.ts",
|
||||
"src/plugin-sdk/memory-core-host-runtime-core.ts",
|
||||
"src/plugins/compat/registry.ts",
|
||||
"src/plugins/registry.runtime-config.test.ts",
|
||||
"src/plugins/registry.ts",
|
||||
"src/plugins/contracts/config-boundary-guard.test.ts",
|
||||
"src/plugins/contracts/deprecated-internal-config-api.test.ts",
|
||||
"src/plugins/registry.runtime-config.test.ts",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"media-generation-runtime-shared",
|
||||
"memory-core",
|
||||
"memory-core-engine-runtime",
|
||||
"memory-core-host-events",
|
||||
"memory-core-host-multimodal",
|
||||
|
||||
@@ -231,7 +231,6 @@
|
||||
"persistent-dedupe",
|
||||
"keyed-async-queue",
|
||||
"qa-runner-runtime",
|
||||
"memory-core",
|
||||
"memory-core-engine-runtime",
|
||||
"memory-core-host-engine-embeddings",
|
||||
"memory-core-host-engine-foundation",
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { isAcpSessionKey } from "../routing/session-key.js";
|
||||
|
||||
/**
|
||||
* Leaf type for agent runtime classification. Defined here so that
|
||||
* agent-runtime-metadata.ts can import applyAcpRuntimeOverlay without
|
||||
* creating a circular dependency (agent-runtime-metadata → acp-runtime-overlay
|
||||
* → agent-runtime-metadata). agent-runtime-metadata.ts re-exports this type
|
||||
* so all existing consumers remain unaffected.
|
||||
*/
|
||||
export type AgentRuntimeMetadata = {
|
||||
id: string;
|
||||
source: "implicit" | "model" | "provider" | "session-key";
|
||||
};
|
||||
|
||||
/**
|
||||
* When a session key and persisted session metadata identify an ACP
|
||||
* control-plane session, override the resolved runtime metadata to report the
|
||||
* ACP runtime id with a "session-key" source — regardless of what the
|
||||
* agent-config policy resolved to.
|
||||
*
|
||||
* Callers that already have model/provider context (resolveModelAgentRuntimeMetadata)
|
||||
* still benefit here because the model-runtime policy chain does not inspect session
|
||||
* keys for the ACP indicator.
|
||||
*
|
||||
* Key shape alone is not sufficient: ACP bridge sessions may use ACP-shaped
|
||||
* keys without persisted SessionAcpMeta and still run the configured model.
|
||||
*
|
||||
* When `acpBackend` is provided and non-empty, it is used as the runtime id so that
|
||||
* sessions backed by a configured non-default ACP backend (e.g. a custom registered
|
||||
* backend) are reported faithfully instead of always being labelled "acpx".
|
||||
* Falls back to "acpx" when no backend is known.
|
||||
*/
|
||||
export function applyAcpRuntimeOverlay(
|
||||
meta: AgentRuntimeMetadata,
|
||||
sessionKey: string | undefined | null,
|
||||
acpRuntime: boolean | undefined,
|
||||
acpBackend?: string,
|
||||
): AgentRuntimeMetadata {
|
||||
if (acpRuntime === true && isAcpSessionKey(sessionKey)) {
|
||||
const id = acpBackend && acpBackend.length > 0 ? acpBackend : "acpx";
|
||||
return { id, source: "session-key" };
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { applyAcpRuntimeOverlay, type AgentRuntimeMetadata } from "./acp-runtime-overlay.js";
|
||||
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
|
||||
import { resolveDefaultModelForAgent } from "./model-selection.js";
|
||||
|
||||
export type { AgentRuntimeMetadata };
|
||||
type AgentRuntimeMetadata = {
|
||||
id: string;
|
||||
source: "implicit" | "model" | "provider";
|
||||
};
|
||||
|
||||
export function resolveAgentRuntimeMetadata(
|
||||
_cfg: OpenClawConfig,
|
||||
@@ -22,19 +24,6 @@ export function resolveModelAgentRuntimeMetadata(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
sessionKey?: string;
|
||||
/**
|
||||
* True when the loaded session entry has persisted ACP metadata. ACP-shaped
|
||||
* keys without this marker can be bridge sessions that use the configured
|
||||
* model/runtime.
|
||||
*/
|
||||
acpRuntime?: boolean;
|
||||
/**
|
||||
* The ACP backend identifier stored on the session entry (`entry.acp.backend`).
|
||||
* When provided for an ACP-keyed session, the overlay reports this value as the
|
||||
* runtime id instead of the generic fallback "acpx", so sessions backed by a
|
||||
* non-default registered ACP backend are classified correctly.
|
||||
*/
|
||||
acpBackend?: string;
|
||||
}): AgentRuntimeMetadata {
|
||||
const resolved =
|
||||
params.provider && params.model
|
||||
@@ -47,9 +36,8 @@ export function resolveModelAgentRuntimeMetadata(params: {
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const meta: AgentRuntimeMetadata = {
|
||||
return {
|
||||
id: policy.runtime,
|
||||
source: policy.runtimeSource ?? "implicit",
|
||||
};
|
||||
return applyAcpRuntimeOverlay(meta, params.sessionKey, params.acpRuntime, params.acpBackend);
|
||||
}
|
||||
|
||||
@@ -364,20 +364,6 @@ describe("applyPatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps dot-dot-prefixed filenames inside cwd and reports relative paths", async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const patch = `*** Begin Patch
|
||||
*** Add File: ..note.txt
|
||||
+inside
|
||||
*** End Patch`;
|
||||
|
||||
const result = await applyPatch(patch, { cwd: dir });
|
||||
|
||||
expect(result.summary.added).toEqual(["..note.txt"]);
|
||||
await expect(fs.readFile(path.join(dir, "..note.txt"), "utf8")).resolves.toBe("inside\n");
|
||||
});
|
||||
});
|
||||
|
||||
it("allows deleting a symlink itself even if it points outside cwd", async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const outsideDir = await fs.mkdtemp(path.join(path.dirname(dir), "openclaw-patch-outside-"));
|
||||
|
||||
@@ -326,7 +326,7 @@ async function assertNoExistingParentAliases(params: { parentPath: string; rootP
|
||||
const rootPath = path.resolve(params.rootPath);
|
||||
const parentPath = path.resolve(params.parentPath);
|
||||
const relative = path.relative(rootPath, parentPath);
|
||||
if (!relative || relative === "" || relativePathEscapesRoot(relative)) {
|
||||
if (!relative || relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -410,21 +410,12 @@ function toDisplayPath(resolved: string, cwd: string): string {
|
||||
if (!relative || relative === "") {
|
||||
return path.basename(resolved);
|
||||
}
|
||||
if (relativePathEscapesRoot(relative)) {
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return resolved;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
function relativePathEscapesRoot(relativePath: string): boolean {
|
||||
return (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith("../") ||
|
||||
relativePath.startsWith("..\\") ||
|
||||
path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function parsePatchText(input: string): { hunks: Hunk[]; patch: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -103,23 +103,4 @@ describe("evaluateStoredCredentialEligibility", () => {
|
||||
});
|
||||
expect(result).toEqual({ eligible: false, reasonCode: "invalid_expires" });
|
||||
});
|
||||
|
||||
it("marks oauth with oauthRef as eligible", () => {
|
||||
const result = evaluateStoredCredentialEligibility({
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "",
|
||||
refresh: "",
|
||||
expires: now + 60_000,
|
||||
oauthRef: {
|
||||
source: "openclaw-credentials",
|
||||
provider: "openai-codex",
|
||||
id: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
},
|
||||
now,
|
||||
});
|
||||
expect(result).toEqual({ eligible: true, reasonCode: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { coerceSecretRef, normalizeSecretInputString } from "../../config/types.secrets.js";
|
||||
import type { AuthProfileCredential, OAuthCredential, OAuthCredentialRef } from "./types.js";
|
||||
import type { AuthProfileCredential, OAuthCredential } from "./types.js";
|
||||
|
||||
export type AuthCredentialReasonCode =
|
||||
| "ok"
|
||||
@@ -69,15 +69,6 @@ function hasConfiguredSecretString(value: unknown): boolean {
|
||||
return normalizeSecretInputString(value) !== undefined;
|
||||
}
|
||||
|
||||
function hasConfiguredOAuthRef(value: OAuthCredentialRef | undefined): boolean {
|
||||
return (
|
||||
value?.source === "openclaw-credentials" &&
|
||||
value.provider === "openai-codex" &&
|
||||
typeof value.id === "string" &&
|
||||
/^[a-f0-9]{32}$/.test(value.id)
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluateStoredCredentialEligibility(params: {
|
||||
credential: AuthProfileCredential;
|
||||
now?: number;
|
||||
@@ -113,8 +104,7 @@ export function evaluateStoredCredentialEligibility(params: {
|
||||
|
||||
if (
|
||||
normalizeSecretInputString(credential.access) === undefined &&
|
||||
normalizeSecretInputString(credential.refresh) === undefined &&
|
||||
!hasConfiguredOAuthRef(credential.oauthRef)
|
||||
normalizeSecretInputString(credential.refresh) === undefined
|
||||
) {
|
||||
return { eligible: false, reasonCode: "missing_credential" };
|
||||
}
|
||||
|
||||
@@ -12,15 +12,6 @@ export type OAuthRefreshFailureReason =
|
||||
const OAUTH_REFRESH_FAILURE_PROVIDER_RE = /OAuth token refresh failed for ([^:]+):/i;
|
||||
const SAFE_PROVIDER_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
function isOAuthRefreshFailureMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("oauth token refresh failed") ||
|
||||
lower.includes("access token could not be refreshed") ||
|
||||
lower.includes("authentication session could not be refreshed automatically")
|
||||
);
|
||||
}
|
||||
|
||||
function extractOAuthRefreshFailureProvider(message: string): string | null {
|
||||
const provider = message.match(OAUTH_REFRESH_FAILURE_PROVIDER_RE)?.[1]?.trim();
|
||||
return provider && provider.length > 0 ? provider : null;
|
||||
@@ -58,7 +49,7 @@ export function classifyOAuthRefreshFailure(message: string): {
|
||||
provider: string | null;
|
||||
reason: OAuthRefreshFailureReason | null;
|
||||
} | null {
|
||||
if (!isOAuthRefreshFailureMessage(message)) {
|
||||
if (!/oauth token refresh failed/i.test(message)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -276,33 +276,6 @@ describe("resolveAuthProfileOrder", () => {
|
||||
expect(order).toEqual(["openai-codex:personal", "openai:backup"]);
|
||||
});
|
||||
|
||||
it("lets Codex auth discover oauthRef-backed OAuth profiles", async () => {
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:personal": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "",
|
||||
refresh: "",
|
||||
expires: Date.now() + 60_000,
|
||||
oauthRef: {
|
||||
source: "openclaw-credentials",
|
||||
provider: "openai-codex",
|
||||
id: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const order = resolveAuthProfileOrder({
|
||||
store,
|
||||
provider: "openai-codex",
|
||||
});
|
||||
|
||||
expect(order).toEqual(["openai-codex:personal"]);
|
||||
});
|
||||
|
||||
it("preserves native Codex profiles before OpenAI alias API-key order", async () => {
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
|
||||
@@ -67,7 +67,7 @@ function createBackendEntry(params: {
|
||||
params.id === "claude-cli"
|
||||
? "@anthropic-ai/claude-code"
|
||||
: params.id === "codex-cli"
|
||||
? "@openai/codex@0.130.0"
|
||||
? "@openai/codex@0.129.0"
|
||||
: params.id === "google-gemini-cli"
|
||||
? "@google/gemini-cli"
|
||||
: undefined,
|
||||
@@ -498,7 +498,7 @@ describe("resolveCliBackendLiveTest", () => {
|
||||
defaultModelRef: "codex-cli/gpt-5.5",
|
||||
defaultImageProbe: true,
|
||||
defaultMcpProbe: true,
|
||||
dockerNpmPackage: "@openai/codex@0.130.0",
|
||||
dockerNpmPackage: "@openai/codex@0.129.0",
|
||||
dockerBinaryName: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
decodeHeaderEnvPlaceholder,
|
||||
normalizeStringRecord,
|
||||
} from "./bundle-mcp-adapter-shared.js";
|
||||
import { buildCodexMcpServersConfig } from "../codex-mcp-config.js";
|
||||
import { serializeTomlInlineValue } from "./toml-inline.js";
|
||||
|
||||
// Mutable JSON shape structurally compatible with the bundled Codex
|
||||
@@ -71,7 +70,14 @@ export function injectCodexMcpConfigArgs(
|
||||
args: string[] | undefined,
|
||||
config: BundleMcpConfig,
|
||||
): string[] {
|
||||
const overrides = serializeTomlInlineValue(buildCodexMcpServersConfig(config));
|
||||
const overrides = serializeTomlInlineValue(
|
||||
Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([name, server]) => [
|
||||
name,
|
||||
normalizeCodexServerConfig(name, server),
|
||||
]),
|
||||
),
|
||||
);
|
||||
return [...(args ?? []), "-c", `mcp_servers=${overrides}`];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildCodexMcpServersConfig, loadCodexBundleMcpThreadConfig } from "./codex-mcp-config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
bundleMcp: {
|
||||
config: {
|
||||
mcpServers: {},
|
||||
},
|
||||
diagnostics: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/bundle-mcp.js", () => ({
|
||||
loadEnabledBundleMcpConfig: () => mocks.bundleMcp,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.bundleMcp = {
|
||||
config: {
|
||||
mcpServers: {},
|
||||
},
|
||||
diagnostics: [],
|
||||
};
|
||||
});
|
||||
|
||||
describe("buildCodexMcpServersConfig", () => {
|
||||
it("normalizes OpenClaw MCP servers into Codex app-server mcp_servers shape", () => {
|
||||
expect(
|
||||
buildCodexMcpServersConfig({
|
||||
mcpServers: {
|
||||
openclaw: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:23119/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
|
||||
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
|
||||
"x-static": "static-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
openclaw: {
|
||||
url: "http://127.0.0.1:23119/mcp",
|
||||
default_tools_approval_mode: "approve",
|
||||
bearer_token_env_var: "OPENCLAW_MCP_TOKEN",
|
||||
http_headers: {
|
||||
"x-static": "static-value",
|
||||
},
|
||||
env_http_headers: {
|
||||
"x-session-key": "OPENCLAW_MCP_SESSION_KEY",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadCodexBundleMcpThreadConfig", () => {
|
||||
it("loads enabled bundled MCP servers as a Codex thread config patch", () => {
|
||||
mocks.bundleMcp = {
|
||||
config: {
|
||||
mcpServers: {
|
||||
search: {
|
||||
type: "http",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const loaded = loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: "/workspace",
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"bundle-probe": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(loaded.configPatch).toEqual({
|
||||
mcp_servers: {
|
||||
search: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(loaded.fingerprint).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("leaves user mcp.servers to the Codex user MCP projection path", () => {
|
||||
const loaded = loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: "/workspace",
|
||||
cfg: {
|
||||
mcp: {
|
||||
servers: {
|
||||
search: {
|
||||
transport: "streamable-http",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toolsEnabled: true,
|
||||
});
|
||||
|
||||
expect(loaded.configPatch).toBeUndefined();
|
||||
expect(loaded.fingerprint).toBeUndefined();
|
||||
expect(loaded.evaluated).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an evaluated empty MCP config when Pi would not create a bundle MCP runtime", () => {
|
||||
const cfg = {
|
||||
mcp: {
|
||||
servers: {
|
||||
search: {
|
||||
transport: "streamable-http",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
for (const params of [
|
||||
{ toolsEnabled: false },
|
||||
{ toolsEnabled: true, disableTools: true },
|
||||
{ toolsEnabled: true, toolsAllow: [] },
|
||||
{ toolsEnabled: true, toolsAllow: ["memory_search"] },
|
||||
]) {
|
||||
const loaded = loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: "/workspace",
|
||||
cfg,
|
||||
...params,
|
||||
});
|
||||
|
||||
expect(loaded.configPatch).toBeUndefined();
|
||||
expect(loaded.fingerprint).toBeUndefined();
|
||||
expect(loaded.evaluated).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("omits the config patch when no MCP servers are configured", () => {
|
||||
const loaded = loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: "/workspace",
|
||||
cfg: {},
|
||||
toolsEnabled: true,
|
||||
});
|
||||
|
||||
expect(loaded.configPatch).toBeUndefined();
|
||||
expect(loaded.fingerprint).toBeUndefined();
|
||||
expect(loaded.evaluated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
loadEnabledBundleMcpConfig,
|
||||
type BundleMcpConfig,
|
||||
type BundleMcpServerConfig,
|
||||
} from "../plugins/bundle-mcp.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
applyCommonServerConfig,
|
||||
decodeHeaderEnvPlaceholder,
|
||||
normalizeStringRecord,
|
||||
} from "./cli-runner/bundle-mcp-adapter-shared.js";
|
||||
import type {
|
||||
CodexBundleMcpThreadConfig,
|
||||
CodexMcpServersConfig,
|
||||
LoadCodexBundleMcpThreadConfigParams,
|
||||
} from "./codex-mcp-config.types.js";
|
||||
import { shouldCreateBundleMcpRuntimeForAttempt } from "./pi-embedded-runner/run/attempt-tool-construction-plan.js";
|
||||
|
||||
export type {
|
||||
CodexBundleMcpThreadConfig,
|
||||
CodexMcpServersConfig,
|
||||
LoadCodexBundleMcpThreadConfigParams,
|
||||
} from "./codex-mcp-config.types.js";
|
||||
|
||||
function isOpenClawLoopbackMcpServer(name: string, server: BundleMcpServerConfig): boolean {
|
||||
return (
|
||||
name === "openclaw" &&
|
||||
typeof server.url === "string" &&
|
||||
/^https?:\/\/(?:127\.0\.0\.1|localhost):\d+\/mcp(?:[?#].*)?$/.test(server.url)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCodexMcpServerConfig(
|
||||
name: string,
|
||||
server: BundleMcpServerConfig,
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = {};
|
||||
applyCommonServerConfig(next, server);
|
||||
if (isOpenClawLoopbackMcpServer(name, server)) {
|
||||
next.default_tools_approval_mode = "approve";
|
||||
}
|
||||
const httpHeaders = normalizeStringRecord(server.headers);
|
||||
if (httpHeaders) {
|
||||
const staticHeaders: Record<string, string> = {};
|
||||
const envHeaders: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(httpHeaders)) {
|
||||
const decoded = decodeHeaderEnvPlaceholder(value);
|
||||
if (!decoded) {
|
||||
staticHeaders[name] = value;
|
||||
continue;
|
||||
}
|
||||
if (decoded.bearer && normalizeOptionalLowercaseString(name) === "authorization") {
|
||||
next.bearer_token_env_var = decoded.envVar;
|
||||
continue;
|
||||
}
|
||||
envHeaders[name] = decoded.envVar;
|
||||
}
|
||||
if (Object.keys(staticHeaders).length > 0) {
|
||||
next.http_headers = staticHeaders;
|
||||
}
|
||||
if (Object.keys(envHeaders).length > 0) {
|
||||
next.env_http_headers = envHeaders;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildCodexMcpServersConfig(config: BundleMcpConfig): CodexMcpServersConfig {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([name, server]) => [
|
||||
name,
|
||||
normalizeCodexMcpServerConfig(name, server),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function stableJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableJsonValue);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, child]) => [key, stableJsonValue(child)]),
|
||||
);
|
||||
}
|
||||
|
||||
function fingerprintCodexMcpServersConfig(config: CodexMcpServersConfig): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(JSON.stringify(stableJsonValue(config)))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function loadCodexBundleMcpThreadConfig(
|
||||
params: LoadCodexBundleMcpThreadConfigParams,
|
||||
): CodexBundleMcpThreadConfig {
|
||||
const shouldCreateRuntime = shouldCreateBundleMcpRuntimeForAttempt({
|
||||
toolsEnabled: params.toolsEnabled ?? true,
|
||||
disableTools: params.disableTools,
|
||||
toolsAllow: params.toolsAllow,
|
||||
});
|
||||
if (!shouldCreateRuntime) {
|
||||
return {
|
||||
diagnostics: [],
|
||||
evaluated: true,
|
||||
};
|
||||
}
|
||||
const bundleMcp = loadEnabledBundleMcpConfig({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
const mcpServers = buildCodexMcpServersConfig(bundleMcp.config);
|
||||
if (Object.keys(mcpServers).length === 0) {
|
||||
return {
|
||||
diagnostics: bundleMcp.diagnostics,
|
||||
evaluated: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
configPatch: {
|
||||
mcp_servers: mcpServers,
|
||||
},
|
||||
diagnostics: bundleMcp.diagnostics,
|
||||
evaluated: true,
|
||||
fingerprint: fingerprintCodexMcpServersConfig(mcpServers),
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { BundleMcpDiagnostic } from "../plugins/bundle-mcp.js";
|
||||
|
||||
export type CodexMcpServersConfig = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type CodexBundleMcpThreadConfig = {
|
||||
configPatch?: {
|
||||
mcp_servers: CodexMcpServersConfig;
|
||||
};
|
||||
diagnostics: BundleMcpDiagnostic[];
|
||||
evaluated: boolean;
|
||||
fingerprint?: string;
|
||||
};
|
||||
|
||||
export type LoadCodexBundleMcpThreadConfigParams = {
|
||||
workspaceDir: string;
|
||||
cfg?: OpenClawConfig;
|
||||
toolsEnabled?: boolean;
|
||||
disableTools?: boolean;
|
||||
toolsAllow?: string[];
|
||||
};
|
||||
@@ -3,86 +3,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { collectConfiguredAgentHarnessRuntimes } from "./harness-runtimes.js";
|
||||
|
||||
describe("collectConfiguredAgentHarnessRuntimes", () => {
|
||||
it("requires Codex for selectable default OpenAI agent models", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
||||
models: {
|
||||
"openai/gpt-5.5": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual(
|
||||
["codex"],
|
||||
);
|
||||
});
|
||||
|
||||
it("requires Codex for selectable per-agent OpenAI models", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "worker",
|
||||
models: {
|
||||
"openai/gpt-5.5": {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual(
|
||||
["codex"],
|
||||
);
|
||||
});
|
||||
|
||||
it("respects explicit Pi runtime policy on selectable OpenAI agent models", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "pi" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not infer Codex for custom OpenAI-compatible base URLs", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://openai-compatible.example.test/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.5": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores malformed agents.list while scanning best-effort config", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
|
||||
import { resolveModelRuntimePolicy } from "./model-runtime-policy.js";
|
||||
import { modelSelectionShouldEnsureCodexPlugin } from "./openai-codex-routing.js";
|
||||
import { normalizeEmbeddedAgentRuntime } from "./pi-embedded-runner/runtime.js";
|
||||
import { normalizeProviderId } from "./provider-id.js";
|
||||
|
||||
@@ -37,12 +38,6 @@ function listAgentModelRefs(value: unknown): string[] {
|
||||
return refs;
|
||||
}
|
||||
|
||||
function pushAgentModelRefs(refs: string[], value: unknown): void {
|
||||
for (const ref of listAgentModelRefs(value)) {
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfiguredModelRef(
|
||||
value: unknown,
|
||||
): { provider: string; modelId: string } | undefined {
|
||||
@@ -60,23 +55,21 @@ function parseConfiguredModelRef(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredModelHarnessRuntime(params: {
|
||||
config: OpenClawConfig;
|
||||
modelRef: string;
|
||||
agentId?: string;
|
||||
}): string | undefined {
|
||||
const parsed = parseConfiguredModelRef(params.modelRef);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const policy = resolveAgentHarnessPolicy({
|
||||
config: params.config,
|
||||
provider: parsed.provider,
|
||||
modelId: parsed.modelId,
|
||||
agentId: params.agentId,
|
||||
function hasOpenAIModelRef(config: OpenClawConfig, value: unknown, agentId?: string): boolean {
|
||||
return listAgentModelRefs(value).some((ref) => {
|
||||
if (!modelSelectionShouldEnsureCodexPlugin({ model: ref, config })) {
|
||||
return false;
|
||||
}
|
||||
const parsed = parseConfiguredModelRef(ref);
|
||||
const policy = resolveModelRuntimePolicy({
|
||||
config,
|
||||
provider: parsed?.provider,
|
||||
modelId: parsed?.modelId,
|
||||
agentId,
|
||||
});
|
||||
const runtime = normalizeRuntimeId(policy.policy?.id);
|
||||
return !runtime || runtime === "auto" || runtime === "codex";
|
||||
});
|
||||
const runtime = normalizeRuntimeId(policy.runtime);
|
||||
return runtime && runtime !== "auto" && runtime !== "pi" ? runtime : undefined;
|
||||
}
|
||||
|
||||
function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set<string>): void {
|
||||
@@ -111,44 +104,7 @@ function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set<str
|
||||
pushModelMapRuntimeIds(config.agents?.defaults?.models);
|
||||
const agents = Array.isArray(config.agents?.list) ? config.agents.list : [];
|
||||
for (const agent of agents) {
|
||||
pushModelMapRuntimeIds(isRecord(agent) ? agent.models : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function pushConfiguredAgentModelRuntimeIds(config: OpenClawConfig, runtimes: Set<string>): void {
|
||||
const pushModelRefs = (modelRefs: string[], agentId?: string) => {
|
||||
for (const modelRef of modelRefs) {
|
||||
const runtime = resolveConfiguredModelHarnessRuntime({ config, modelRef, agentId });
|
||||
if (runtime) {
|
||||
runtimes.add(runtime);
|
||||
}
|
||||
}
|
||||
};
|
||||
const pushModelMapRefs = (models: unknown, agentId?: string) => {
|
||||
if (!isRecord(models)) {
|
||||
return;
|
||||
}
|
||||
pushModelRefs(Object.keys(models), agentId);
|
||||
};
|
||||
|
||||
const defaultsModel = config.agents?.defaults?.model;
|
||||
const defaultsModelRefs: string[] = [];
|
||||
pushAgentModelRefs(defaultsModelRefs, defaultsModel);
|
||||
pushModelRefs(defaultsModelRefs);
|
||||
pushModelMapRefs(config.agents?.defaults?.models);
|
||||
|
||||
if (!Array.isArray(config.agents?.list)) {
|
||||
return;
|
||||
}
|
||||
for (const agent of config.agents.list) {
|
||||
if (!isRecord(agent)) {
|
||||
continue;
|
||||
}
|
||||
const agentId = typeof agent.id === "string" ? agent.id : undefined;
|
||||
const selectedModelRefs: string[] = [];
|
||||
pushAgentModelRefs(selectedModelRefs, agent.model ?? defaultsModel);
|
||||
pushModelRefs(selectedModelRefs, agentId);
|
||||
pushModelMapRefs(agent.models, agentId);
|
||||
pushModelMapRuntimeIds(agent.models);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +136,11 @@ export function collectConfiguredAgentHarnessRuntimes(
|
||||
const runtimes = new Set<string>();
|
||||
const includeEnvRuntime = options.includeEnvRuntime ?? true;
|
||||
const includeLegacyAgentRuntimes = options.includeLegacyAgentRuntimes ?? true;
|
||||
const pushCodexForOpenAIModel = (model: unknown, agentId?: string) => {
|
||||
if (hasOpenAIModelRef(config, model, agentId)) {
|
||||
runtimes.add("codex");
|
||||
}
|
||||
};
|
||||
|
||||
if (includeEnvRuntime) {
|
||||
const envRuntime = normalizeRuntimeId(env.OPENCLAW_AGENT_RUNTIME);
|
||||
@@ -191,7 +152,19 @@ export function collectConfiguredAgentHarnessRuntimes(
|
||||
if (includeLegacyAgentRuntimes) {
|
||||
pushLegacyAgentRuntimeIds(config, runtimes);
|
||||
}
|
||||
pushConfiguredAgentModelRuntimeIds(config, runtimes);
|
||||
const defaultsModel = config.agents?.defaults?.model;
|
||||
pushCodexForOpenAIModel(defaultsModel);
|
||||
if (Array.isArray(config.agents?.list)) {
|
||||
for (const agent of config.agents.list) {
|
||||
if (!isRecord(agent)) {
|
||||
continue;
|
||||
}
|
||||
pushCodexForOpenAIModel(
|
||||
agent.model ?? defaultsModel,
|
||||
typeof agent.id === "string" ? agent.id : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...runtimes].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
@@ -36,17 +36,3 @@ describe("toRelativeWorkspacePath (windows semantics)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toRelativeWorkspacePath", () => {
|
||||
it("accepts dot-dot-prefixed filenames inside the workspace", () => {
|
||||
expect(toRelativeWorkspacePath("/workspace/root", "/workspace/root/..file.txt")).toBe(
|
||||
"..file.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects parent directory traversal outside the workspace", () => {
|
||||
expect(() => toRelativeWorkspacePath("/workspace/root", "/workspace/root/../file.txt")).toThrow(
|
||||
"Path escapes workspace root",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,12 +36,7 @@ function validateRelativePathWithinBoundary(params: {
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
if (
|
||||
params.relativePath === ".." ||
|
||||
params.relativePath.startsWith("../") ||
|
||||
params.relativePath.startsWith("..\\") ||
|
||||
params.isAbsolutePath(params.relativePath)
|
||||
) {
|
||||
if (params.relativePath.startsWith("..") || params.isAbsolutePath(params.relativePath)) {
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved: params.rootResolved,
|
||||
|
||||
@@ -281,15 +281,6 @@ describe("formatAssistantErrorText", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an explicit re-authentication message for Codex app-server refresh failures", () => {
|
||||
const msg = makeAssistantError(
|
||||
"Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
|
||||
);
|
||||
expect(formatAssistantErrorText(msg)).toBe(
|
||||
"Authentication refresh failed. Re-authenticate this provider and try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a contention-specific message for OAuth refresh lock timeouts", () => {
|
||||
const msg = makeAssistantError("file lock timeout for /tmp/openclaw-oauth-refresh.lock");
|
||||
expect(formatAssistantErrorText(msg)).toBe(
|
||||
|
||||
@@ -1442,34 +1442,11 @@ describe("classifyProviderRuntimeFailureKind", () => {
|
||||
});
|
||||
|
||||
it("classifies OAuth refresh failures", () => {
|
||||
const refreshFailures = [
|
||||
"OAuth token refresh failed for openai-codex: invalid_grant. Please try again or re-authenticate.",
|
||||
"Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
|
||||
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
];
|
||||
for (const message of refreshFailures) {
|
||||
expect(classifyProviderRuntimeFailureKind(message)).toBe("auth_refresh");
|
||||
expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("auth_permanent");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not make uncertain OAuth refresh wrappers terminal", () => {
|
||||
const message =
|
||||
"OAuth token refresh failed for openai-codex: file lock timeout for /tmp/agent/auth-profiles.json. Please try again or re-authenticate.";
|
||||
expect(classifyProviderRuntimeFailureKind(message)).toBe("auth_refresh");
|
||||
expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("auth");
|
||||
});
|
||||
|
||||
it("keeps Codex entitlement and usage-limit payloads out of terminal auth", () => {
|
||||
const entitlementMessages = [
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), try again after 11:34 AM.",
|
||||
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits, try again later.",
|
||||
'429 {"type":"error","error":{"type":"rate_limit_error","message":"You\\u0027ve hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), try again after 11:34 AM."}}',
|
||||
];
|
||||
for (const message of entitlementMessages) {
|
||||
expect(classifyProviderRuntimeFailureKind(message)).not.toBe("auth_refresh");
|
||||
expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("rate_limit");
|
||||
}
|
||||
expect(
|
||||
classifyProviderRuntimeFailureKind(
|
||||
"OAuth token refresh failed for openai-codex: invalid_grant. Please try again or re-authenticate.",
|
||||
),
|
||||
).toBe("auth_refresh");
|
||||
});
|
||||
|
||||
it("classifies OAuth refresh timeouts and lock contention distinctly", () => {
|
||||
|
||||
@@ -854,10 +854,6 @@ function classifyFailoverClassificationFromMessage(
|
||||
if (isBillingErrorMessage(raw)) {
|
||||
return toReasonClassification("billing");
|
||||
}
|
||||
const oauthRefreshFailure = classifyOAuthRefreshFailure(raw);
|
||||
if (oauthRefreshFailure?.reason) {
|
||||
return toReasonClassification("auth_permanent");
|
||||
}
|
||||
if (isAuthPermanentErrorMessage(raw)) {
|
||||
return toReasonClassification("auth_permanent");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
|
||||
|
||||
export type MessagingToolSend = {
|
||||
tool: string;
|
||||
provider: string;
|
||||
@@ -9,16 +7,3 @@ export type MessagingToolSend = {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
export type MessagingToolSourceReplyPayload = Pick<
|
||||
ReplyPayload,
|
||||
| "audioAsVoice"
|
||||
| "channelData"
|
||||
| "interactive"
|
||||
| "mediaUrl"
|
||||
| "mediaUrls"
|
||||
| "presentation"
|
||||
| "text"
|
||||
> & {
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
@@ -226,9 +226,6 @@ function normalizeEmbeddedRunAttemptResult(
|
||||
messagingToolSentTexts?: EmbeddedRunAttemptForRunner["messagingToolSentTexts"] | null;
|
||||
messagingToolSentMediaUrls?: EmbeddedRunAttemptForRunner["messagingToolSentMediaUrls"] | null;
|
||||
messagingToolSentTargets?: EmbeddedRunAttemptForRunner["messagingToolSentTargets"] | null;
|
||||
messagingToolSourceReplyPayloads?:
|
||||
| EmbeddedRunAttemptForRunner["messagingToolSourceReplyPayloads"]
|
||||
| null;
|
||||
itemLifecycle?: EmbeddedRunAttemptForRunner["itemLifecycle"] | null;
|
||||
};
|
||||
return {
|
||||
@@ -239,7 +236,6 @@ function normalizeEmbeddedRunAttemptResult(
|
||||
messagingToolSentTexts: raw.messagingToolSentTexts ?? [],
|
||||
messagingToolSentMediaUrls: raw.messagingToolSentMediaUrls ?? [],
|
||||
messagingToolSentTargets: raw.messagingToolSentTargets ?? [],
|
||||
messagingToolSourceReplyPayloads: raw.messagingToolSourceReplyPayloads ?? [],
|
||||
itemLifecycle: raw.itemLifecycle ?? {
|
||||
startedCount: 0,
|
||||
completedCount: 0,
|
||||
@@ -2499,11 +2495,6 @@ export async function runEmbeddedPiAgent(
|
||||
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
|
||||
inlineToolResultsAllowed: false,
|
||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
agentId: params.agentId,
|
||||
runId: params.runId,
|
||||
runAborted: aborted,
|
||||
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
||||
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||
});
|
||||
@@ -2580,7 +2571,6 @@ export async function runEmbeddedPiAgent(
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
|
||||
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||
successfulCronAdds: attempt.successfulCronAdds,
|
||||
};
|
||||
@@ -2798,7 +2788,6 @@ export async function runEmbeddedPiAgent(
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
|
||||
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||
successfulCronAdds: attempt.successfulCronAdds,
|
||||
};
|
||||
@@ -2850,7 +2839,6 @@ export async function runEmbeddedPiAgent(
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
|
||||
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||
successfulCronAdds: attempt.successfulCronAdds,
|
||||
};
|
||||
@@ -2961,7 +2949,6 @@ export async function runEmbeddedPiAgent(
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
|
||||
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||
successfulCronAdds: attempt.successfulCronAdds,
|
||||
};
|
||||
@@ -3077,7 +3064,6 @@ export async function runEmbeddedPiAgent(
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
|
||||
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||
successfulCronAdds: attempt.successfulCronAdds,
|
||||
};
|
||||
|
||||
@@ -622,10 +622,6 @@ describe("remapInjectedContextFilesToWorkspace", () => {
|
||||
path: "/real/workspace/nested/TOOLS.md",
|
||||
content: "tools",
|
||||
},
|
||||
{
|
||||
path: "/real/workspace/..context/USER.md",
|
||||
content: "dot-prefixed context",
|
||||
},
|
||||
{
|
||||
path: "/outside/README.md",
|
||||
content: "outside",
|
||||
@@ -643,10 +639,6 @@ describe("remapInjectedContextFilesToWorkspace", () => {
|
||||
path: "/sandbox/workspace/nested/TOOLS.md",
|
||||
content: "tools",
|
||||
},
|
||||
{
|
||||
path: "/sandbox/workspace/..context/USER.md",
|
||||
content: "dot-prefixed context",
|
||||
},
|
||||
{
|
||||
path: "/outside/README.md",
|
||||
content: "outside",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user