Compare commits

...
Author SHA1 Message Date
Ryder Sun cc5f32dbf8 Show DeepSeek default provider in native settings
Reflect the runtime default while leaving explicit native overrides unclassified. Verified the main IPC test, frontend tests and both frontend builds.
2026-11-08 00:51:36 +08:00
Ryder Sun 34593a6c98 Track captured native credential files for readiness
Invalidate project verification when absolute native credential or certificate files change. Verified with the main security and agent catalog test suites.
2026-11-08 00:48:40 +08:00
Ryder Sun 5de5f88b0b Track project-relative native credentials during verification
Include relative configuration and certificate files from the captured native environment so changed credentials invalidate cached readiness. Verified with the main security and agent catalog test suites.
2026-11-08 00:47:11 +08:00
Ryder Sun 655b94b85d Track relative project credentials for Agent verification
Invalidate stale Hermes verification when a relative native credential file changes. Checked with node --test desktop/test/workspace/local-workspace-agent-catalog.test.cjs.
2026-11-08 00:27:49 +08:00
Ryder Sun 9d0d89dd12 Recognize first-person reviewer rejections
Do not accept a contradictory completion receipt when the reviewer explicitly says they cannot approve. Verified task graph and focused V4 tests plus git diff --check.
2026-11-08 00:07:49 +08:00
Ryder Sun 69553895f7 Reject explicit objections in legacy task graph reviews
Recheck reviewer objections during execution and recovery without rewriting historical records. Verified task graph and focused V4 review tests plus git diff --check.
2026-11-08 00:03:55 +08:00
Ryder Sun 4dd5ff9058 Reject contradictory explicit review objections
Withdraw a candidate when a reviewer explicitly says not to approve despite a completed receipt. Verified V4 natural discussion tests and git diff --check.
2026-11-08 00:01:18 +08:00
Ryder Sun e031f0945d Show native Agent configuration file checks
Report recognized and absent configuration alternatives for selected native directories. Verified desktop security tests, frontend tests, both frontend builds, and git diff --check.
2026-11-07 23:57:09 +08:00
Ryder Sun fc041c6887 Keep latest result across overlapping Agent probes
Prevent an older detection from inheriting an intermediate result after a third probe completes. Verified CLI discovery tests and git diff --check.
2026-11-07 23:54:19 +08:00
Ryder Sun 3c5e9f294f Share the latest matching concurrent probe result
Avoid false unavailable states while preserving protection against stale failures and changed executable identities. Verified CLI discovery tests and git diff --check.
2026-11-07 23:53:14 +08:00
Ryder Sun c18a553e9b Reject superseded Agent capability results
Prevent older concurrent probe responses from reporting stale compatibility to callers. Verified CLI discovery tests and git diff --check.
2026-11-07 23:51:11 +08:00
Ryder Sun cacd255182 Cover late failed Agent capability probes
Verify an older failed probe cannot erase a newer successful cache. Verified CLI discovery tests and git diff --check.
2026-11-07 23:49:31 +08:00
Ryder Sun a535e5942a Prevent stale concurrent capability cache updates
Keep older probe completions from replacing the result of a newer detection. Verified CLI discovery tests and git diff --check.
2026-11-07 23:48:42 +08:00
Ryder Sun a624f1dc29 Invalidate stale capability cache after probe failures
Do not reuse earlier compatibility after a spawn or authentication failure followed by timeout. Verified CLI discovery tests and git diff --check.
2026-11-07 23:46:01 +08:00
Ryder Sun 94cf73d7d6 Explain Agent detection startup failures
Distinguish probe startup errors from unsupported CLI features in both languages. Verified frontend tests, production and desktop builds, and git diff --check.
2026-11-07 23:44:14 +08:00
Ryder Sun 47e33865ef Preserve safe inconclusive Agent probe reasons
Keep spawn failure and authentication-required states in the installer catalog without exposing arbitrary error text. Verified installer tests and git diff --check.
2026-11-07 23:43:08 +08:00
Ryder Sun 65937244c4 Keep system probe failures distinct from incompatibility
Treat CLI spawn errors as inconclusive rather than proof of missing capabilities. Verified the CLI discovery suite and git diff --check.
2026-11-07 23:42:14 +08:00
Ryder Sun 250cef7619 Continue discovery past unidentified CLI candidates
Prefer an identified compatible installation so an unusable earlier candidate does not hide it. Verified the CLI discovery suite and git diff --check.
2026-11-07 23:41:13 +08:00
Ryder Sun 785759ee97 Reject ambiguous alternative Agent versions
Avoid routing based on the first version in a CLI output that lists alternatives. Verified agent compatibility and CLI discovery tests, plus git diff --check.
2026-11-07 23:40:06 +08:00
Ryder Sun ec995a0983 Invalidate group approval when new workspace artifacts appear
Prevent a review of an earlier candidate from approving a newly produced file. Verified with the V4 natural discussion test suite (85 passed) and git diff --check.
2026-11-07 23:23:51 +08:00
Ryder Sun 6e6aac4803 Document task graph review limitations
Clarify V4 support and the verified legacy reviewer acceptance risk. Checked git diff --check.
2026-11-07 23:14:41 +08:00
Ryder Sun 874037326f Reject newly unidentified or incompatible agents during runs
Revalidate explicit version and compatibility failures before invoking an active run or reusing its snapshot. Checked workspace and catalog tests and desktop pack.
2026-11-07 22:56:14 +08:00
Ryder Sun 9a76e429e0 Include native authentication files in probe cache identity
Invalidate cached capability evidence when separately stored authentication or network trust files change. Checks: 213 discovery and readiness tests; real DSH detection; desktop pack.
2026-11-07 22:53:03 +08:00
Ryder Sun be1bd12b0d Bind capability probe cache to native configuration
Invalidate stale capability evidence when native config files change before an inconclusive probe. Checks: 212 discovery, configuration and readiness tests; real DSH detection; desktop pack.
2026-11-07 22:51:29 +08:00
Ryder Sun 28d3bd0523 Expire capability cache after clock rollback
Treat timed-out probes as unverified when the cached check is in the future. Checks: 169 discovery, compatibility and catalog tests.
2026-11-07 22:49:31 +08:00
Ryder Sun 67b0f5db06 Bound native configuration alias traversal
Prevent cyclic or highly shared YAML configuration from stalling credential redaction or DeepSeek readiness checks. Checks: 164 agent tests; real DSH direct/group calls; desktop pack.
2026-11-07 22:48:08 +08:00
Ryder Sun e7c228cf43 Invalidate DeepSeek verification after dotenv changes
Bind automatic routing verification to native and project dotenv files without invalidating unrelated projects. Checks: 157 configuration, catalog, routing and ACP tests; desktop pack.
2026-11-07 22:46:21 +08:00
Ryder Sun 3c8b877347 Track DeepSeek dotenv configuration in ACP sessions
Redact native and project dotenv credentials and recreate ACP runtimes after configuration changes. Checks: 56 CLI and workspace tests, real DSH direct/group calls, desktop pack.
2026-11-07 22:44:41 +08:00
Ryder Sun dab2768e7b Redact DeepSeek native stored credentials from output
Include native DSH credential references and API key records in runtime output redaction without changing CLI configuration. Checks: 54 CLI and workspace tests; real DSH direct/group calls; desktop pack.
2026-11-07 22:40:57 +08:00
Ryder Sun 1a2c497979 Recognize DeepSeek native default-route credentials
Report configured native ZGCI credentials without treating them as verified authentication; retain unknown status for unvalidated configurations. Checks: 202 readiness and ACP/workspace tests; desktop pack.
2026-11-07 22:38:33 +08:00
Ryder Sun 2cc146a2a4 Resolve Hermes dotenv references in provider summaries
Share native dotenv expansion between provider display and credential redaction. Checks: 88 Hermes configuration and ACP tests; desktop pack.
2026-11-07 22:22:37 +08:00
Ryder Sun 953ba18bfa Preserve management of legacy Connector packages
Reject unsafe endpoint paths for new packages without blocking the package store from reading and disabling older packages. Checks: 231 Connector conformance tests.
2026-11-07 22:10:26 +08:00
Ryder Sun 72ff8c273b Reject recognizable credentials in Connector endpoint paths
Prevent HTTP package paths from persisting recognizable plaintext or percent-encoded credentials. Checks: 79 Connector SDK, package and runtime tests.
2026-11-07 22:07:51 +08:00
Ryder Sun dc4dd306b9 Allow multiline responses to Agent input requests
Use multiline controls in direct and group runs so users can supply formatted input intact. Checks: 420 frontend tests; frontend builds and desktop pack.
2026-11-07 22:06:38 +08:00
Ryder Sun a100bf138f Preserve human input when resuming Connector runs
Keep significant whitespace across the UI, IPC, durable gate and Connector resume while rejecting empty answers. Checks: 419 frontend tests; 50 gate and Connector tests; IPC test; frontend builds and desktop pack.
2026-11-07 22:04:31 +08:00
Ryder Sun c84de2fb21 Redact escaped single-quoted Hermes credentials
Decode native dotenv quote escapes before registering output redactions. Checks: 78 CLI and ACP event tests; native dotenv parser reproduction.
2026-11-07 22:01:23 +08:00
Ryder Sun 535d7f8f2f Match Hermes dotenv expansion when redacting secrets
Honor native dotenv precedence, interpolation and unquoted hash values so streamed output is redacted correctly. Checks: 78 CLI process, ACP lifecycle and event tests.
2026-11-07 22:00:12 +08:00
Ryder Sun 4c7cf6b661 Match Hermes provider summary to native dotenv precedence
Report the provider from the selected native dotenv instead of a stale inherited value. Checks: 64 Hermes summary and native configuration tests; isolated native dotenv reproduction.
2026-11-07 21:59:18 +08:00
Ryder Sun 39f75d790a Validate human gates before writing request content
Avoid orphaned approval request blobs when gate records are invalid or the store is full. Checks: 49 gate, blob store and ACP lifecycle tests.
2026-11-07 21:56:49 +08:00
Ryder Sun 6af8884601 Honor Windows casing for Gemini configuration directories
Keep Gemini native configuration selection consistent with Windows filesystem semantics. Checks: 131 native configuration and readiness tests.
2026-11-07 21:55:33 +08:00
Ryder Sun 1b2df8dbfe Validate public human approval option fields
Reject recognizable secrets and control characters before creating public approval records; preserve existing record parsing. Checks: 40 gate and ACP lifecycle tests.
2026-11-07 21:54:35 +08:00
Ryder Sun 626d9aee6c Reject secrets in task graph decision fields
Validate new graph identifiers and decision labels before persistence while preserving legacy recovery. Checks: 59 task graph and recovery tests.
2026-11-07 21:53:32 +08:00
Ryder Sun 188650d94d Require acceptance criteria for new task graph nodes
Prevent new Agent nodes from accepting empty results while preserving legacy graph recovery. Checks: 58 task graph execution and recovery tests; task graph record tests.
2026-11-07 21:52:06 +08:00
Ryder Sun bbe0420619 Expire readiness grace when the system clock rolls back
Avoid retaining stale Agent availability after a clock rollback. Checks: 107 readiness and routing tests.
2026-11-07 21:47:26 +08:00
Ryder Sun 38f54eebf6 Redact Hermes native configuration credentials
Protect native configuration secrets in streamed output and refresh ACP redaction when configuration changes. Checks: 114 focused CLI tests; desktop pack.
2026-11-07 21:35:51 +08:00
Ryder Sun 740d7c4a36 Discover flat Hermes installations
Search the selected native home itself as well as nested installs so valid flat layouts are not reported missing. Verified discovery and compatibility tests and desktop packaging.
2026-11-07 21:22:28 +08:00
Ryder Sun b7e3251a4a Isolate OpenClaw sessions across native directories
Bind newly created OpenClaw sessions to the selected native configuration while preserving existing default sessions and restoring archived profiles. Verified workspace tests and desktop packaging.
2026-11-07 21:18:28 +08:00
Ryder Sun 76007ebd17 Follow OpenClaw native home and legacy paths
Resolve native home and state overrides with the same precedence as OpenClaw while retaining guarded directory validation. Verified readiness and security tests, a real CLI against a local provider, and desktop packaging.
2026-11-07 21:15:46 +08:00
Ryder Sun 00a0353f6d Support OpenClaw native state directories
Use selected or shell-configured state roots for native discovery while retaining guarded path and isolated execution checks. Verified readiness, security and frontend tests, frontend builds and desktop packaging.
2026-11-07 21:09:09 +08:00
Ryder Sun 1c8eb35ad5 Redact Qwen native configuration credentials
Include parsed native configuration secrets in transient redaction for final and streaming output. Verified CLI tests and desktop packaging.
2026-11-07 21:03:23 +08:00
Ryder Sun fd1f785941 Align MiMo provider tests with project-aware status
Expect unverified status when the built-in account is logged out because project configuration can select another provider. Verified MiMo and readiness tests.
2026-11-07 21:01:13 +08:00
Ryder Sun f1f6ab3b1d Redact OpenCode native provider credentials
Reuse native configuration discovery for in-memory redaction of inline credentials across final and streaming output. Verified CLI configuration and ACP tests and desktop packaging.
2026-11-07 21:00:18 +08:00
Ryder Sun bea31af3e3 Redact MiMo native configuration credentials
Use native configuration layers to protect inline provider secrets in final and streaming output without adding them to the subprocess environment. Verified CLI, ACP and workspace tests and desktop packaging.
2026-11-07 20:58:03 +08:00
Ryder Sun f2d4cf9c64 Redact Claude user configuration credentials
Include the selected native settings directory in transient output redaction alongside project settings. Verified CLI and workspace tests and desktop packaging.
2026-11-07 20:56:21 +08:00
Ryder Sun 89efac6ae8 Redact Claude project credentials from runtime output
Collect project settings credentials only for in-memory output redaction, including split streaming and bearer tokens. Verified CLI and workspace tests, a real CLI with a local mock service, and desktop packaging.
2026-11-07 20:54:42 +08:00
Ryder Sun 960dcdb8de Allow Claude project credentials when globally logged out
Treat the global authentication probe as inconclusive for project settings and keep automatic routing gated on verified runs. Verified readiness and workspace tests and desktop packaging.
2026-11-07 20:50:47 +08:00
Ryder Sun 7788d494d2 Allow MiMo project providers without official login
Treat the official identity probe as inconclusive for project providers so explicit chats can use native configuration while automatic routing still requires verification. Verified readiness and workspace tests and desktop packaging.
2026-11-07 20:48:39 +08:00
Ryder Sun e9db23713e Validate recovered task graph outcome references
Ensure recovered review entries refer to the accepted artifacts and evidence rather than other records from the same execution. Verified task graph tests and desktop packaging.
2026-11-07 20:40:41 +08:00
Ryder Sun f0ba6335e0 Validate task graph recovery entry provenance
Reject unrelated blackboard entries in recovered node outcomes so review inputs remain bound to the original Agent execution. Verified task graph tests and desktop packaging.
2026-11-07 20:39:18 +08:00
Ryder Sun 335bbec0dc Match Qwen project trust defaults
Resolve project credentials with the same default and explicit denial behavior as Qwen CLI, while treating malformed trust configuration as unverified. Verified focused readiness and security tests and desktop packaging.
2026-11-07 20:35:08 +08:00
Ryder Sun a7c3d3a84d Align OAuth verification cache with credential validation
Treat placeholder refresh tokens as unavailable when invalidating expired Gemini verification.

Checks: 194 catalog and readiness tests; desktop packaging; git diff --check.
2026-11-07 20:29:02 +08:00
Ryder Sun 84a14345a5 Expire cached Gemini verification with its access token
Invalidate previous successful verification when an unrefreshable native OAuth access token enters its expiry window.

Checks: 194 catalog and readiness tests; desktop packaging; git diff --check.
2026-11-07 20:24:44 +08:00
Ryder Sun 8c01e2d150 Detect expired Gemini OAuth tokens without refresh credentials
Avoid reporting readiness when the native OAuth client cannot refresh an expired access token.

Checks: 109 Gemini readiness tests; desktop packaging; git diff --check.
2026-11-07 20:23:29 +08:00
Ryder Sun 71bb4c6eda Check Gemini OAuth token fields explicitly
Avoid treating unrelated credentials inside the OAuth file as a configured OAuth login.

Checks: 109 Gemini readiness tests; desktop packaging; git diff --check.
2026-11-07 20:22:21 +08:00
Ryder Sun 914f2d934b Avoid trusting insecure Gemini system settings
Do not infer readiness from system configuration whose ownership or permissions cannot be verified like the native CLI.

Checks: 147 configuration and readiness tests; desktop packaging; git diff --check.
2026-11-07 20:21:03 +08:00
Ryder Sun e83f790f62 Distinguish Gemini API and Vertex credentials
Do not report Gemini API key readiness solely from a Vertex GOOGLE_API_KEY.

Checks: 109 Gemini readiness tests; desktop packaging; git diff --check.
2026-11-07 20:18:42 +08:00
Ryder Sun 920949deea Respect Gemini enforced authentication type
Avoid reporting readiness when the selected native authentication conflicts with configured enforcement.

Checks: 109 Gemini readiness tests; desktop packaging; git diff --check.
2026-11-07 20:17:49 +08:00
Ryder Sun d42f974d4e Avoid Gemini readiness without a selected auth mode
Treat stale OAuth files and unrelated API keys as unverified when the native noninteractive CLI has no selected authentication.

Checks: 108 Gemini readiness tests; desktop packaging; git diff --check.
2026-11-07 20:16:54 +08:00
Ryder Sun ac78959842 Follow Gemini native authentication inference
Recognize native gateway and cloud overrides and infer the selected auth mode from the same environment priorities as the CLI.

Checks: 129 environment and readiness tests; desktop packaging; git diff --check.
2026-11-07 20:13:46 +08:00
Ryder Sun 96aebb1d6c Invalidate Gemini verification when workspace trust changes
Track default and custom trusted-folder configuration in native and project verification identities.

Checks: 124 configuration and catalog tests; desktop packaging; git diff --check.
2026-11-07 20:12:01 +08:00
Ryder Sun 0f2babbcf8 Resolve Gemini readiness from its selected native auth mode
Respect settings precedence and workspace trust when checking OAuth or API key credentials; leave other modes unverified.

Checks: 107 Gemini and readiness tests; desktop packaging; git diff --check.
2026-11-07 20:09:20 +08:00
Ryder Sun 7cdc88c4d1 Preserve Gemini native workspace trust settings
Forward explicit trusted or restricted workspace settings to Gemini so its native configuration is interpreted consistently.

Checks: 146 environment and readiness tests; desktop packaging; git diff --check.
2026-11-07 20:06:51 +08:00
Ryder Sun 589b9d6142 Validate distinct evidence counts during V4 recovery
Apply the same distinct reference thresholds to persisted work receipts without rejecting otherwise compatible older records.

Checks: 78 orchestration and V4 receipt tests; desktop packaging; git diff --check.
2026-11-07 20:02:04 +08:00
Ryder Sun ff9100db05 Count distinct V4 artifact and evidence references
Prevent repeated references from satisfying work acceptance minimums.

Checks: 27 V4 receipt tests; desktop packaging; git diff --check.
2026-11-07 19:58:41 +08:00
Ryder Sun b0a08d38dc Verify automatic ACP approval survives repeated restart
Exercise durable V4 automatic approval across a second interrupted recovery and subsequent native ACP resume.

Checks: automatic ACP restart regressions; git diff --check.
2026-11-07 19:53:49 +08:00
Ryder Sun 3f1b6daeda Verify ACP approval survives repeated restart
Exercise persisted manual V4 approval through a second interrupted recovery and a subsequent successful native ACP resume.

Checks: targeted native ACP double-restart test; git diff --check.
2026-11-07 19:52:22 +08:00
Ryder Sun 990f65909b Preserve V4 approval continuation on shutdown during recovery
Keep the pending continuation recoverable when an interrupted manual or automatic resume returns during shutdown.

Checks: focused manual and auto continuation tests; desktop packaging; git diff --check.
2026-11-07 19:50:54 +08:00
Ryder Sun 0a8f3750a5 Cover V4 interruption reasons across collaboration phases
Exercise late work, synthesis, and verification results for user stops, shutdowns, and budget exhaustion.

Checks: 9 phase interruption regressions; 26 V4 durability tests; git diff --check.
2026-11-07 19:48:44 +08:00
Ryder Sun e34719f6ad Preserve V4 interruption reasons across resumed phases
Do not report shutdown or budget interruption as a user stop or phase failure while rejecting late work results.

Checks: 3 focused interruption regressions; desktop packaging; git diff --check.
2026-11-07 19:46:35 +08:00
Ryder Sun 307708f3c1 Validate Qwen OAuth credential structure before readiness
Require the native CLI credential fields instead of reporting an incomplete token file as ready.

Checks: 114 readiness and native configuration tests; git diff --check.
2026-11-07 19:40:43 +08:00
Ryder Sun 312aefd1de Verify inferred Qwen authentication at process boundary
Cover native environment forwarding and readiness for OAuth selected through the shell.

Checks: 27 Qwen and process environment tests; git diff --check.
2026-11-07 19:39:39 +08:00
Ryder Sun bd1636eca1 Follow Qwen native environment authentication selection
Detect inferred OAuth and provider modes using the CLI precedence so credential filtering and readiness match invocation.

Checks: 114 native configuration and readiness tests; git diff --check.
2026-11-07 19:38:11 +08:00
Ryder Sun 1d7099a660 Check Qwen OAuth readiness against its own credentials
Avoid treating unrelated provider API keys as a Qwen OAuth login.

Checks: 113 readiness and native configuration tests; git diff --check.
2026-11-07 19:36:45 +08:00
Ryder Sun a6b2140052 Respect Zcode environment model in readiness and settings
Check the selected provider even when no configuration file exists and show the environment-selected provider in settings.

Checks: 112 readiness and native configuration tests; git diff --check.
2026-11-07 19:34:22 +08:00
Ryder Sun a25a165b48 Align Qwen settings summary with native authentication precedence
Use the same native configuration selection for display and execution so system settings and environment defaults are represented correctly.

Checks: 111 readiness and Qwen configuration tests; git diff --check.
2026-11-07 19:33:03 +08:00
Ryder Sun bcbae75f08 Bind task graph approvals to their decision context
Prevent a resumed human approval from resolving conflicts against changed inputs. Preserve compatibility with existing pending gates.\n\nChecks: focused task graph approval and rejection tests; git diff --check.
2026-11-07 19:29:53 +08:00
Ryder Sun f6508d31c2 Keep invalid human decisions visible and rejectable
Show unavailable task graph decision context without exposing invalid request data, reject approval in the main process and preserve the option to reject when storage permits.

Checks: gate store and coordinator tests, task graph recovery tests, frontend tests and builds, macOS pack.
2026-11-07 19:25:48 +08:00
Ryder Sun c03ad0e53b Show selected conclusions on task graph human decisions
Project bounded, redacted claims from the durable decision request into existing gate cards so users can see the conclusions they are approving; preserve legacy gates and validate the public boundary.

Checks: task graph, gate store and restart tests; frontend tests and builds; macOS pack. Correct misplaced legacy review assertion.
2026-11-07 19:21:00 +08:00
Ryder Sun 217a13d516 Validate task graph human prompts before starting work
Apply the same human gate summary constraints during graph creation so invalid decisions fail atomically while legacy graph records remain readable.

Checks: task graph and human gate recovery tests, frontend tests and builds, macOS pack.
2026-11-07 19:11:15 +08:00
Ryder Sun 0589f77772 Bind human task graph decisions to selected conflicts
Only resolve conflicts whose claims were selected for the decision and prevent a terminal decision from hiding unrelated unresolved conflicts, including during recovery.

Checks: task graph and gate recovery tests, frontend tests and builds, macOS pack.
2026-11-07 19:08:20 +08:00
Ryder Sun 99d7ed9186 Forward selected OCR preset credentials from native shell
Capture the selected native OCR preset key without changing provider configuration, and avoid exposing unrelated provider credentials to its subprocess.

Checks: native readiness, catalog and main security tests; real OCR direct and group workspace tests against a local model.
2026-11-07 19:03:24 +08:00
Ryder Sun f9fc9ee35d Align OCR readiness with native endpoint requirements
Require a selected provider entry or a complete native endpoint before reporting ready, preventing unrelated keys and incomplete configurations from appearing usable.

Checks: native readiness, configuration and catalog tests; real OCR missing-provider reproduction and direct/group integration.
2026-11-07 18:59:14 +08:00
Ryder Sun 69debbb07c Respect OCR Responses API native credential
Use and forward OPENAI_RESPONSES_API_KEY for the Responses preset without confusing it with the separate Chat Completions credential.

Checks: native readiness and catalog tests; real OCR Responses request and direct/group workspace tests with local model.
2026-11-07 18:56:16 +08:00
Ryder Sun 86c1af7e8c Check OCR provider invocation prerequisites before readiness
Require the selected provider model and validate declared protocol and endpoint before reporting configured credentials as ready.

Checks: native readiness and catalog tests, real OCR direct and group workspace integration.
2026-11-07 18:54:40 +08:00
Ryder Sun 6cffedac54 Resolve OCR provider name collisions against its native registry
Probe installed OCR provider names before trusting custom credentials so an overridden built-in name cannot produce a false ready state; fail closed when the list cannot be verified.

Checks: native readiness, configuration and catalog tests; real OCR provider registry probe.
2026-11-07 18:52:48 +08:00
Ryder Sun 8f0fe4d01b Check the selected OpenCodeReview provider credential
Avoid marking a configured provider ready because another provider or an unrelated environment variable has a key; retain unverified status when its credential cannot be determined.

Checks: native readiness, configuration and catalog tests; real OCR missing-credential reproduction.
2026-11-07 18:50:52 +08:00
Ryder Sun 14c23653ae Exercise real OpenCodeReview in both chat modes
Run the installed CLI with a selected native home and local protocol-compatible model to verify direct and group workspace integration without external credentials.

Checks: MELDWORK_TEST_REAL_OCR=1 node --test desktop/test/workspace/local-workspace-ocr-native.test.cjs.
2026-11-07 18:48:23 +08:00
Ryder Sun 7538076aef Verify OpenCodeReview configuration in both chat modes
Exercise a persisted native directory through the desktop workspace and CLI adapter for direct and group conversations.

Checks: OpenCodeReview native workspace integration test.
2026-11-07 18:46:27 +08:00
Ryder Sun 90e43a3011 Support OpenCodeReview native configuration directories
Use the selected native home consistently for OCR detection, readiness, execution and session isolation, and explain the home-relative behavior in settings.

Checks: desktop native configuration and security tests, frontend tests and builds, macOS pack, real OCR CLI with an isolated native home.
2026-11-07 18:45:23 +08:00
Ryder Sun 4a9fa31812 Provide verified outcomes to task graph reviewers
Materialize audience-approved text artifacts and evidence summaries with bounded, redacted content so isolated reviews can inspect their supporting outcomes.

Checks: task graph and human gate recovery tests; workspace reviewer integration test.
2026-11-07 18:40:55 +08:00
Ryder Sun 2cb640ca99 Include conflict outcomes in task graph review handoffs
Carry the accepted artifact and evidence references for each conflicting claim into review while preserving audience enforcement.

Checks: task graph and human gate recovery tests.
2026-11-07 18:39:14 +08:00
Ryder Sun f378bb3a27 Include conflicting claims in task graph review handoffs
Reviewers and arbiters need the claims behind each unresolved conflict to make an informed decision; retain the existing audience checks when constructing the handoff.

Checks: task graph and human gate recovery tests.
2026-11-07 18:37:33 +08:00
Ryder Sun 617addecea Forward OpenCodeReview native Anthropic environment
Preserve the native endpoint, model and credentials required by OCR without forwarding them to other agents or exposing secrets in runtime output.

Checks: native readiness and environment tests; real OCR CLI against a local Anthropic-compatible mock.
2026-11-07 18:33:43 +08:00
Ryder Sun 155c528a62 Preserve OpenCodeReview native protocol settings
Forward the CLI protocol, auth header mode, and timeout so a selected OpenAI-compatible endpoint is invoked using its configured protocol.

Checks: native readiness and configuration tests; real OCR CLI against a local mock endpoint.
2026-11-07 18:32:10 +08:00
Ryder Sun 783937f000 Invalidate OpenCodeReview verification when native configuration changes
Track the CLI and application home configuration files so routing cannot reuse a stale successful verification after credentials or provider settings change.

Checks: native configuration and catalog tests, main process security tests; real CLI configuration path checked in an isolated home.
2026-11-07 18:28:28 +08:00
Ryder Sun 84cc159230 Track selected native homes in agent verification
Show safe Qwen authentication and Zcode provider summaries from their native user configuration, and invalidate stale readiness when selected-home files change.

Checks: focused desktop tests, frontend tests, web build, macOS pack.
2026-11-07 18:24:36 +08:00
Ryder Sun d3f523b6c9 Support Qwen native configuration selection
Use the selected Qwen home for credentials, sessions and Skills, with native project precedence. Checks: desktop and frontend tests, builds, desktop pack and local mock-service CLI conversations.
2026-11-07 18:18:59 +08:00
Ryder Sun d664b9cae4 Support Zcode native configuration selection
Use the selected native Zcode home consistently for discovery, credentials and chat execution. Checks: desktop and frontend tests, builds, desktop pack and local mock-service CLI conversations.
2026-11-07 18:02:24 +08:00
Ryder Sun 4ff4740166 Verify profile sessions in direct and group chats
Exercise profile switching across workspace restart and document session isolation. Checks: native session profile tests.
2026-11-07 17:43:09 +08:00
Ryder Sun 68ebce4fdf Isolate native sessions by configuration directory
Preserve profile-specific conversation sessions and protect pending runs when switching configuration. Checks: desktop and frontend tests, frontend builds and desktop pack.
2026-11-07 17:41:20 +08:00
Ryder Sun 2a0b4fd3e9 Bind agent verification to Windows shim target
Invalidate prior successful-run verification when the executable behind an npm shim changes or cannot be read. Checks: 176 catalog, discovery and routing tests.
2026-11-07 17:25:51 +08:00
Ryder Sun cdad2847c8 Bind Windows capability cache to shim target
Invalidate cached compatibility when the npm launcher target changes. Checks: 176 discovery, compatibility and readiness tests.
2026-11-07 17:24:51 +08:00
Ryder Sun 9d5387762e Require invocability for explicit agent routing
Reject targets with no confirmed invocation state in both routing modes. Checks: routing, catalog and workspace tests.
2026-11-07 17:21:40 +08:00
Ryder Sun e074d36514 Require invocability for automatic agent routing
Exclude agents without an explicit invocable state from automatic selection and permission inference. Checks: 103 routing and catalog tests.
2026-11-07 17:18:49 +08:00
Ryder Sun 6a992561f6 Require both outcomes for new graph decisions
Prevent unresolvable human decision workflows while preserving existing graph records. Checks: task graph unit tests and 172 workspace and recovery tests.
2026-11-07 17:17:21 +08:00
Ryder Sun 064b42df35 Resolve Hermes home shorthand during discovery
Track the same native directory Hermes selects when HERMES_HOME uses a home-relative path. Checks: 201 focused desktop tests.
2026-11-07 17:12:54 +08:00
Ryder Sun abe7c2da75 Discover legacy Hermes virtual environments
Honor native legacy installation paths without falling back to an obsolete environment for package-managed installs. Checks: 166 focused desktop tests.
2026-11-07 17:10:52 +08:00
Ryder Sun e77965357c Match Hermes fallback credential selection
Recognize credentials only for valid native fallback entries, including inline keys. Checks: 197 focused desktop tests.
2026-11-07 17:09:00 +08:00
Ryder Sun 167387bf29 Recognize configured Hermes fallback login records
Read only explicitly configured built-in provider credentials from Hermes auth storage when fallback providers are present. Checks: readiness, catalog and routing tests (197 passed).
2026-11-07 17:06:47 +08:00
Ryder Sun 475da8c319 Scope Hermes fallback readiness to declared providers
Avoid treating unrelated credentials as evidence for a configured Hermes fallback chain while retaining native declared keys. Checks: readiness, catalog and routing tests (197 passed); desktop pack.
2026-11-07 17:05:34 +08:00
Ryder Sun 442f5342b2 Show recovery guidance for unreadable run history
Handle ledger load failures without initializing workspace APIs or changing existing history, including window reactivation. Checks: main security and ledger tests (171 passed); desktop pack.
2026-11-07 17:02:10 +08:00
Ryder Sun 93b446df5b Preserve task graph conflicts during replay
Validate and retain generated conflict entries when recovering a checkpointed claim. Checks: task graph and human gate recovery tests (48 passed).
2026-11-07 16:51:07 +08:00
Ryder Sun 69aadfa4e7 Read identified CLI versions from nonzero exits
Preserve product version output from completed version probes without trusting warnings or timed-out processes. Checks: discovery, compatibility and routing tests (96 passed), focused timeout regression.
2026-11-07 16:42:49 +08:00
Ryder Sun d947e36855 Test Connector permission gate session recovery
Exercise bound permission continuations through manual and V4 group execution alongside input-gate restart recovery. Checks: focused workspace Connector gate tests (4 passed).
2026-11-07 16:41:18 +08:00
Ryder Sun 2bb6ae4a8c Bind Connector continuation to its session
Reject a human continuation if its session hash does not match the session passed to the Connector. Checks: connector conformance suite (220 passed).
2026-11-07 16:39:32 +08:00
Ryder Sun 25ffd811f2 Enforce declared Connector wait types on resume
Reject input or permission continuations that the Connector cannot declare as an event, while retaining normal session resume. Checks: connector conformance suite (219 passed).
2026-11-07 16:37:39 +08:00
Ryder Sun 01ee05b50a Distinguish authentication from CLI incompatibility
Treat definitive sign-in failures during incomplete capability probes as inconclusive and show existing sign-in guidance. Checks: desktop discovery, catalog and routing tests (169 passed); frontend tests (412 passed); frontend and desktop builds.
2026-11-07 16:36:05 +08:00
Ryder Sun 47949a8f45 Restore references for completed legacy graph nodes
Revalidate and persist missing durable outcome references before dependent nodes run. Checks: task graph and human gate recovery tests (47 passed).
2026-11-07 16:33:09 +08:00
Ryder Sun d3286177f1 Restore legacy task graph outcome references
Reconstruct missing legacy references only from verified durable outcomes during replay, preserving artifact-only and conclusion checkpoints. Checks: task graph and human gate recovery tests (47 passed).
2026-11-07 16:31:24 +08:00
Ryder Sun a48ef1ae07 Forward all verified task graph outcomes
Record each verified Artifact and Evidence for dependent graph nodes and validate complete references on replay. Checks: task graph and human gate recovery tests (47 passed).
2026-11-07 16:29:20 +08:00
Ryder Sun 5e0477b83f Verify task graph replay reference completeness
Reject missing, duplicate or misdirected durable references when replaying artifact-only results. Checks: task graph and human gate recovery tests (46 passed).
2026-11-07 16:26:24 +08:00
Ryder Sun a6f6b3fb3f Replay artifact-only task graph results safely
Recover a checkpointed artifact and evidence result without requiring a nonexistent conclusion; reject changed or superseded references. Checks: task graph and human gate recovery tests (46 passed), focused replay test.
2026-11-07 16:25:13 +08:00
Ryder Sun 22f3ddf40b Preserve task graph messages during V4 recovery
Keep unsupported graph records out of ordinary discussion recovery and its message cleanup. Checks: focused V4 graph recovery tests (2 passed).
2026-11-07 16:23:11 +08:00
Ryder Sun 3c31d54f59 Reject unsupported V4 task graph recovery
Prevent persisted graph runs from silently resuming as ordinary discussions until graph execution is supported. Checks: focused recovery and V4 graph ledger tests (126 passed).
2026-11-07 16:22:01 +08:00
Ryder Sun 1408218aeb Test Hermes numeric provider credential selection
Verify native credential lookup chooses the selected numeric provider without exposing another account. Checks: Hermes readiness and configuration tests (97 passed).
2026-11-07 16:20:43 +08:00
Ryder Sun 0a98ec0db1 Recognize numeric Hermes provider identifiers
Match native Hermes normalization for unquoted integer provider IDs in settings summaries and credential discovery. Checks: Hermes summary and main security tests (98 passed).
2026-11-07 16:19:46 +08:00
Ryder Sun 414a7fdedc Track Google ADC changes in native verification
Invalidate Gemini, Pi and OpenCode verification when their selected Google Cloud credential files change, including project-relative directories. Checks: native configuration, catalog and main security tests (212 passed).
2026-11-07 16:06:50 +08:00
Ryder Sun 6d0f384221 Verify DeepSeek project credential identity
Cover native provider credential changes and cross-Agent isolation for project verification. Checks: main security and Agent catalog tests (178 passed).
2026-11-07 16:04:44 +08:00
Ryder Sun 4b4fe3538c Preserve partial status after discussion failures
Keep completed earlier contributions visible as partial progress when a later Agent attempt fails. Checks: V4 natural discussion and durability tests (106 passed).
2026-11-07 16:03:41 +08:00
Ryder Sun 72ac7c7b0a Test native credential declaration boundary
Verify callers cannot add credential variables or unsafe runtime options to a native Agent subprocess. Checks: node --test desktop/test/security/main-security.test.cjs (95 passed).
2026-11-07 16:00:20 +08:00
Ryder Sun 2f6b2272b9 Unify native CLI credential declarations
Use the same trusted declarations for discovery, execution, readiness, and project identities, while filtering unsafe variables and redacting secrets.\n\nChecks: focused desktop tests (221 passed); npm --prefix desktop run pack.
2026-11-07 15:59:06 +08:00
Ryder Sun 96f4ac077d Report Qwen dotenv credential source accurately
Distinguish a native dotenv credential from an inherited shell credential in readiness state. Verified Qwen readiness and main-process security tests.
2026-11-07 15:55:43 +08:00
Ryder Sun 1c53e322b7 Fail closed on unsupported Qwen credential references
Keep Qwen unverified when its selected envKey is unsafe or native settings cannot be read, rather than accepting unrelated provider credentials. Verified Qwen readiness and main-process security tests.
2026-11-07 15:54:36 +08:00
Ryder Sun 2b347d39d0 Match Qwen readiness to its configured provider
Select the native default credential for the configured Qwen authentication type so unrelated provider keys cannot establish readiness. Verified Qwen, readiness and main-process security tests.
2026-11-07 15:52:48 +08:00
Ryder Sun 23d6fa0de6 Recognize Qwen native dotenv credentials
Check the selected model envKey in native dotenv files using Qwen precedence and last-assignment semantics, while respecting explicitly empty inherited values. Verified Qwen readiness and main-process security tests.
2026-11-07 15:50:07 +08:00
Ryder Sun c01b7f3cdc Honor Qwen selected model credential references
Capture only the selected native model envKey across trusted configuration layers, scope it to Qwen, redact it, and invalidate verification when trust changes. Verified native, security and CLI protocol tests, real Qwen direct/group smoke checks against a local fixture, and desktop packaging.
2026-11-07 15:47:49 +08:00
Ryder Sun 34d72f5bff Preserve Qwen native provider environment
Pass Qwen supported Anthropic, Gemini and Vertex model variables from the native environment for discovery and execution. Verified readiness and native environment tests.
2026-11-07 15:38:32 +08:00
Ryder Sun e0cc9b1c88 Find native Hermes launchers on Windows
Search the selected or default Windows Hermes launcher and legacy virtual environment after PATH candidates. Verified discovery and readiness tests with Windows path simulations.
2026-11-07 15:24:28 +08:00
Ryder Sun 69f979d63b Discover Hermes in its native installation directory
Fall back to the Hermes installer location when its PATH launcher is absent, including selected native homes and explicit install directories. Verified discovery and readiness tests and desktop package.
2026-11-07 15:23:00 +08:00
Ryder Sun 98fb53ade1 Clarify task graph artifact recording for agents
Explain that Meldwork records completed responses as artifacts and evidence, so agents do not search for internal IDs. Verified task graph tests and real Hermes review smoke checks.
2026-11-07 15:19:07 +08:00
Ryder Sun f04f64eff7 Show safe ACP tool categories in execution traces
Preserve standard tool kinds across ACP lifecycle updates without exposing upstream titles or arguments. Verified ACP protocol, lifecycle, and adapter tests.
2026-11-07 15:14:56 +08:00
Ryder Sun 381d7f452d Track relative Hermes Google credentials for routing
Invalidate project verification when a relative CLOUDSDK_CONFIG credential file changes. Verified native configuration, readiness and agent catalog tests.
2026-11-07 14:59:33 +08:00
Ryder Sun c6f61787c2 Pass native cloud credentials to Hermes
Preserve Hermes Bedrock and Vertex settings from the native shell for detection and execution, and invalidate verification when cloud credential files change. Verified native environment, readiness and security tests.
2026-11-07 14:58:22 +08:00
Ryder Sun 3eb9071461 Align Hermes readiness with native environment provider
Use the selected native environment provider consistently for credential discovery and readiness, preventing unrelated credentials from producing a ready state. Verified Hermes summary, readiness, and security tests.
2026-11-07 14:56:18 +08:00
Ryder Sun c6b6628f6c Show Hermes provider from native environment file
Read the provider name from the selected Hermes environment file when configuration and inherited environment do not specify it. Verified Hermes readiness, summary, and desktop security tests.
2026-11-07 14:55:11 +08:00
Ryder Sun 80d52bf53c Prevent native config selection before initial read
Keep the directory action disabled until the initial settings read finishes so stale responses cannot overwrite a later selection. Verified frontend tests and desktop build.
2026-11-07 14:53:30 +08:00
Ryder Sun dea8bdc2d8 Verify task graph reviewer session isolation
Cover a primary and reviewer using the same Agent to ensure the reviewer gets the approved handoff without resuming the primary session. Verified the focused desktop task graph test.
2026-11-07 14:52:09 +08:00
Ryder Sun 3b20e20a1b Track Gemini environment files in routing verification
Follow the native CLI environment search above Git roots and its home fallback to invalidate stale verification after configuration changes. Verified native configuration and agent catalog tests.
2026-11-07 14:45:59 +08:00
Ryder Sun bc546a76ab Track Qwen project environment in routing verification
Follow the native CLI environment file search above Git roots and track its home fallback so changed configuration invalidates prior verification. Verified native configuration, readiness and workspace tests.
2026-11-07 14:45:02 +08:00
Ryder Sun 6cf274a603 Track native credential files in agent verification
Invalidate routing verification when readiness-related configuration changes while preserving native directory validation. Verified native configuration, readiness and workspace tests.
2026-11-07 14:43:22 +08:00
Ryder Sun 5a9b81700b Invalidate Qwen verification after native environment changes
Track the Qwen environment file already used by readiness so automatic routing cannot reuse an outdated verification. Verified with native config, readiness, and workspace tests.
2026-11-07 14:41:57 +08:00
Ryder Sun 0a3afaac9e Record verified file hashes for collaboration acceptance
Allow write-enabled collaboration tasks to require main-process file-hash evidence captured from workspace outputs. Verified with targeted desktop tests and desktop pack.
2026-11-07 14:38:21 +08:00
Ryder Sun b3204b2e37 Require trusted provenance for V4 test and hash evidence
Reject Agent-authored claims as system test or file-hash evidence and avoid asking Agents to require currently unavailable evidence types.

Checks: V4 receipt, plan, ledger and group negotiation tests; desktop pack.
2026-11-07 14:34:17 +08:00
Ryder Sun d91d0b788b Require typed evidence for V4 work acceptance
Allow work plans to name evidence kinds and verify that matching records support an output Artifact before accepting or recovering a result.

Checks: V4 records, receipts, ledger and group negotiation tests; desktop pack.
2026-11-07 14:31:23 +08:00
Ryder Sun 40dface2cf Exercise stricter evidence requirements in V4 group work
Verify a negotiated task requiring two Artifacts and two Evidence records receives its criteria and completes with matching durable results.

Checks: V4 Agent negotiation integration test.
2026-11-07 14:28:47 +08:00
Ryder Sun 174cbd6a0e Add typed evidence requirements to V4 work plans
Allow negotiated work packages to require bounded Artifact and Evidence counts and enforce them during settlement and recovery.

Checks: V4 plan, receipt, ledger, negotiation, convergence and Gate recovery tests; desktop pack.
2026-11-07 14:27:40 +08:00
Ryder Sun 2cc3efe551 Persist V4 work dependency bindings before dispatch
Checkpoint each work slot’s exact dependency receipts and validate them during recovery and settlement while retaining legacy record compatibility.

Checks: full desktop test suite, focused Gate restart and negotiated-work tests, desktop pack.
2026-11-07 14:22:55 +08:00
Ryder Sun 742abe5971 Freeze V4 work dependencies before dispatch
Capture the dependency receipt IDs used to build each work package and reject settlement if durable dependencies have changed.

Checks: V4 receipt, ledger, coordination, parallel checkpoint and Gate restart tests; desktop pack.
2026-11-07 14:00:26 +08:00
Ryder Sun 35455e7975 Bind V4 work results to their dependency receipts
Persist exact dependency receipt IDs for new work results and verify them on recovery while retaining support for earlier records.

Checks: V4 records, receipts, ledger, negotiated work, parallel checkpoint and Gate restart tests; desktop pack.
2026-11-07 13:57:46 +08:00
Ryder Sun b5f8181e89 Reject missing selected Agent configurations at launch
Check manually selected native directories during readiness and before starting the CLI so a missing configuration cannot silently fall back to another account.

Checks: main-process security, native configuration and Agent catalog tests; desktop pack.
2026-11-07 13:42:52 +08:00
Ryder Sun 515ca64ac3 Warn when selected Agent configuration disappears
Revalidate saved native directories when settings open and identify expected files without exposing configuration contents.

Checks: desktop native configuration and security tests, frontend tests and build, desktop pack.
2026-11-07 13:41:20 +08:00
Ryder Sun abf6b3a23f Bind OpenClaw workspace TLS files to verification
Include relative network certificate paths in the isolated Agent project identity to avoid reusing stale verification.

Checks: main-process security and Agent catalog tests; desktop pack.
2026-11-07 13:39:49 +08:00
Ryder Sun 63af207a1b Track native TLS bundle changes in Agent verification
Include common certificate files in global and workspace identities so automatic routing does not reuse stale verification after a CA bundle changes.

Checks: native configuration, readiness, catalog and main-process security tests; desktop pack.
2026-11-07 13:38:00 +08:00
Ryder Sun 9012728f18 Ignore terminal OSC sequences in Agent version detection
Strip title and hyperlink control sequences before matching product versions to prevent metadata from affecting compatibility detection.

Checks: compatibility, discovery and catalog tests; desktop pack.
2026-11-07 13:35:43 +08:00
Ryder Sun f9578a20ab Explain unresolved OpenClaw native secret references
Keep the Agent unavailable while identifying native configuration failures without exposing secret details.

Checks: desktop readiness and catalog tests, frontend tests and build, desktop pack.
2026-11-07 13:34:34 +08:00
Ryder Sun 6d65a46ab7 Cover Zcode model overrides in workspace chats
Exercise native credential selection through direct and group invocation with a local CLI fixture.

Checks: Zcode workspace regression and native readiness tests.
2026-11-07 13:29:37 +08:00
Ryder Sun 001026d622 Respect Zcode native credential rules for model overrides
Capture the Anthropic credential when a native ZCODE_MODEL override selects a named provider, matching the CLI configuration parser.

Checks: readiness, process environment and main-process security tests; desktop pack.
2026-11-07 13:24:57 +08:00
Ryder Sun 9328551c40 Track Zcode CA changes in Agent verification
Include the native ZCODE_AGENT_CA_CERT path in configuration identity so changed certificates invalidate stale verification.

Checks: native configuration, readiness and verification identity tests; desktop pack.
2026-11-07 13:23:15 +08:00
Ryder Sun 21df47cca3 Invalidate Agent verification when AWS CA bundle changes
Track absolute and workspace-relative CA bundle paths alongside native cloud identity files so stale Bedrock verification is not reused.

Checks: native configuration, readiness and verification identity tests; desktop pack.
2026-11-07 13:21:21 +08:00
Ryder Sun 0106aecf18 Preserve AWS profile and CA settings for Bedrock agents
Pass native AWS_DEFAULT_PROFILE and AWS_CA_BUNDLE consistently to discovery and execution without exposing them to unrelated agents.

Checks: focused desktop readiness, process and discovery tests; desktop pack.
2026-11-07 13:17:20 +08:00
Ryder Sun 6a08bdd29a Show configured Codex provider in agent settings
Read the native user configuration without exposing credentials, while making clear that projects may override it.

Checks: frontend tests, focused desktop tests, frontend build, desktop pack.
2026-11-07 13:15:22 +08:00
Ryder Sun 2c92de46bb Track complete project ancestry for Agent verification
Allow deep workspaces to retain verified routing while detecting changes through the Git boundary. Checks: workspace and routing tests (229 passed), focused catalog tests (100 passed), desktop pack.
2026-11-07 13:12:11 +08:00
Ryder Sun e8a44eac95 Avoid guessing Hermes named provider readiness
Keep ambiguous native provider names unverified until a successful run rather than using another account credential. Checks: readiness, catalog, native environment and security tests (282 passed); desktop pack; real Hermes request completed.
2026-11-07 13:09:46 +08:00
Ryder Sun 909d901811 Align Hermes provider parsing across configuration paths
Honor nested model and legacy root provider fields consistently in summaries, credential discovery and readiness. Checks: Hermes, native environment, catalog and security tests (281 passed); desktop pack.
2026-11-07 13:01:57 +08:00
Ryder Sun ed651e419a Show the configured Hermes provider in settings
Expose only a validated provider identifier from the active native configuration and refresh it after directory changes. Checks: frontend tests (408 passed), desktop tests (180 passed), web build and desktop pack.
2026-11-07 13:00:13 +08:00
Ryder Sun c3c0b25168 Preserve native DeepSeek official credentials
Capture the ACP bundle credential when the official provider is selected, including model-only native overrides, without forwarding it to explicit custom providers. Checks: DeepSeek, CLI, readiness, security and workspace tests (279 passed); desktop pack.
2026-11-07 12:52:58 +08:00
Ryder Sun 2b73250b4e Respect native DeepSeek ACP provider configuration
Apply the ZGCI model as a default only, pass native declared credentials, and invalidate persistent ACP processes when native configuration changes. Checks: desktop tests (278 passed), frontend tests (407 passed), direct/group ACP simulation, web build and desktop pack.
2026-11-07 12:50:41 +08:00
Ryder Sun e5d12a1121 Document Connector upgrades and SDK entry points
Link the SDK guide from both READMEs and describe compatible account migration and recovery. Checks: connector conformance suite (213 passed).
2026-11-07 12:40:23 +08:00
Ryder Sun b18889221a Bind Connector credential access to approved package state
Recheck package trust across confirmation, shell lookup and instance setup to prevent stale authorization. Checks: security, SDK and credential store tests (132 passed); desktop pack.
2026-11-07 12:39:00 +08:00
Ryder Sun 411528b83c Bind Connector approval to the reviewed package state
Reject approval if the package audit or active connector context changes during native confirmation. Checks: security and Connector tests (141 passed), frontend tests (407 passed), web build and desktop pack.
2026-11-07 12:37:19 +08:00
Ryder Sun 8a86b918e9 Require Connector review before approval
Show identity, publisher, origin, permissions, credential types and destinations before the approval action. Checks: frontend tests (407 passed), web build, desktop pack.
2026-11-07 12:35:34 +08:00
Ryder Sun 76269d296a Respect revocation during Connector upgrade recovery
Block follow-on upgrades while instances remain on an older manifest and do not migrate accounts from a revoked package. Checks: Connector and main security tests (187 passed); desktop pack.
2026-11-07 12:33:55 +08:00
Ryder Sun b77daa62cb Preserve compatible Connector accounts during upgrades
Migrate instance bindings after an approved upgrade and recover interrupted migrations from the durable package audit, while refusing incompatible credentials and active requests. Checks: Connector tests (96 passed), main security tests (89 passed), desktop pack.
2026-11-07 12:32:37 +08:00
Ryder Sun c36ce21ce6 Resolve Hermes custom provider aliases for readiness
Match enabled native provider names and keys before checking the selected credentials. Checks: readiness, native environment and catalog tests (187 passed); desktop pack.
2026-11-07 12:26:50 +08:00
Ryder Sun f4b2d90c64 Reject incomplete project verification identities
Do not reuse Agent verification when the ancestor scan cannot reach the project boundary. Checks: catalog, readiness and routing tests (186 passed); desktop pack.
2026-11-07 12:25:42 +08:00
Ryder Sun 978be9caa6 Honor Hermes named provider credential scope
Do not treat a model-level key variable as the credential of a named custom provider. Checks: readiness, native environment and catalog tests (185 passed); desktop pack.
2026-11-07 12:23:38 +08:00
Ryder Sun 164225f887 Scope Hermes legacy custom provider readiness
Resolve named legacy provider credentials without treating unrelated accounts as configured. Checks: readiness, native environment and catalog tests (185 passed); desktop pack.
2026-11-07 12:22:42 +08:00
Ryder Sun 05b42e8ab8 Avoid marking unusable Hermes provider configurations ready
Treat disabled or endpoint-less named providers as unverified, matching native resolution prerequisites. Checks: readiness, native environment and catalog tests (184 passed); desktop pack.
2026-11-07 12:21:15 +08:00
Ryder Sun a08385500c Scope Hermes custom provider readiness to selected credentials
Avoid reporting a named native provider as ready from unrelated account credentials while preserving its configured key. Checks: readiness, native environment and catalog tests (184 passed); desktop pack.
2026-11-07 12:19:51 +08:00
Ryder Sun 8b5c5eabd7 Require matching publisher IDs for Connector upgrades
Reject a package from a different declared publisher when upgrading an installed Connector. Checks: Connector package, SDK and local tests (62 passed); desktop pack.
2026-11-07 12:18:13 +08:00
Ryder Sun a71d9597e8 Cover each declared Connector event and usage capability
Verify every optional event and usage field is rejected when omitted from a Connector manifest. Checks: Connector runtime, SDK, local and package tests (86 passed).
2026-11-07 12:17:05 +08:00
Ryder Sun b183e95b62 Bind V4 acceptance evidence to its run and agent
Reject proposals and synthesis artifacts from another run or agent when evaluating collaboration acceptance. Checks: V4 convergence, negotiation, durability and receipt tests (77 passed); desktop pack.
2026-11-07 12:14:39 +08:00
Ryder Sun 30c39aa869 Normalize Windows proxy settings for agent execution
Use the same case-insensitive network environment contract for discovery and execution so native CLI connections retain proxy settings. Checks: CLI process, native environment and discovery tests (37 passed); desktop pack.
2026-11-07 12:10:44 +08:00
Ryder Sun 8f8f0867f9 Resolve Zcode identity paths for target platform
Use the requested platform when tracking native Zcode configuration, so Windows profile paths are interpreted consistently during cross-platform discovery. Checks: native configuration and catalog tests (104 passed); desktop pack.
2026-11-07 12:09:14 +08:00
Ryder Sun e2265dd01b Validate recovered V4 work provenance
Bind recovered work artifacts and evidence to the current run and assigned agent before resuming or accepting collaboration results.\n\nChecks: V4 records, receipt, negotiation and durability tests (113 passed); desktop pack.
2026-11-07 12:06:44 +08:00
Ryder Sun d3eda7e3bf Deliver verified V4 dependency receipts to downstream work
Build dependent work context from validated receipts and reject missing or incorrectly owned prerequisites instead of silently omitting them.

Checks: 112 V4 orchestration, negotiation, receipt and recovery tests; desktop pack (unsigned).
2026-11-07 12:00:05 +08:00
Ryder Sun 0e92487be0 Cover V4 dependency receipts stored in plan order
Verify valid reordered and independent work receipts remain recoverable while missing prerequisites are rejected.

Checks: V4 receipt parsing, recovery and negotiated group workflow.
2026-11-07 11:56:42 +08:00
Ryder Sun b10193d7c3 Reject V4 work receipts with missing dependencies
Fail closed during record parsing and scheduling when a completed work item lacks a completed prerequisite, so recovery cannot skip upstream work.

Checks: 111 V4 orchestration, negotiation, receipt and recovery tests; desktop pack (unsigned).
2026-11-07 11:53:27 +08:00
Ryder Sun 973d0e3b1c Bound repeated private ACP reasoning independently
Stop sustained repeated reasoning without mixing it with visible answers or exposing private text, while allowing normal long reasoning progress.

Checks: 244 ACP and workspace tests; desktop pack (unsigned); bounded real Hermes run.
2026-11-07 11:46:29 +08:00
Ryder Sun 63323bc1b0 Preserve automatic Agent runs during private ACP reasoning
Refresh the watchdog for substantive reasoning chunks without exposing their text; keep heartbeat and repeated-output safeguards intact.

Checks: 312 ACP, watchdog and workspace tests; V4 heartbeat isolation; desktop pack (unsigned).
2026-11-07 11:39:21 +08:00
Ryder Sun 3de72d8c5a Invalidate Agent verification when tool search paths change
Bind native environment fingerprints to the PATH used by CLI execution without persisting the path itself.

Checks: 169 discovery, process and security tests; desktop pack (unsigned).
2026-11-07 11:24:51 +08:00
Ryder Sun 85a858e897 Bind OpenClaw routing verification to guarded runtime
Recheck the selected native model and credentials before and after execution, and fail closed for OpenClaw without affecting other Agents.

Checks: 352 desktop runtime and routing tests; desktop pack (unsigned).
2026-11-07 11:23:27 +08:00
Ryder Sun 12a7b576b7 Track native environments for automatic Agent routing
Invalidate project-scoped verification when the inherited native configuration changes across built-in CLIs, while keeping credentials isolated and transient.

Checks: 313 focused desktop tests; desktop pack (unsigned).
2026-11-07 11:21:00 +08:00
Ryder Sun 5be3a5cb67 Bind Codex and Hermes verification to native shell credentials
Invalidate project routing verification when either CLI's own native environment changes, without persisting credential values or overriding providers.

Checks: 304 focused desktop tests; desktop pack (unsigned).
2026-11-07 11:18:11 +08:00
Ryder Sun d4d87d3471 Reject superseded task graph evidence on recovery
Require recorded conclusions and outcome references to remain active, with content-addressed regression fixtures for Agent and human decisions.

Checks: 46 task graph and recovery tests; desktop pack (unsigned).
2026-11-07 11:15:48 +08:00
Ryder Sun 81da8996aa Verify active human decisions when restoring task graphs
Require an active matching approval or rejection record before resuming durable workflow state.

Checks: 46 task graph and recovery tests; desktop pack (unsigned).
2026-11-07 11:12:50 +08:00
Ryder Sun 9149018f88 Validate restored task graphs with creation constraints
Reject ambiguous human decision IDs and nonterminal nodes depending on terminal nodes during recovery, matching new graph validation.

Checks: task graph records; nine task graph recovery tests; desktop pack (unsigned).
2026-11-07 11:10:28 +08:00
Ryder Sun f7bb81ca63 Cover project credential changes in automatic group runs
Verify that an old authentication failure cannot block replacement credentials or prevent another group member from replying.

Checks: focused direct and V4 group authentication regressions.
2026-11-07 11:05:12 +08:00
Ryder Sun bc2f6f3814 Recheck native credentials before deferred authentication failures
Avoid marking replacement project credentials as failed when the Agent used an older environment. Verify the race through the normal workspace recovery path.

Checks: 116 workspace tests; authentication recovery tests; desktop pack (unsigned).
2026-11-07 11:03:37 +08:00
Ryder Sun 690ae55c0c Preserve invocation metadata when stopping credential revalidation
Route cancellation during authentication-failure environment capture through normal stop handling so traces and retry safety metadata are retained.

Checks: 115 workspace tests; desktop pack (unsigned).
2026-11-07 11:00:46 +08:00
Ryder Sun aacc022f90 Invalidate project agent verification when native credentials change
Prevent automatic routing from using stale project-specific credential verification and keep cancellation responsive during environment capture. Update orchestration fixtures to reflect validated task graphs and queued slots.

Checks: 298 focused desktop tests; V4 restart regression; desktop pack (unsigned).
2026-11-07 10:56:11 +08:00
Ryder Sun be9bce90e8 Invalidate Agent verification for uninspectable configuration
Treat configuration inspection failures differently from absent files to avoid reusing stale readiness. Verified 117 catalog, identity and routing tests and desktop pack.
2026-11-07 10:33:03 +08:00
Ryder Sun ca299edc3f Check Connector attachment MIME against file contents
Reuse stored attachment type detection to reject disguised media before Connector dispatch while preserving structured data. Verified 155 Connector and main-security tests and desktop pack.
2026-11-07 10:31:34 +08:00
Ryder Sun 5d545222c1 Validate Connector image path inputs
Reject non-image and symlink paths before image-only recipes receive attachments, and reject path objects without MIME metadata. Verified 154 Connector and main-security tests and desktop pack.
2026-11-07 10:29:13 +08:00
Ryder Sun bdab379500 Exercise concurrent Connector credential isolation
Run two isolated Codex account processes concurrently and align the main-process fixture with the required terminal outcome contract. Verified 153 Connector and main-security tests.
2026-11-07 10:26:02 +08:00
Ryder Sun deeaeaa496 Reject delegated Connector results without outcomes
Treat an absent upstream terminal outcome as a protocol failure instead of a successful run. Verified 69 Connector tests and desktop pack.
2026-11-07 10:23:53 +08:00
Ryder Sun 8d5f0e44ae Preserve artifact-only graph outcomes and require arbitration
Allow verified outcomes without optional conclusions and reject empty arbitration of unresolved conflicts. Verified 44 graph and recovery tests and desktop pack.
2026-11-07 10:20:54 +08:00
Ryder Sun 49e4150f49 Revalidate task graph evidence before downstream work
Stop a running graph if accepted output disappears before dispatch or completion. Verified 42 graph and recovery tests and desktop pack.
2026-11-07 10:18:28 +08:00
Ryder Sun 3a10b15db7 Verify graph recovery with missing stored artifact
Exercise restart against real ledger and blob storage so missing accepted evidence cannot dispatch dependent work. Verified 41 graph and recovery tests.
2026-11-07 10:16:24 +08:00
Ryder Sun 13d8a5b41a Revalidate task graph evidence during recovery
Check accepted Agent outcomes and human decisions before resuming durable graph runs, preventing missing evidence from being trusted. Verified 40 graph and recovery tests and desktop pack.
2026-11-07 10:14:51 +08:00
Ryder Sun 2efb822cba Reject incomplete V4 task graph acceptance
Validate required conclusion and outcome references and bind human choices before persisting graph progress. Verified 123 ledger and orchestration tests and desktop pack.
2026-11-07 10:10:56 +08:00
Ryder Sun a17601674f Refresh MiMo runtime after model history changes
Bind ACP process reuse to the effective recent model list to avoid retaining stale native selection. Verified 144 focused tests and desktop pack.
2026-11-07 10:03:11 +08:00
Ryder Sun eab07839f6 Honor MiMo recent model fallback
Capture credentials for bounded recent provider candidates so native fallback remains usable. Verified 126 focused tests, native fallback against a local service, and desktop pack.
2026-11-07 10:00:51 +08:00
Ryder Sun 58d902c824 Honor MiMo recent model credentials
Resolve the native recent model provider and invalidate verification when the model history changes. Verified 126 focused tests, native local service, and desktop pack.
2026-11-07 09:58:56 +08:00
Ryder Sun 65b718050d Honor MiMo provider environment references
Capture selected provider header and embedded key references without forwarding unrelated provider secrets. Verified 101 focused tests and desktop pack.
2026-11-07 09:54:53 +08:00
Ryder Sun 6811398c54 Resolve MiMo model-selected native credentials
Capture the model environment reference before resolving its provider credential, preserving project isolation. Verified 100 focused tests and desktop pack.
2026-11-07 09:53:08 +08:00
Ryder Sun b388695b71 Enforce task graph handoff audiences
Authorize declared downstream agents and reject unavailable inputs to prevent unauthorized or incomplete collaboration context. Verified task graph and recovery tests (39 passed).
2026-11-07 09:40:50 +08:00
Ryder Sun 3ca0cddb8b Follow native OpenCode configuration precedence
Resolve effective model and provider controls using the observed native layer order, avoiding credentials from overridden selections. Verified 190 tests and desktop packaging.
2026-11-07 09:34:31 +08:00
Ryder Sun 7627ffbf61 Honor OpenCode global and disabled project configuration
Read native global config and home settings, forward project-disable flag and track configuration identity. Verified 127 native configuration and main security tests.
2026-11-07 09:32:10 +08:00
Ryder Sun 9f2789fd59 Respect OpenCode provider enablement during credential discovery
Filter native credential discovery by unambiguous enabled and disabled provider settings, including shell-resolved values. Verified 172 tests and desktop packaging.
2026-11-07 09:30:27 +08:00
Ryder Sun 533b6ef9ff Support OpenCode native default provider discovery
Capture present native provider credentials when no model is configured, including catalogs larger than 128 entries, without exposing them to other Agents. Verified 170 tests and desktop packaging.
2026-11-07 09:27:27 +08:00
Ryder Sun 46c88f9eac Resolve OpenCode environment-selected model credentials
Discover provider credentials after resolving a native model environment reference, without sharing them with other Agents. Verified 168 tests and desktop packaging.
2026-11-07 09:24:22 +08:00
Ryder Sun 125f784c3d Invalidate OpenCode verification when model metadata changes
Bind verification to the native model cache used for provider credential discovery, including custom XDG paths. Verified 98 configuration and catalog tests.
2026-11-07 09:21:29 +08:00
Ryder Sun 1cacf22c03 Honor OpenCode recent native model credentials
Resolve recently selected providers from local native state when no model is configured and invalidate verification on state changes. Verified 261 configuration, readiness and security tests.
2026-11-07 09:18:15 +08:00
Ryder Sun 050a7a34dc Discover selected OpenCode provider credentials from native metadata
Read selected model provider env names from the local OpenCode cache without forwarding unrelated credentials. Verified 181 targeted tests, installed metadata and desktop packaging.
2026-11-07 09:15:59 +08:00
Ryder Sun dc12892449 Honor OpenCode native cloud provider environments
Forward Bedrock, Azure and Google settings and invalidate verification when cloud credential files change. Verified 257 native configuration, discovery and catalog tests and desktop packaging.
2026-11-07 09:13:10 +08:00
Ryder Sun 30fdbfd438 Verify V4 task graph ledger durability
Exercise graph cursor persistence and reject invalid dependency states through the real Run Ledger. Verified 121 ledger and orchestration tests.
2026-11-07 09:08:00 +08:00
Ryder Sun 3aa7b39094 Cancel connector response streams after read failures
Release failed HTTP response streams without waiting for cancellation or masking the original error. Verified 76 connector and bridge tests.
2026-11-07 09:06:58 +08:00
Ryder Sun e0a18d0f2a Redact short native credentials from CLI output
Treat standard credential environment names as sensitive regardless of value length, including during discovery. Verified 99 native environment, discovery and process tests.
2026-11-07 09:06:07 +08:00
Ryder Sun 3df757707a Use native environment contract in default Agent discovery
Make standalone detection honor Agent-scoped credentials and cloud settings while preserving OpenClaw isolation. Verified 163 discovery, readiness and process tests.
2026-11-07 09:05:00 +08:00
Ryder Sun c6d1c506ca Share native environment contract across CLI probes and execution
Use one Agent-scoped credential and configuration mapping for direct capability probes, readiness and execution, with Windows case handling and OpenClaw isolation. Verified 259 focused tests and desktop packaging.
2026-11-07 09:03:22 +08:00
Ryder Sun 640b93f997 Preserve task graph in V4 phase checkpoints
Carry the durable cursor forward without sharing mutable state when rebuilding phase records. Verified 137 task graph, orchestration and natural discussion tests.
2026-11-07 08:50:29 +08:00
Ryder Sun c6cf99338c Invalidate verification when default AWS credentials change
Track native default AWS configuration and credential files unless explicitly overridden. Verified 173 configuration, catalog and main security tests.
2026-11-07 08:47:53 +08:00
Ryder Sun aa4c90660a Track cloud credential files in Agent verification
Bind Pi and Claude verification to selected AWS, Google and Azure credential file identities, including project-relative paths. Verified 177 readiness and catalog tests.
2026-11-07 08:46:43 +08:00
Ryder Sun 8b6fd95680 Share native environment rules with CLI execution
Use readiness credential and configuration selection for native child processes to prevent probe and execution drift and cross-Agent credential leakage. Verified 334 focused tests and desktop packaging.
2026-11-07 08:43:46 +08:00
Ryder Sun ebbd73d8d5 Reject conflicting CLI versions on the same line
Inspect every product identity in version output to avoid selecting one of several contradictory versions. Verified 65 compatibility and discovery tests.
2026-11-07 08:41:11 +08:00
Ryder Sun 7104847430 Track relative Gemini and Qwen configuration during verification
Invalidate project-scoped Agent verification when native system settings, defaults or Gemini credentials change. Verified 90 native configuration and catalog tests.
2026-11-07 08:40:10 +08:00
Ryder Sun 9d740e71c5 Reject premature V4 task graph completion
Validate terminal state against every graph node while retaining legacy cursor compatibility. Verified 54 orchestration and task graph tests.
2026-11-07 08:38:49 +08:00
Ryder Sun 46e8220e43 Validate task graph dependencies in V4 checkpoints
Reject V4 task graph records that claim dependent work ran before prerequisites; preserve legacy cursor parsing. Verified 53 orchestration and task graph tests.
2026-11-07 08:37:56 +08:00
Ryder Sun 2a71019caa Follow XDG config root for OpenCode skills
Refresh discovery when the native XDG directory changes while retaining explicitly configured skill roots. Verified skill catalog and main security tests (95 passed).
2026-11-07 08:35:46 +08:00
Ryder Sun 762f60a130 Discover skills from selected native configuration paths
Respect Gemini home and include OpenCode custom configuration directories during skill discovery. Verified native config and skill catalog tests (48 passed).
2026-11-07 08:34:49 +08:00
Ryder Sun d9e30d6801 Release failed connector response streams
Discard HTTP error bodies without waiting for cancellation, preventing failed connector and bridge requests from retaining streams. Verified with 75 connector, bridge and HTTP response tests.
2026-11-07 08:33:10 +08:00
Ryder Sun 47836a04d2 Validate natural consensus artifact ownership
Reject missing or unrelated artifacts before accepting group consensus. Verified with natural discussion, agent-led consensus and writer tests (108 passed).
2026-11-07 08:30:10 +08:00
Ryder Sun 12275575c5 Keep native project paths out of public snapshots
Preserve private project configuration identity across catalog refresh and in-flight Agent snapshots without copying paths into public workspace snapshots. Checks: Agent catalog, main security and workspace tests; desktop pack.
2026-11-07 08:17:34 +08:00
Ryder Sun c6de6239a0 Release stalled HTTP readers on cancellation
Cancel the underlying read and settle bounded response processing even when the stream ignores cancellation. Checks: bounded response, cloud bridge and Connector SDK tests; desktop pack.
2026-11-07 08:15:36 +08:00
Ryder Sun 890f652b7d Track relative native configuration in project verification
Bind OpenCode and MiMo project verification to explicitly selected relative configuration files and directories without exposing paths to the renderer. Checks: native configuration, main security, Agent catalog, routing and workspace tests; desktop pack.
2026-11-07 08:14:09 +08:00
Ryder Sun 0ba6668ac8 Track OpenCode and MiMo external configuration identities
Invalidate Agent verification when explicitly selected or merged native configuration files change. Checks: native directory, Agent catalog and main security tests; desktop pack.
2026-11-07 08:11:11 +08:00
Ryder Sun 94a98b6ab9 Scope Codex and Claude verification to project settings
Prevent automatic routing from reusing verification across project directories and native project configuration changes. Checks: Agent catalog, routing and workspace tests; desktop pack.
2026-11-07 07:59:22 +08:00
Ryder Sun e0b448f236 Match Zcode native project override precedence
Avoid inheriting a replaced global model or provider when discovering credentials and checking readiness. Checks: native readiness and CLI environment tests; desktop pack.
2026-11-07 07:55:33 +08:00
Ryder Sun f4e3ac75b3 Resolve Zcode readiness from native project configuration
Share native configuration layer order between credential discovery and readiness when a work directory is supplied. Checks: native readiness, CLI environment and Agent catalog tests; desktop pack.
2026-11-07 07:53:57 +08:00
Ryder Sun 58d4706311 Scope Qwen verification to project settings
Prevent automatic routing from reusing Qwen verification after a project or native project settings change. Checks: Agent catalog, routing and workspace tests; desktop pack.
2026-11-07 07:52:19 +08:00
Ryder Sun 5dc260ca34 Honor Qwen native authentication settings
Forward native system settings and bind verification to Qwen authentication files. Checks: native readiness, configuration, Agent catalog and main security tests; desktop pack.
2026-11-07 07:51:02 +08:00
Ryder Sun 84896722f5 Clarify Connector network enforcement boundary
Distinguish enforced SDK HTTP destinations from network access made directly by native Agent CLIs. Checks: Connector conformance suite and git diff --check.
2026-11-07 07:49:28 +08:00
Ryder Sun ca5c913597 Support MiMo native configuration directory selection
Use the selected native directory for MiMo credential discovery and execution, and invalidate project verification on root config changes. Checks: frontend tests and build, native configuration and security tests, Agent catalog tests, desktop pack.
2026-11-07 07:47:48 +08:00
Ryder Sun bd3ed50b07 Keep independent Agent verification across projects
Retain bounded per-project successful verifications and authentication cooldowns through other project runs and restart. Checks: Agent catalog, routing and workspace tests; desktop pack.
2026-11-07 07:46:02 +08:00
Ryder Sun 339312cfe1 Preserve verified routing across project auth failures
Keep another project’s verified success and the failed project’s cooldown independently through refresh and later runs. Checks: Agent catalog, routing and workspace tests; desktop pack.
2026-11-07 07:43:33 +08:00
Ryder Sun 96481946e4 Allow project authentication retry after cooldown
Evaluate project-scoped authentication cooldown on invocation so retry works without a catalog refresh, including after restart. Checks: Agent catalog, routing and workspace tests; desktop pack.
2026-11-07 07:41:34 +08:00
Ryder Sun 3e293016a8 Scope project Agent authentication failures
Prevent a project-specific authentication failure from blocking other projects while retaining cooldown and stale-result protection. Checks: Agent catalog, routing, workspace and manual recovery tests; desktop pack.
2026-11-07 07:39:48 +08:00
Ryder Sun e5371b8a2b Scope Agent routing verification to project configuration
Bind project-sensitive Agent verification to the working directory and native project files, rejecting stale and cross-project success. Checks: routing, Agent catalog, workspace and V4 convergence tests; desktop pack.
2026-11-07 07:36:47 +08:00
Ryder Sun 72398d9dce Track Gemini default authentication files in verification
Invalidate cached verification when native default system configuration or application credentials change. Checks: native configuration, Agent catalog and main security tests; desktop pack.
2026-11-07 07:32:53 +08:00
Ryder Sun bcfb081e2c Invalidate Gemini verification when selected cloud files change
Bind successful verification to explicitly selected native system settings and cloud credentials. Checks: native configuration, Agent catalog and main security tests; desktop pack.
2026-11-07 07:31:29 +08:00
Ryder Sun 178b8a1b59 Match Pi Cloudflare credential merge semantics
Validate stored and environment fields using native precedence before reporting readiness. Checks: readiness and CLI environment tests, desktop pack.
2026-11-07 07:30:10 +08:00
Ryder Sun 4e4ba35900 Preserve Pi Cloudflare account configuration
Pass required account and gateway identifiers to Pi and avoid declaring environment-only configurations ready when they are incomplete. Checks: readiness and CLI environment tests, desktop pack.
2026-11-07 07:29:00 +08:00
Ryder Sun d53e23859c Preserve Pi native Copilot authentication
Capture and scope Pi’s documented GitHub Copilot token through its login shell and subprocess. Checks: readiness and CLI environment tests, desktop pack.
2026-11-07 07:27:47 +08:00
Ryder Sun fd1fbc7e0a Preserve Pi built-in provider credentials
Forward native built-in provider keys through discovery and execution and evaluate the selected provider without exposing keys to other Agents. Checks: readiness and CLI environment tests, desktop pack.
2026-11-07 07:26:41 +08:00
Ryder Sun c628f50f2e Report Connector stops before send as known failures
Preserve unknown remote outcome only after a request is sent. Checks: Connector conformance suite and desktop pack.
2026-11-07 07:24:28 +08:00
Ryder Sun ca0ed07d8b Honor Gemini native authentication selection
Forward the CLI authentication override from the login shell without exposing it to other Agents. Checks: readiness tests and desktop pack; full desktop suite passed before this change.
2026-11-07 07:23:13 +08:00
Ryder Sun 80b6a10f19 Preserve Gemini system configuration paths
Forward native settings and trusted-folder paths to the Gemini CLI so it uses the same configuration as the shell. Checks: local-agent-readiness tests and desktop pack.
2026-11-07 07:12:46 +08:00
Ryder Sun f15f35826b Bind final candidate evidence to its exact content reference
Apply the same artifact reference validation to final candidate commits as other acceptance paths. Checks: V4 convergence tests and desktop pack.
2026-11-07 07:10:00 +08:00
Ryder Sun d16af4c68c Stop Connector preparation when trust is revoked
Track HTTP operations during outbound recording so disable, revocation and deletion interrupt pending preparation before any request is sent. Checks: Connector SDK tests and desktop pack.
2026-11-07 07:08:23 +08:00
Ryder Sun e87031a31f Allow stopping Connector during outbound recording
Race outbound recording against transport cancellation before sending the request. Checks: Connector SDK tests and desktop pack.
2026-11-07 07:07:34 +08:00
Ryder Sun 06dfabbee5 Forward Gemini native cloud authentication settings
Preserve the installed CLI’s native cloud authentication and endpoint environment without passing it to other agents. Checks: local-agent-readiness tests and desktop pack.
2026-11-07 07:06:27 +08:00
Ryder Sun 220a821240 Preserve Gemini Vertex AI native selection
Forward the documented Vertex AI switch from the login shell to Gemini without exposing it to other agents. Checks: local-agent-readiness tests and desktop pack.
2026-11-07 07:04:52 +08:00
Ryder Sun 66e5d4673b Bound HTTP transport waits even when abort is ignored
Settle cloud and Connector calls on cancellation or timeout, cancel response readers and discard late response bodies. Checks: 90 HTTP, cloud and Connector tests; desktop pack.
2026-11-07 07:02:21 +08:00
Ryder Sun 574828196c Match Unicode group identities in permission checks
Use the same stable identifier mapping as context packs to retain session and read-only recovery support for Unicode group IDs. Checks: Unicode and recovery regressions; full desktop suite; desktop pack.
2026-11-07 06:59:42 +08:00
Ryder Sun ea3e7c879c Reject cloud replies arriving after cancellation
Recheck transport cancellation after response headers and body so timed-out or stopped requests cannot report late success. Checks: cloud and Connector tests; full desktop suite; desktop pack.
2026-11-07 06:59:42 +08:00
Ryder Sun 4caafc7d29 Restore task permissions from durable context packs
Keep read-only execution bounds when message metadata is missing and reject context packs bound to another task or group. Checks: 41 permission, context and recovery tests; desktop pack.
2026-11-07 06:38:36 +08:00
Ryder Sun 9e262a978e Preserve explicit read-only limits throughout a task
Persist an explicit task permission cap and enforce it during preparation, execution, regeneration and recovery even in writable conversations. Checks: 129 routing and recovery tests, targeted direct and restart tests, desktop pack.
2026-11-07 06:35:20 +08:00
Ryder Sun b3282dd608 Fall back to eligible read-only teams when needed
Keep automatic routing usable when verified writers do not satisfy the task while preserving explicit write requirements. Checks: 18 routing and workspace tests; desktop pack.
2026-11-07 06:27:14 +08:00
Ryder Sun 5b67a85819 Support mixed permissions in automatic collaboration
Allow verified read-only peers alongside a writable Agent in automatically formed teams while preserving explicit write requirements and per-Agent execution permissions. Checks: 17 routing and workspace tests; desktop pack.
2026-11-07 06:25:19 +08:00
Ryder Sun 935d8c48ce Ignore unverified writers when routing Agents
Choose default permissions from available verified candidates so unusable writers cannot exclude a runnable read-only Agent. Checks: routing and workspace tests; desktop pack.
2026-11-07 06:23:00 +08:00
Ryder Sun 1344fa3859 Bind authentication failures to the originating Agent
Ignore late results from removed Agents and clear stale authentication failures when the installation or native identity changes. Checks: 62 catalog and routing tests; desktop pack.
2026-11-07 06:20:11 +08:00
Ryder Sun 9073eb4e2e Verify Connector retry safety in V4 group runs
Exercise both orchestration versions and assert the durable unknown-outcome record after a non-retryable Connector failure. Checks: focused workspace harness tests.
2026-11-07 06:18:02 +08:00
Ryder Sun 21fc213730 Respect Connector retry decisions in group recovery
Preserve non-retryable Connector failures and classify interrupted network or timeout outcomes as uncertain to prevent duplicate execution. Checks: 55 failure-policy and Connector tests, workspace invocation and group regression tests, desktop pack.
2026-11-07 06:16:36 +08:00
Ryder Sun 48606bd574 Preserve uncertain Connector outcomes in workspace runs
Do not classify an unknown remote Connector outcome as a confirmed failure when recording invocation recovery metadata. Checks: 24 Connector and Harness tests; desktop pack.
2026-11-07 06:14:48 +08:00
Ryder Sun dbc873d2d8 Bind cloud replies to the verified server identity
Check server identity and declared capabilities before sending and after receiving a cloud response, reporting changed targets as an unknown outcome. Checks: 81 cloud and Connector tests; desktop pack.
2026-11-07 06:12:08 +08:00
Ryder Sun c32903b12d Abort active runs when a cloud bridge is removed
Reject late replies and keep remote outcome unknown when a server is deleted, without interrupting other servers. Checks: 76 cloud and Connector tests; desktop pack.
2026-11-07 06:10:36 +08:00
Ryder Sun c861c8644d Preserve remote outcome uncertainty on cloud stop
Abort cloud bridge transports regardless of declared remote cancellation support and report an unknown non-retryable outcome instead of claiming the remote task stopped. Checks: 73 cloud and Connector tests; desktop pack.
2026-11-07 06:08:47 +08:00
Ryder Sun a657b6cc11 Clear declared credentials removed between shell captures
Propagate explicit unsets for declared native credentials even when the application environment did not contain them, preventing stale values from an earlier capture. Checks: 151 readiness, discovery and process-support tests.
2026-11-07 06:06:16 +08:00
Ryder Sun 74ebf2c10b Respect platform casing for native CLI environment
Use exact environment names on Unix while retaining case-insensitive Windows lookup for paths, configuration directories and process settings. Checks: 150 discovery, process environment and readiness tests.
2026-11-07 06:04:53 +08:00
Ryder Sun 859efaddd9 Validate Connector upstream version before configuration
Reject incompatible upstream versions before persisting instances or credentials and explain the failure in both supported languages. Checks: 33 Connector tests, 407 frontend tests and desktop frontend build.
2026-11-07 06:03:00 +08:00
Ryder Sun 9f548abfd8 Preserve identified agent version through discovery
Avoid reinterpreting a preceding runtime version as the Agent version in compatibility checks and Connector registration. Checks: 73 compatibility, discovery and Connector tests.
2026-11-07 05:50:32 +08:00
Ryder Sun 8c0620c18a Avoid claiming automatic config when loading fails
Keep native configuration state unknown until the desktop API returns it, so a read failure cannot falsely imply automatic discovery. Checks: AgentNativeConfig tests and desktop frontend build.
2026-11-07 05:48:46 +08:00
Ryder Sun f7d21efe33 Reject impossible task graph ordering before starting
Validate terminal dependencies on new workflows before recording a message or starting a run, while preserving parsing of existing durable graphs. Checks: task graph records and 37 orchestration and recovery tests.
2026-11-07 05:47:42 +08:00
Ryder Sun 833870de0d Honor unexported native shell settings
Remove inherited values when login shells keep them as local variables, aligning discovery with the environment passed to CLI processes. Checks: 92 readiness, discovery and process-support tests.
2026-11-07 05:45:05 +08:00
Ryder Sun 2509be188e Limit zsh Vertex discovery to exported variables
Match the environment inherited by native CLI processes when discovering dynamic model regions.\n\nChecks: readiness, CLI discovery and process support tests (89 passed).
2026-11-07 05:43:56 +08:00
Ryder Sun e9e8aeb184 Guard cloud bridge targets before sending
Revalidate bridge identity, destination, agent and cancellation after outbound review to prevent stale or changed targets from receiving a request.\n\nChecks: cloud and connector tests (64 passed); desktop npm run pack.
2026-11-07 05:42:25 +08:00
Ryder Sun 17c8a26c21 Avoid identifying runtime warnings as Agent versions
Require an adjacent product version while continuing capability probes for ambiguous output. Verified compatibility, discovery and catalog tests, live CLI detection and desktop packaging.
2026-11-07 05:40:20 +08:00
Ryder Sun f011c03977 Verify Connector revocation during response streaming
Exercise a real local HTTP stream and confirm revocation closes the connection without asserting a remote outcome. Verified Connector conformance.
2026-11-07 05:36:00 +08:00
Ryder Sun ad7ed95851 Abort active HTTP Connector requests when trust changes
Stop affected local transports on revocation, disable or instance deletion and preserve unknown remote outcome without automatic retry. Verified Connector conformance and desktop packaging.
2026-11-07 05:35:06 +08:00
Ryder Sun 9c5d46549d Recheck Connector trust before HTTP dispatch
Prevent a pending request from sending after its package trust or instance changes during outbound recording. Verified Connector conformance and desktop packaging.
2026-11-07 05:33:10 +08:00
Ryder Sun eb8ee15679 Reject duplicate task graph decision options
Validate human decision IDs before creating a new run while preserving historical graph loading. Verified task graph, workspace tests and desktop packaging.
2026-11-07 05:31:22 +08:00
Ryder Sun 1eb19e0774 Scope Zcode readiness to the selected native provider
Avoid treating unrelated provider or fallback model credentials as readiness for the active native model. Verified readiness, catalog and security tests and desktop packaging.
2026-11-07 05:29:31 +08:00
Ryder Sun a30da5cd5a Invalidate stale group review approvals
Revoke an Agent approval when its later review is stale or unbound so consensus remains tied to the current candidate. Verified natural discussion, convergence tests and desktop packaging.
2026-11-07 05:27:46 +08:00
Ryder Sun aac1eff0ac Withdraw stale group approvals after unclassified replies
Require a fresh candidate when a later contribution has no decision receipt, preventing old votes from hiding a possible objection. Verified natural discussion, convergence tests and desktop packaging.
2026-11-07 05:25:12 +08:00
Ryder Sun 7eb619e0ea Validate task graph dependencies before execution
Reject impossible node progress at execution and update boundaries while allowing historical cursor records to load. Verified task graph, recovery and scheduling tests and desktop packaging.
2026-11-07 05:22:24 +08:00
Ryder Sun ffaea9ff87 Invalidate Zcode verification when native config changes
Bind readiness verification to the native user configuration without exposing paths or credentials. Verified identity, catalog, security tests and desktop packaging.
2026-11-07 05:17:34 +08:00
Ryder Sun e086117556 Enforce Connector file types before structured input
Classify MIME-bearing non-media attachments as files and prevent structured-data declarations from bypassing file capability checks. Verified Connector conformance and desktop packaging.
2026-11-07 05:16:29 +08:00
Ryder Sun e7eabe6147 Prevent Connector media type spoofing
Treat ordinary file MIME types as files even when an attachment claims a media type. Verified Connector conformance and desktop packaging.
2026-11-07 05:15:39 +08:00
Ryder Sun 371d85c62b Align Connector attachment checks with declared capabilities
Match the workspace attachment limit and enforce media declarations before submission and in the composer. Verified Connector conformance, workspace and frontend tests, builds and desktop packaging.
2026-11-07 05:14:33 +08:00
Ryder Sun b908deea1d Match Zcode native provider key selection
Remove unsupported apiKeyEnv inference and use model-level provider names and kinds from the native adapter. Verified readiness, CLI, security tests and desktop packaging.
2026-11-07 05:05:57 +08:00
Ryder Sun 9250f0e933 Honor Zcode provider apiKeyEnv references
Capture native per-provider credential names and the gateway default without sharing credentials across projects. Verified readiness, CLI, security tests and desktop packaging.
2026-11-07 05:04:32 +08:00
Ryder Sun cfd4b160b6 Match Zcode native model and home resolution
Honor the inherited user directory and Anthropic default for unqualified ZCODE_MODEL values. Verified targeted readiness, CLI and security tests and desktop packaging.
2026-11-07 04:55:04 +08:00
Ryder Sun af01f2adf0 Honor Zcode project provider configuration
Discover selected native project model credentials and preserve Zcode network and model environment. Verified readiness, CLI, main security tests and desktop packaging.
2026-11-07 04:53:35 +08:00
Ryder Sun 193dd5da07 Scope Hermes credential files to the selected provider
Check native provider-specific auth pools and configuration instead of unrelated provider credentials. Verified readiness, catalog, security tests and desktop packaging.
2026-11-07 04:50:48 +08:00
Ryder Sun 738678b526 Scope Hermes dotenv readiness to the selected provider
Recognize declared native dotenv credential names without treating other providers as authenticated. Verified readiness, catalog, security tests and desktop packaging.
2026-11-07 04:49:42 +08:00
Ryder Sun c28a6e66d1 Scope Hermes readiness to its selected provider
Avoid treating another provider environment key as authenticated when native configuration selects a single provider. Verified readiness, catalog, CLI, security tests and desktop packaging.
2026-11-07 04:48:48 +08:00
Ryder Sun 030cccf669 Align Codex readiness with its selected provider
Use the configured provider env_key rather than unrelated account credentials to determine native authentication state. Verified readiness, catalog, CLI, security tests and desktop packaging.
2026-11-07 04:47:26 +08:00
Ryder Sun 3a9de6966f Honor Codex native provider credential references
Capture the selected provider env_key for discovery and execution while preserving Connector isolation and redaction. Verified targeted readiness, CLI, main security tests and desktop packaging.
2026-11-07 04:45:41 +08:00
Ryder Sun 3350485e77 Scope Pi readiness to the selected provider
Check the selected native provider rather than unrelated account credentials and preserve unverified fallback. Verified readiness, catalog and security tests and desktop packaging.
2026-11-07 04:42:27 +08:00
Ryder Sun 9d26ecdc20 Resolve Pi native model credentials before readiness
Avoid reporting unresolved environment references or unevaluated commands as configured credentials and honor the selected provider. Verified readiness and security tests and desktop packaging.
2026-11-07 04:39:46 +08:00
Ryder Sun ef8b1b8ae3 Recognize and track OpenCode native authentication
Detect the CLI native API auth format and invalidate successful verification when its XDG authentication file changes. Verified readiness, catalog and security tests and desktop packaging.
2026-11-07 04:37:51 +08:00
Ryder Sun 2c5da3e3df Verify concurrent Connector account isolation
Exercise two independent credentials at the HTTP transport boundary with an unrelated global provider credential. Verified Connector conformance suite.
2026-11-07 04:34:54 +08:00
Ryder Sun 66ca299b7c Align Connector session references with workspace persistence
Use one session reference contract at Connector and workspace boundaries so accepted references survive reload without accepting secret-like values. Verified Connector conformance, workspace state tests and desktop packaging.
2026-11-07 04:33:52 +08:00
Ryder Sun 0a38ddd80d Validate Connector session references at runtime
Reject malformed and oversized session references before invocation or persistence and explain the error in both locales. Verified Connector conformance, frontend tests and desktop packaging.
2026-11-07 04:31:48 +08:00
Ryder Sun 3a529c7b83 Reject undeclared Connector progress results
Prevent non-legacy Connector results from bypassing event capability checks while preserving trusted native compatibility. Verified Connector conformance and desktop packaging.
2026-11-07 04:29:57 +08:00
Ryder Sun d9392924aa Recover completed task graph results after interruption
Reconcile only uniquely matched durable Agent messages and completed runs before resuming a task graph, without replaying uncertain work. Verified graph recovery, ledger tests and desktop packaging.
2026-11-07 04:28:05 +08:00
Ryder Sun 03d25b8330 Resume task graphs from durable idle boundaries
Recover interrupted task graphs only when no node or Agent invocation remains active, preserving completed work without replaying uncertain side effects. Verified graph recovery, V4 recovery, ledger tests and desktop packaging.
2026-11-07 04:24:48 +08:00
Ryder Sun abd9f544b0 Stop parallel task graph work on immediate hard failures
Persist failed branches as soon as they finish and propagate circuit breaker or budget stops without waiting for other branches. Verified task graph recovery, ledger and desktop packaging.
2026-11-07 04:20:14 +08:00
Ryder Sun 50e8517912 Preserve parallel task graph progress across gate recovery
Checkpoint completed parallel nodes immediately and resume the remaining gated node without replaying completed work. Verified task graph, restart recovery and ledger tests and desktop packaging.
2026-11-07 04:16:57 +08:00
Ryder Sun ec5ecbc3a7 Support selecting Gemini native configuration
Map a selected .gemini directory to the CLI native home while preserving symlink semantics and verification identity. Verified readiness, security and frontend tests and desktop packaging.
2026-11-07 04:11:19 +08:00
Ryder Sun f74a6ed096 Bind Gemini verification to native credential files
Invalidate stale successful verification when the selected Gemini native configuration changes. Verified directory, catalog and main security tests, plus desktop packaging.
2026-11-07 04:09:23 +08:00
Ryder Sun 1cbaaa9990 Honor Gemini native home during discovery and execution
Forward GEMINI_CLI_HOME and inspect credentials in the same native home selected by the CLI. Verified readiness and process security tests and desktop packaging.
2026-11-07 04:07:30 +08:00
Ryder Sun 1c4266f503 Support selecting OpenCode native configuration directory
Apply the chosen directory to provider discovery and CLI execution with localized settings guidance. Verified native config and security tests, frontend tests and desktop packaging.
2026-11-07 04:05:27 +08:00
Ryder Sun 769031edfd Honor OpenCode native provider environment references
Read declared provider and model variables from native OpenCode configuration for discovery and execution while isolating and redacting their values. Verified readiness, security, ACP protocol tests and desktop packaging.
2026-11-07 04:01:54 +08:00
Ryder Sun 0bae4004e7 Preserve larger native Agent credential declarations
Allow up to 128 declared variables per Agent while retaining bounded shell batches and output redaction. Verified native readiness, process and main security tests, plus desktop packaging.
2026-11-07 03:58:07 +08:00
Ryder Sun 6b8c2c7ce8 Capture declared credentials in bounded shell batches
Prevent combined native Agent credential declarations from being truncated while preserving shell-unset values and Agent isolation. Verified native readiness and main security tests and desktop packaging.
2026-11-07 03:55:33 +08:00
Ryder Sun d0e42fe0da Reject Connector waits that cannot resume
Prevent non-resumable Connectors from leaving conversations waiting for input or permission that cannot be submitted. Verified Connector conformance and desktop packaging.
2026-11-07 03:53:22 +08:00
Ryder Sun e86dd9e0ca Honor Pi native provider environment references
Capture only declared model and header environment variables for Pi discovery and execution and redact their values. Verified readiness, CLI process and main security tests and desktop packaging.
2026-11-07 03:51:43 +08:00
Ryder Sun 4a1f9a64ba Require every task graph node to finish before success
Normalize legacy graph cursors before enforcing round limits so unfinished or failed nodes cannot be reported as completed. Verified graph, ledger and group workflow tests and desktop packaging.
2026-11-07 03:48:42 +08:00
Ryder Sun 55686d0f10 Invalidate agent verification when native environment changes
Bind successful readiness verification to a process-keyed fingerprint of the selected native runtime environment, without persisting credentials. Verified catalog, native configuration and main-process security tests and desktop packaging.
2026-11-07 03:46:44 +08:00
Ryder Sun 6cbf618eb0 Preserve native AWS SDK settings for cloud agents
Carry native SDK discovery and retry settings into Claude and Pi probes and execution. Verified readiness and CLI process environment tests.
2026-11-07 03:43:41 +08:00
Ryder Sun a0888914c9 Fix rejected intermediate task graph gates
Stop graphs when an intermediate human decision is rejected and normalize legacy cursors on recovery. Verified task graph and ledger tests, workspace task graph tests, and desktop packaging.
2026-11-07 03:41:30 +08:00
Ryder Sun fb73e11af1 Keep Connector credential values out of renderer configuration
Restrict the legacy renderer IPC to credential-free instances and use the confirmed native environment flow for credentials. Verified 148 conformance tests and desktop packaging.
2026-11-07 03:38:33 +08:00
Ryder Sun 73f10b85e2 Configure Connector credentials from native shell
Allow users to bind credential slots to environment variable names while keeping secret values in the main process and OS secure storage. Verified 1999 desktop tests, 405 frontend tests, 148 conformance tests, frontend builds and desktop packaging.
2026-11-07 03:36:10 +08:00
Ryder Sun 7a30f32603 Include Connector integration and security in conformance suite
Run local Connector wiring and main-process credential isolation checks with the documented conformance command. Verified 131 tests and desktop packaging.
2026-11-07 03:12:14 +08:00
Ryder Sun 2ee8e7d05c Track discovered native Agent configuration in verification
Invalidate cached routing verification when automatically discovered configuration changes without exposing configuration paths to the renderer. Verified 220 desktop tests and packaging.
2026-11-07 03:10:36 +08:00
Ryder Sun b4a15a7402 Invalidate DeepSeek routing verification on profile changes
Track native profile configuration files and verify automatic routing rejects stale verification without refreshing the catalog. Verified 55 routing tests and desktop packaging.
2026-11-07 03:07:19 +08:00
Ryder Sun 4be378944b Invalidate Agent verification when selected config changes
Bind recent verification to native configuration and credential file identities so changed providers cannot inherit stale automatic-routing trust. Verified 139 workspace tests, 54 focused tests and desktop packaging.
2026-11-07 03:05:44 +08:00
Ryder Sun 75a4bd792e Publish Connector execution records atomically
Prevent interrupted provenance writes from blocking recovery while preserving immutable run bindings. Verified 108 Connector and security tests and desktop packaging.
2026-11-07 03:03:17 +08:00
Ryder Sun 62854754f6 Redact credentials from inline native Agent configuration
Include MiMo and OpenCode JSON or JSONC provider secrets in complete and streamed output redaction. Verified 62 CLI tests and desktop packaging.
2026-11-07 03:01:52 +08:00
Ryder Sun 6a6fa6d0ca Refresh MiMo ACP process after native config changes
Bind persistent ACP processes to a private fingerprint of effective native configuration to avoid reusing stale provider settings. Verified 31 ACP tests, 14 workspace recovery tests and desktop packaging.
2026-11-07 03:00:03 +08:00
Ryder Sun c8dc5a4420 Resolve MiMo relative configuration paths per chat
Use the invocation working directory for relative native config paths and avoid claiming an unknown path during discovery. Verified native CLI behavior, 194 tests and desktop packaging.
2026-11-07 02:57:41 +08:00
Ryder Sun 0acfa91483 Persist private Connector execution provenance
Bind each execution to its instance, versions, recipe and credential reference before invocation without exposing the reference to renderer results. Verified 108 Connector and security tests and desktop packaging.
2026-11-07 02:56:17 +08:00
Ryder Sun 6127340e1b Resolve MiMo project credentials per working directory
Match native project configuration lookup and collect only the active project provider environment reference for each invocation. Verified native MiMo configuration order, 193 tests and desktop packaging.
2026-11-07 02:53:50 +08:00
Ryder Sun 45868c6635 Resolve MiMo native configuration overrides
Read provider references from global, explicit, directory and inline JSON or JSONC settings in native precedence order. Verified 191 readiness and security tests and desktop packaging.
2026-11-07 02:51:05 +08:00
Ryder Sun 054f15e7a2 Honor MiMo native provider environment references
Capture the active provider credential from the native shell for MiMo detection and execution, isolate it from other Agents, and redact it from child output. Verified 190 tests and desktop packaging.
2026-11-07 02:48:56 +08:00
Ryder Sun 2247510b8e Avoid claiming remote Connector cancellation without confirmation
Treat aborted HTTP transport after dispatch as an unknown remote outcome regardless of declared cancellation support. Verified 36 Connector tests and desktop packaging.
2026-11-07 02:45:49 +08:00
Ryder Sun 1d2bcbaacd Start Connector timeout at HTTP request
Exclude outbound recording from the network timeout and prevent sending after cancellation during recording. Verified 35 Connector tests and desktop packaging.
2026-11-07 02:43:59 +08:00
Ryder Sun fb8af87891 Allow choosing Kimi native configuration directory
Use the selected Kimi home consistently for discovery, execution and skills. Verified 150 desktop tests, 402 frontend tests, both frontend builds and desktop packaging.
2026-11-07 02:42:06 +08:00
Ryder Sun fce6d30564 Read Kimi credentials from its selected native home
Avoid reporting stale default-directory credentials when KIMI_CODE_HOME selects another location. Verified 123 readiness and discovery tests and desktop packaging.
2026-11-07 02:40:16 +08:00
Ryder Sun 4f1f954d67 Honor shell unsets in agent processes
Prevent stale proxy and configuration-directory variables from being inherited by native Agent children after shell unsets. Verified 141 environment and security tests and desktop packaging.
2026-11-07 02:38:18 +08:00
Ryder Sun 06dc55beb7 Honor native shell credential unsets
Prevent stale inherited Provider settings from being restored after the native shell unsets them, while retaining fallback behavior when shell capture fails. Verified 179 readiness, discovery and security tests and desktop packaging.
2026-11-07 02:37:10 +08:00
Ryder Sun 3d5f9bed0f Preserve Claude Bedrock container credentials
Forward native AWS container credential settings to Claude discovery and execution without exposing them to unrelated agents. Verified 121 readiness and CLI discovery tests.
2026-11-07 02:34:45 +08:00
Ryder Sun 048d0764a0 Separate unverified Agent access from authentication
Report unknown and credential-only state accurately while preserving explicit manual use and verified automatic routing. Verified workspace, routing and security tests, Electron group flow, and desktop packaging.
2026-11-07 02:33:33 +08:00
Ryder Sun 3b3df763c4 Reject unverified durable Connector idempotency
Avoid unsafe recovery assumptions when SDK providers only forward operation IDs without durable deduplication. Verified legacy package handling, Connector conformance, and desktop packaging.
2026-11-07 02:30:00 +08:00
Ryder Sun f6318db809 Bound Connector HTTP transport lifetime
Abort stalled or stopped HTTP requests without claiming unconfirmed remote cancellation. Verified local streaming, Connector conformance, watchdog tests, and desktop packaging.
2026-11-07 02:28:26 +08:00
Ryder Sun 58862159fa Reject unsupported Connector SDK capabilities
Enforce provider capability claims while preserving management of legacy packages and keeping unrelated Connectors available. Verified conformance, security, frontend tests, builds, and packaging.
2026-11-07 02:24:31 +08:00
Ryder Sun dacb581a1e Avoid overstating Codex authentication readiness
Do not treat OPENAI_API_KEY alone as proof of default Codex authentication while preserving it for native custom providers. Verified readiness, catalog and security tests plus desktop packaging.
2026-11-07 02:20:33 +08:00
Ryder Sun d04656b127 Use native Codex API key for isolated accounts
Pass CODEX_API_KEY to the default Codex provider for isolated Connectors and native local runs, with scoped capture and redaction. Verified the real CLI against a local endpoint, security tests, and desktop packaging.
2026-11-07 02:19:17 +08:00
Ryder Sun bd7832de00 Capture Hermes qualified model provider credentials
Honor configured provider/model routing in automatic mode without interpreting aggregator slugs as provider overrides. Verified readiness and security tests plus desktop packaging.
2026-11-07 02:14:44 +08:00
Ryder Sun 60251525cb Avoid conflicting Connector install actions
Offer upgrade when another version is installed and document the available package management UI. Verified frontend tests, Connector conformance, builds, and desktop packaging.
2026-11-07 02:11:58 +08:00
Ryder Sun b26312dfd7 Isolate native configuration per credentialed Connector
Prevent global Codex and XDG profiles from overriding Connector account credentials while retaining per-instance sessions. Verified child-process isolation, Connector conformance, real CLI credential selection, and desktop packaging.
2026-11-07 02:08:05 +08:00
Ryder Sun eb85ee8bd9 Accept Hermes env-only native configuration directories
Allow valid Hermes .env-only directories and align bilingual settings guidance. Verified native config tests, frontend tests, builds, and desktop packaging.
2026-11-07 01:58:23 +08:00
Ryder Sun 0832b34f75 Reject prefixed permission errors as task completion
Recognize direct permission-denied diagnostics even when a contradictory receipt claims success. Verified all natural discussion tests.
2026-11-07 01:53:01 +08:00
Ryder Sun 7f8b195c46 Honor Hermes provider selection without config file
Capture environment-selected native credentials when config.yaml is absent while preserving file precedence and isolation. Verified readiness and main-process security tests.
2026-11-07 01:50:29 +08:00
Ryder Sun 9da5541115 Forward selected Hermes provider environment credentials
Capture conventional native provider keys for selected and fallback routes without overriding models or endpoints. Verified readiness and main-process security tests.
2026-11-07 01:49:13 +08:00
Ryder Sun 8d4fa426b5 Honor Hermes native fallback provider credentials
Capture declared fallback_providers credentials alongside legacy fallback_model without forwarding unrelated secrets. Verified readiness and main-process isolation tests and the native fallback resolver.
2026-11-07 01:45:29 +08:00
Ryder Sun fc29c87c4b Bind Connector approvals to their package contents
Reject replaced package files and normalize malformed endpoint validation so unapproved content cannot inherit trust. Verified Connector conformance and desktop packaging.
2026-11-07 01:43:28 +08:00
Ryder Sun 42a82f12b5 Honor Connector session and HTTP outcome contracts
Keep stateless echo sessions empty and preserve remote failures, partial results, and cancellation instead of reporting success. Verified the Connector conformance suite.
2026-11-07 01:41:38 +08:00
Ryder Sun 11bcf8d206 Reject placeholder values in credential readiness
Avoid reporting configured credentials for null and boolean placeholders in native configuration. Verified local agent readiness tests.
2026-11-07 01:39:45 +08:00
Ryder Sun 3b11e82e63 Align Hermes readiness with native config filename
Ignore config.yml because Hermes loads config.yaml, preventing a false credential-ready state. Verified native readiness and config-directory tests.
2026-11-07 01:38:09 +08:00
Ryder Sun b136a9e04e Recognize past-tense failures in group decisions
Prevent contradictory completion receipts from hiding explicit failed attempts. Verified natural discussion regression suite and focused failure recognition tests.
2026-11-07 01:36:05 +08:00
Ryder Sun a21f6bb598 Honor configured Zcode provider credentials
Capture only credentials declared by native model providers to preserve CLI behavior without exposing unrelated shell secrets. Verified agent and security tests, native CLI direct/group execution, and desktop packaging.
2026-11-07 01:33:24 +08:00
Ryder Sun b271e7976b Distinguish independent task graph claims
Allow explicitly scoped primary claims so distinct deliverables do not conflict while shared claims still require resolution.

Checks: task graph and recovery tests; desktop pack.
2026-11-07 01:28:45 +08:00
Ryder Sun d468f4ef8e Prevent task graph completion with unresolved conflicts
Require an arbiter or human decision before terminal results can complete a graph containing unresolved conflicts.

Checks: task graph and recovery tests; desktop pack.
2026-11-07 01:25:56 +08:00
Ryder Sun ac37df35c4 Scope Hermes credential capture to configured providers
Capture only selected and fallback provider credentials while supporting fallback chains and excluding disabled entries.

Checks: native readiness, CLI environment and main security tests; desktop pack.
2026-11-07 01:22:27 +08:00
Ryder Sun 651774cdf5 Bind ACP process reuse to credential redaction policy
Restart the Hermes ACP process when declared secret names change so a reused runtime cannot expose newly sensitive values.

Checks: ACP lifecycle tests; desktop pack.
2026-11-07 01:20:16 +08:00
Ryder Sun daba7acee5 Document Hermes native credential environment
Explain declared credential variables and native dependency preparation for new data directories.

Checks: reviewed both language versions; git diff --check.
2026-11-07 01:18:11 +08:00
Ryder Sun cca6739c13 Honor Hermes credential variables declared in native config
Capture explicitly declared shell credentials for Hermes, block unsafe process variables, and redact their values from runtime and discovery output.

Checks: Agent suite, main security and focused environment tests; desktop pack.
2026-11-07 01:15:55 +08:00
Ryder Sun 2098539921 Preserve Hermes native endpoint environment
Forward native custom, OpenRouter and Ollama endpoint settings from the user shell to Hermes without changing its provider selection.

Checks: native readiness and CLI environment tests; desktop pack.
2026-11-07 01:04:12 +08:00
Ryder Sun ce7741bd5d Remove unsupported Zcode settings option
Zcode 0.16.1 advertises --settings but rejects it, so restore native default configuration and prevent failed invocations.

Checks: native CLI and adapter against an isolated local mock; Zcode and frontend tests; desktop pack.
2026-11-07 00:58:26 +08:00
Ryder Sun 936af81088 Support a selected native Zcode configuration directory
Use the selected config.json for both readiness and the native --settings invocation, keeping provider selection in Zcode.

Checks: frontend tests and builds; desktop native config and main security tests; desktop pack.
2026-11-07 00:56:15 +08:00
Ryder Sun 5da56371ac Preserve native Zcode provider environment
Forward documented native Zcode provider settings through shell capture, readiness and child execution without leaking them to unrelated Agents.

Checks: native readiness and CLI environment tests; desktop pack.
2026-11-07 00:53:29 +08:00
Ryder Sun 2d4003f747 Reject ambiguous Connector package installations
Prevent multiple installed packages for one Connector and fail closed on legacy conflicts instead of selecting an arbitrary provider.

Checks: Connector package and SDK tests; desktop pack.
2026-11-07 00:51:49 +08:00
Ryder Sun ded3500472 Bind authentication failures to the invoked Agent identity
Prevent delayed failures from an older CLI or configuration from marking its replacement signed out in direct and automatic runs.

Checks: catalog, recovery support and invocation tests; desktop pack.
2026-11-07 00:50:24 +08:00
Ryder Sun b67beabfcb Bind connector permission events to the approved permission
Carry the original permission through live and recovered gates and reject a decision event that changes it.

Checks: connector runtime, SDK, invocation and event tests; desktop pack.
2026-11-07 00:48:46 +08:00
Ryder Sun 3ae4db03ed Bind connector permission decision events to resumed gates
Reject permission decision events without a matching resumed request and decision to prevent fabricated approval state.

Checks: connector runtime and run event tests; desktop pack.
2026-11-07 00:47:45 +08:00
Ryder Sun 6466fcebac Update consensus fixtures for validated completion receipts
Keep file integrity and durable consensus tests aligned with the required deliverables contract.

Checks: targeted consensus regression tests; full desktop suite in progress.
2026-11-07 00:41:31 +08:00
Ryder Sun 1f0870ee86 Bind Agent verification to selected native configuration
Invalidate prior readiness when a native configuration directory changes and ignore late results from the previous selection.

Checks: Agent catalog and invocation tests; desktop pack.
2026-11-07 00:36:58 +08:00
Ryder Sun 2cd6f6c457 Bind readiness recovery to the detected installation
Ignore late results and indeterminate-probe grace from replaced executables, preserving accurate invocation readiness.

Checks: Agent catalog tests; desktop pack.
2026-11-07 00:35:25 +08:00
Ryder Sun 8ee20e26ee Keep connector permission callbacks read-only
Reject write and unsafe tool permission requests before interactive approval during read-only runs.

Checks: connector runtime and ACP permission tests; desktop pack.
2026-11-07 00:33:37 +08:00
Ryder Sun ee457b563e Enforce run mode on connector write permission events
Reject workspace-write permission events in read-only runs so a connector cannot escalate the active permission mode.

Checks: connector runtime tests; desktop pack.
2026-11-07 00:32:30 +08:00
Ryder Sun 3b1a618b37 Validate recovered group completion receipts
Reject missing deliverables before an owner completes a discussion or a persisted receipt creates or approves a consensus candidate.

Checks: natural discussion tests; desktop pack.
2026-11-07 00:30:47 +08:00
Ryder Sun c44ccf51d6 Recognize qualified task failure statements
Prevent apologetic permission failures from being accepted as group task completion while retaining quotation safeguards.

Checks: natural discussion tests; desktop pack.
2026-11-07 00:25:58 +08:00
Ryder Sun 5bee16150e Recognize local Hermes failed turns
Treat native failed-turn boundaries as failures even when the legacy CLI exits successfully, so chats cannot mark an unsuccessful request complete.

Checks: CLI adapter and profile tests; desktop pack.
2026-11-07 00:23:33 +08:00
Ryder Sun 3d87add235 Read Hermes legacy state from the active native profile
Use the spawned process configuration for both state watermark and final response recovery, preventing replies from the default profile being used for a different session.

Checks: desktop test:agents; desktop pack.
2026-11-07 00:22:00 +08:00
Ryder Sun 5dbf5f1b82 Recognize remote Hermes failed turns
Treat the native provider failure notice as an unsuccessful remote run even when the Hermes process exits successfully.

Checks: cloud Agent tests (60 passed), focused failed-turn regression; desktop pack.
2026-11-07 00:17:21 +08:00
Ryder Sun d3b66111f7 Respect remote OpenCode process failure after completion
Only accept a terminal result after clean exit or the Bridge’s own termination signal, avoiding false success after a nonzero CLI exit.

Checks: cloud Agent tests (59 passed); desktop pack.
2026-11-07 00:15:37 +08:00
Ryder Sun 34945ac668 Preserve UTF-8 across remote Agent output chunks
Decode Bridge process output incrementally and enforce the byte limit on raw input so split multibyte replies remain intact.

Checks: cloud Agent tests (58 passed); desktop pack.
2026-11-07 00:14:28 +08:00
Ryder Sun 641f59d826 Reject invalid remote Agent terminal output
Do not report cached OpenCode success after a subsequent error or output overflow, and require Codex terminal completion without failure events.

Checks: cloud Agent tests (57 passed); desktop pack.
2026-11-07 00:13:02 +08:00
Ryder Sun 6ba1c9b585 Align native directories across Agent discovery paths
Share supported native directory declarations with direct probes and use remote CLI configuration roots when listing Bridge Skills.

Checks: Agent tests (603 passed, 1 skipped); desktop pack.
2026-11-07 00:10:05 +08:00
Ryder Sun 4a4d8882ec Replay task graph Agent results without duplicating records
Reuse checkpointed result entries during recovery and keep identical conclusions from distinct Agent runs separately attributable.

Checks: task graph and human gate recovery tests (25 passed); desktop pack.
2026-11-07 00:06:38 +08:00
Ryder Sun f627f7cb5f Make task graph human decision recovery idempotent
Reuse the already checkpointed decision after interruption and reject inconsistent replayed choices instead of recording duplicate or contradictory decisions.

Checks: task graph and human gate recovery tests (22 passed); desktop pack.
2026-11-07 00:03:58 +08:00
Ryder Sun 7571f7ff1f Preserve task graph conflicts after human rejection
Keep the rejection decision durable without marking the rejected conflicts resolved; approved decisions continue to resolve them.

Checks: task graph tests (19 passed); desktop pack.
2026-11-07 00:01:35 +08:00
Ryder Sun e0eba65456 Record task graph decisions only after acceptance
Keep conflicts unresolved when an arbiter reports failure or lacks required evidence rather than persisting an invalid decision.

Checks: task graph tests (17 passed); desktop pack.
2026-11-06 23:59:28 +08:00
Ryder Sun 582694b32f Bind task graph evidence to its producer and artifact
Require trusted recorder provenance and direct exact artifact content references before evidence can satisfy a graph node.

Checks: task graph tests (15 passed); V4 receipt and graph record tests (22 passed); desktop pack.
2026-11-06 23:57:35 +08:00
Ryder Sun ba22c5d2c2 Reject explicit failures in task graph acceptance
Treat explicit inability or permission failures as failed nodes even when the CLI reports a completed process outcome.

Checks: task graph tests (14 passed); desktop pack.
2026-11-06 23:54:25 +08:00
Ryder Sun 9c8945fb76 Bind Agent verification to executable identity
Invalidate automatic-routing verification when a detected Agent executable or version changes, including late results from replaced processes.

Checks: routing, catalog and workspace tests (130 passed); desktop pack.
2026-11-06 23:49:48 +08:00
Ryder Sun a2c3e57220 Bind capability probe cache to native environment
Invalidate prior compatibility evidence when configuration, credentials, or other probe environment values change, without storing those values in the cache.

Checks: Agent tests (601 passed, 1 skipped); desktop pack.
2026-11-06 23:46:15 +08:00
Ryder Sun 56a88d7299 Record Agent verification only after result validation
Defer successful run verification until the response has been validated and its message and session have been saved, preventing empty or unpersisted results from qualifying for automatic routing.

Checks: routing, catalog and workspace tests (127 passed); desktop pack.
2026-11-06 23:43:37 +08:00
Ryder Sun 791340d8f6 Exclude partial runs from automatic routing verification
Record authentication readiness for partial results without marking an incomplete Agent invocation as a successful verification.

Checks: routing, catalog, and workspace tests (125 passed); desktop pack.
2026-11-06 23:40:54 +08:00
Ryder Sun f4eab41ab2 Distinguish successful runs from authentication probes
Require a recorded successful invocation for automatic routing; do not treat authentication-only or legacy ambiguous readiness records as verified runs.

Checks: routing, agent catalog, and workspace tests (124 passed); desktop pack.
2026-11-06 23:38:46 +08:00
Ryder Sun 7e988504bd Require recent verification for automatic agent routing
Exclude unverified and expired Agent records from automatic selection while keeping explicit first runs available and explaining how to enable routing.

Checks: routing and workspace tests (91 passed); frontend tests (399 passed); frontend build; desktop pack.
2026-11-06 23:36:43 +08:00
Ryder Sun 4bda69eb7a Honor WorkBuddy native configuration directories
Use the selected WorkBuddy home consistently for model choices, process configuration, and local and plugin Skill discovery.

Checks: native configuration, catalog and main security tests (81 passed); frontend tests (399 passed); frontend build; desktop pack.
2026-11-06 23:32:04 +08:00
Ryder Sun ab2a875e7d Protect connector credentials in HTTP error codes
Replace failure codes containing the current account credential before emitting runtime events or returning a failed result.

Checks: connector tests (48 passed); desktop pack.
2026-11-06 23:28:50 +08:00
Ryder Sun 27d8ebe071 Honor DeepSeek shared agent configuration home
Forward DSH_AGENTS_HOME to native discovery and execution, and use the same directory for shared Skill discovery with cache invalidation.

Checks: native environment, DeepSeek ACP, Skill catalog and main security tests (75 passed); desktop pack.
2026-11-06 23:26:12 +08:00
Ryder Sun 01f97661d7 Honor selected DeepSeek Harness native directories
Allow selecting an existing DSH_HOME and use it consistently for detection, execution, and native Skill discovery while retaining shared Skills.

Checks: native configuration, catalog and security tests (76 passed); frontend tests (398 passed); frontend builds; desktop pack.
2026-11-06 23:23:31 +08:00
Ryder Sun 867593a86f Reject HTTP connector responses containing account credentials
Prevent echoed account tokens in response text or session references from reaching chat and persisted runtime state.

Checks: connector tests (48 passed); desktop pack.
2026-11-06 23:20:38 +08:00
Ryder Sun 896ee03698 Reject group completion when receipt states failure
Apply explicit failure checks to task-decision reasons as well as response text, preserving unresolved blockers and preventing contradictory approvals.

Checks: targeted natural discussion regressions in both scheduling modes; desktop pack.
2026-11-06 23:18:09 +08:00
Ryder Sun 10844da6f1 Keep incomplete group contributions unresolved
Treat failed and truncated peer replies as blockers until a complete successful contribution replaces them, avoiding false task completion.

Checks: focused natural discussion regressions; desktop pack.
2026-11-06 23:15:25 +08:00
Ryder Sun 2385e74996 Withdraw group consensus after unreceipted failures
Evaluate failed and partial discussion contributions before skipping messages without structured decisions, so a later blocker invalidates earlier approvals.

Checks: focused natural discussion regression tests; desktop pack.
2026-11-06 23:12:41 +08:00
Ryder Sun 46478315c2 Guard natural group completion against multiline failures
Check explicit failure statements throughout an agent response while ignoring quoted examples and code blocks, preventing contradictory completion receipts from marking failed work complete.

Checks: natural discussion tests (67 passed); failure signal tests; desktop pack.
2026-11-06 23:08:31 +08:00
Ryder Sun 874cfbf9cc Align skill discovery with native shell configuration
Refresh selected native configuration directories before listing or validating skills so discovery matches agent execution. Preserve explicit directory selections and invalidate stale catalog entries.

Checks: node --test desktop/test/security/main-security.test.cjs desktop/test/skills/*.test.cjs; npm --prefix desktop run pack.
2026-11-06 23:05:11 +08:00
Ryder Sun 8063fda452 Discover Pi Skills from its native Agent directory
Use the installed Pi CLI resource layout for default and manually selected Agent directories without changing its model or Provider configuration.

Checks: Skill and IPC tests (76 passed); Electron targeted tests (2 passed); desktop pack; git diff --check.
2026-11-06 23:00:15 +08:00
Ryder Sun 74fefb8637 Discover Skills from selected native Agent directories
Use selected Hermes, Codex and Claude directories for Skill discovery and Codex plugin caches, and invalidate cached selections when the directory changes.

Checks: Skill and IPC tests (76 passed); Electron targeted tests (2 passed); desktop pack; git diff --check. Windows regression runs in CI.
2026-11-06 22:57:34 +08:00
Ryder Sun 49e9749dd6 Select native configuration directories for supported Agents
Validate user-selected Hermes, Codex, Claude and Pi configuration directories and apply them consistently to discovery, readiness and execution without overriding provider settings.

Checks: desktop tests (1865 passed, 1 platform skip); focused IPC and native tests; frontend tests (397 passed); web and desktop builds; desktop pack; git diff --check. Visual inspection unavailable while the Mac is locked.
2026-11-06 22:53:51 +08:00
Ryder Sun 0f92d70cf7 Recognize standalone permission failures in group discussion
Treat unambiguous one-line access-denied replies as blockers even when a contradictory completion receipt is present.

Checks: natural discussion tests (65 passed); Electron targeted tests (7 passed); git diff --check.
2026-11-06 22:36:07 +08:00
Ryder Sun 0ebe4527a2 Sync Connector audit directory on first write
Durably create the audit log where directory sync is supported and fail closed if its initial directory sync fails. Exercise package storage on Windows CI.

Checks: Connector conformance (39 passed); Electron targeted tests (2 passed); git diff --check.
2026-11-06 22:32:45 +08:00
Ryder Sun 831cf2d732 Expose Skill trust review and revocation in settings
Let users inspect Agent-scoped Skill approvals and revoke them with confirmation using the existing main-process trust store.

Checks: frontend tests (395 passed); web and desktop builds; desktop pack; git diff --check. Visual inspection unavailable while the Mac is locked.
2026-11-06 22:29:14 +08:00
Ryder Sun 8e65afaa30 Honor Windows casing for native Agent environment keys
Normalize allowed native configuration keys case-insensitively on Windows while preferring canonical keys and excluding unrelated values.

Checks: native readiness tests (45 passed); Electron targeted test (1 passed); git diff --check. Windows regression runs in CI.
2026-11-06 22:25:12 +08:00
Ryder Sun 68769c650f Reject incompatible task graph and V4 requests
Prevent both automatic run entry points from silently replacing an explicit task graph with V4 discussion, and explain the incompatibility to users.

Checks: task graph tests (11 passed); Electron targeted test; frontend tests (394 passed); web and desktop builds; desktop pack; git diff --check.
2026-11-06 22:23:31 +08:00
Ryder Sun 5eaf4cf989 Make Connector package upgrades a single audited transition
Switch versions with one audit event, replay legacy upgrade history, and stop further writes after an uncertain audit append.

Checks: Connector conformance (37 passed); Electron targeted tests (2 passed); git diff --check.
2026-11-06 22:20:53 +08:00
Ryder Sun 18d590f305 Allow removed Connector packages to be imported again
Record a new import after removal while preserving audit history and requiring fresh approval; repeated imports do not undo revocation.

Checks: Connector conformance (36 passed); Electron targeted test (1 passed); git diff --check.
2026-11-06 22:18:44 +08:00
Ryder Sun ed24c2ea3f Preserve scoped configuration in direct capability probes
Apply the same owner-specific native directory selection when capability probing is invoked directly or through Agent discovery.

Checks: discovery and process tests (65 passed); Electron targeted test (1 passed); git diff --check.
2026-11-06 22:17:15 +08:00
Ryder Sun 315dac808f Scope native Agent configuration directories to their owner
Keep Codex, Claude and Pi configuration paths out of the shared child environment while preserving owner-specific discovery and execution settings.

Checks: discovery and process tests (65 passed); Electron targeted tests (2 passed); main security tests (54 passed); git diff --check.
2026-11-06 22:16:08 +08:00
Ryder Sun 4f91eb14c3 Cover unstructured peer failures in group runs
Exercise agent-led and sequential discussions through persisted Run Ledger results when a peer reports a permission failure without a structured decision.

Checks: targeted Node and Electron group tests (2 passed each); git diff --check.
2026-11-06 22:13:20 +08:00
Ryder Sun 593e1d6fc4 Retain unstructured peer failures during group completion
Forward explicit peer failures even without a structured decision and retain them as blockers until the same Agent reports a valid resolution.

Checks: natural discussion suite (63 passed); targeted Node and Electron tests (2 passed each); git diff --check.
2026-11-06 22:12:11 +08:00
Ryder Sun c96bb9e7a4 Refresh Connector state after failed operations
Reload package and instance state even when an operation fails, and discard stale controls if the refresh itself fails.

Checks: frontend tests (394 passed); web and desktop builds; desktop pack; git diff --check.
2026-11-06 22:09:09 +08:00
Ryder Sun f4cb6d1372 Expose Connector package trust details and history
Show inspected transport, permissions, credential slots, outbound destinations and durable trust events in Agent settings.

Checks: frontend tests (393 passed); web and desktop builds; desktop pack; git diff --check.
2026-11-06 22:07:51 +08:00
Ryder Sun 025034de7f Manage and test configured Connector instances
Show instance availability, run explicit conformance probes, and confirm deletion while surfacing conversations that still use an instance.

Checks: frontend tests (392 passed); web and desktop builds; desktop pack; git diff --check.
2026-11-06 22:05:59 +08:00
Ryder Sun 9131fdcd06 Expose credential-free Connector package management
Add an inline Agent settings entry for importing, reviewing and managing Connector packages and creating instances that do not require credentials. Keep credential entry out of the renderer.

Checks: frontend tests (391 passed); web and desktop builds; desktop pack; git diff --check. Visual inspection unavailable while the Mac is locked.
2026-11-06 22:04:22 +08:00
Ryder Sun 8099b09fb0 Verify process termination with packaged Windows runtime
Build the Windows application in CI and run the real parent and descendant cancellation test against the packaged Electron executable and app.asar.

Checks: process support tests (12 passed, Windows integration skipped on macOS); git diff --check. Packaged Windows verification runs in CI.
2026-11-06 21:55:44 +08:00
Ryder Sun c42f8a49e8 Exercise real Windows Agent process tree termination
Run taskkill against a live parent and descendant on Windows CI to verify cancellation beyond mocked child processes.

Checks: process support tests (12 passed, Windows test skipped on macOS); git diff --check. Windows integration runs in CI.
2026-11-06 21:54:07 +08:00
Ryder Sun 38eeac21f6 Reject Chinese permission failures as group completion
Recognize explicit Chinese access failures with or without a subject or permission-denied prefix when a completion receipt contradicts the reply.

Checks: natural discussion suite (61 passed); Electron targeted tests (3 passed); git diff --check.
2026-11-06 21:53:06 +08:00
Ryder Sun f448fde78b Verify private file persistence on Linux and Windows
Exercise atomic private file replacement across supported platforms and limit POSIX mode assertions to platforms that implement those permissions.

Checks: private file tests (8 passed on macOS); git diff --check. Linux and Windows checks run in CI.
2026-11-06 21:48:50 +08:00
Ryder Sun ed6f974e97 Preserve XDG runtime configuration during readiness checks
Use the shared XDG directory list for native authentication probes so they see the same runtime paths as Agent execution, including explicit empty values.

Checks: readiness and process tests (55 passed); Electron readiness test (1 passed); git diff --check.
2026-11-06 21:47:33 +08:00
Ryder Sun 26523eca8f Preserve native Google Cloud project in Agent execution
Forward the GCLOUD_PROJECT variable already selected by native configuration so execution sees the same project as discovery.

Checks: native environment and process tests (56 passed); Electron native environment test (1 passed); git diff --check.
2026-11-06 21:46:34 +08:00
Ryder Sun 816778b5fe Reject permission-denied replies as group completion
Detect explicit access failures following a permission-denied prefix even when the Agent supplies a contradictory completion receipt. Cover both natural discussion modes and preserve unrelated quoted or resolved errors.

Checks: natural discussion suite (59 passed); Electron targeted tests (3 passed); git diff --check.
2026-11-06 21:45:25 +08:00
Ryder Sun d288235a2c Recover uncertain journal appends safely
Retry rollback only after validating file identity and appended bytes, allowing transient append failures to recover without stale sequences or overwriting external changes.

Checks: run ledger and coordinator tests under Node; append and compaction failure tests under Node and Electron.
2026-11-06 21:40:25 +08:00
Ryder Sun 0a11a7f6b8 Retry uncertain journal compaction without restart
Retain the intended compacted baseline after an uncertain write and make it durable before subsequent appends, allowing transient failures to recover automatically.

Checks: run ledger and coordinator tests under Node; compaction and outbox tests under Electron.
2026-11-06 21:38:27 +08:00
Ryder Sun 75e9bcc760 Propagate OpenClaw health process termination failures
Do not suppress a failed health-check process termination, preserving the pending exit signal needed to retain scheduler capacity.

Checks: OpenClaw runtime and scheduler tests under Node; termination failure test under Electron.
2026-11-06 21:37:03 +08:00
Ryder Sun f8f3cf5b21 Fix durable consensus review fixtures
Give reconstructed review and objection turns real message text so the fixtures satisfy complete contribution validation.

Checks: Agent-led consensus test suite; full desktop suite running.
2026-11-06 21:25:54 +08:00
Ryder Sun 0b3d6aba39 Avoid hanging on oversized response cancellation
Reject oversized Connector and Bridge responses without waiting indefinitely for a stalled stream cancellation.

Checks: bounded response, cloud Bridge, and Connector SDK tests under Node and Electron.
2026-11-06 21:24:04 +08:00
Ryder Sun 63bfe50c46 Verify mixed task graph evidence on successful runs
Exercise a complete graph containing both captured evidence and fabricated references, ensuring valid delivery continues while forged IDs do not propagate.

Checks: task graph tests under Node and Electron.
2026-11-06 21:23:06 +08:00
Ryder Sun 53a83207ad Keep unverified references out of task graph state
Filter outcome references before recording collaboration and node state, so fabricated artifacts cannot propagate to subsequent workflow nodes.

Checks: task graph tests under Node and Electron.
2026-11-06 21:21:59 +08:00
Ryder Sun 619ce1f31c Validate task graph outcome references before acceptance
Count only readable artifacts produced by the current Agent run and evidence bound to their captured content, preventing fabricated references from satisfying node criteria.

Checks: task graph tests under Node and Electron.
2026-11-06 21:20:50 +08:00
Ryder Sun d32596bbcf Prevent explicit task failures from satisfying group completion
Reject contradictory completion receipts when the reply explicitly states it cannot access or complete the task, while retaining unresolved blockers until an actual resolution.

Checks: full natural discussion suite under Node; contradictory receipt tests under Electron.
2026-11-06 21:19:14 +08:00
Ryder Sun 13fa59ae9b Distinguish cloud cancellation from timeout
Record the first abort cause so a user stop is not classified as a Bridge timeout and a later cancellation cannot overwrite an actual timeout.

Checks: cloud Bridge and Connector tests under Node; cloud Bridge tests under Electron.
2026-11-06 21:16:30 +08:00
Ryder Sun c8a95366ad Bound cloud Bridge response reads
Share bounded HTTP stream handling with the Connector SDK and propagate already-aborted Bridge request signals.

Checks: cloud Bridge and Connector SDK tests under Node and Electron.
2026-11-06 21:15:29 +08:00
Ryder Sun 99dbfcad81 Bound Connector HTTP response streaming
Stop reading and cancel SDK HTTP responses once the configured byte limit is exceeded, while preserving valid streamed UTF-8 responses.

Checks: Connector SDK and runtime tests under Node; SDK tests under Electron.
2026-11-06 21:13:54 +08:00
Ryder Sun d79745ccfd Block stale OpenClaw readiness when runtime is unavailable
Do not retain an old ready state when native runtime prerequisites are unavailable; distinguish a timed-out status probe so transient failures retain their bounded grace period.

Checks: native readiness and Agent catalog tests under Node and Electron.
2026-11-06 21:12:10 +08:00
Ryder Sun 96d164382b Expire historical Agent authentication evidence
Only use recent successful runtime verification as current authentication evidence; old chat messages and expired successes no longer imply current credentials.

Checks: Agent catalog and native readiness tests under Node; Agent catalog tests under Electron.
2026-11-06 21:10:41 +08:00
Ryder Sun 20a3074f7a Require explicit cloud session capability declarations
Do not assume an undeclared remote session, resume, or cancel capability; only allow resume when session support is declared.

Checks: cloud bridge and Connector tests under Node; cloud bridge tests under Electron.
2026-11-06 21:09:32 +08:00
Ryder Sun bd44f4cd9f Cancel remote Agent process on Bridge disconnect
Propagate disconnected Bridge requests to the native Agent, terminate its process group on POSIX, and escalate if it ignores termination.

Checks: cloud Bridge server and client tests under Node; server tests under Electron.
2026-11-06 21:08:40 +08:00
Ryder Sun bc1db86761 Align cloud Agent availability with read-only execution
Advertise the bridge read-only permission and exclude remote Agents that cannot execute in that mode or explicitly do not support execution.

Checks: cloud bridge and Connector tests under Node; cloud bridge tests under Electron.
2026-11-06 21:06:39 +08:00
Ryder Sun d6865768b0 Advertise only supported cloud bridge inputs
Constrain cloud Agent manifests to text while the bridge request protocol does not transport attachments, preventing silent attachment loss.

Checks: cloud bridge and local Connector tests under Node; cloud bridge tests under Electron.
2026-11-06 21:05:33 +08:00
Ryder Sun 6e8b96f6c4 Reject truncated task graph conclusions
Require an untruncated conclusion for nodes whose acceptance criteria require a conclusion, so incomplete text cannot satisfy the workflow.

Checks: task graph tests under Node and Electron.
2026-11-06 21:03:27 +08:00
Ryder Sun 93898506f0 Exclude failed Agent turns from group acceptance
Treat a non-completed execution as an incomplete contribution even when its text or task decision claims completion.

Checks: natural discussion failure and truncation regressions under Node and Electron.
2026-11-06 21:02:21 +08:00
Ryder Sun 0224d09cf3 Prefer canonical environment keys during Agent discovery
Use the explicit canonical PATH when Windows casing variants coexist, keeping discovery and custom Agent execution aligned with shell settings.

Checks: CLI discovery and custom Agent tests under Node; targeted environment tests under Electron.
2026-11-06 21:00:43 +08:00
Ryder Sun 36dcc03455 Stop journal writes when append rollback is uncertain
Sync a successful rollback and block further appends when truncation or rollback sync fails, avoiding stale sequence reuse after a failed journal write.

Checks: run ledger and coordinator tests under Node; journal failure tests under Electron.
2026-11-06 20:59:44 +08:00
Ryder Sun dd813b1000 Stop journal writes after uncertain compaction
Prevent appending with a stale sequence when compaction replacement succeeds but its directory sync fails; a fresh load restores the durable journal state.

Checks: full run ledger and coordinator tests under Node; compaction and retention tests under Electron.
2026-11-06 20:58:50 +08:00
Ryder Sun 5b53b7358d Keep group blockers until a complete resolution
Do not clear a prior participant blocker from a truncated or empty completion response.

Checks: natural discussion blocker and truncation tests under Node and Electron.
2026-11-06 20:57:36 +08:00
Ryder Sun 5d1aad12fd Fail closed after Skill trust audit write errors
Disable trust decisions after an uncertain audit append so a failed revocation cannot continue using stale in-memory approval.

Checks: Skill contract, snapshot selection, and trust tests under Node; snapshot and trust tests under Electron.
2026-11-06 20:56:34 +08:00
Ryder Sun 9a2ab7716c Enforce Connector media capabilities for file attachments
Classify declared media MIME types before generic file labels so unsupported images, audio, and video cannot cross the Connector recipe boundary.

Checks: Connector runtime and local tests under Node; runtime tests under Electron.
2026-11-06 20:55:13 +08:00
Ryder Sun 1741778857 Commit the durable terminal intent on retry
Use the already persisted terminal snapshot for every retry so the live controller and crash recovery cannot commit different outcomes.

Checks: run ledger and coordinator tests under Node and Electron; V4 auto and manual durability suites.
2026-11-06 20:54:14 +08:00
Ryder Sun c051722b71 Keep conflicting Agent version output ambiguous
Do not let a bare version resolve contradictory named product versions; continue capability checks using an unidentified version.

Checks: Agent compatibility and CLI discovery tests under Node and Electron.
2026-11-06 20:51:02 +08:00
Ryder Sun 294c0aeca2 Reject truncated group completion and review turns
Require complete contribution text before accepting an owner completion or a peer approval so incomplete replies cannot establish group consensus.

Checks: full natural discussion suite under Node; targeted failure and truncation tests under Electron.
2026-11-06 20:49:43 +08:00
Ryder Sun 27a2281590 Persist knowledge connector records atomically
Sync private knowledge records through the shared atomic writer and withdraw a newly granted in-memory authorization if persistence fails.

Checks: knowledge connector, private file, and workspace context tests under Node; connector and private file tests under Electron.
2026-11-06 20:45:36 +08:00
Ryder Sun 47d7e9d400 Make custom Agent configuration writes durable
Use the private atomic writer and reload persisted definitions after a failed write so memory follows the actual file, including a failed directory sync after rename.

Checks: private file and custom Agent store tests under Node and Electron.
2026-11-06 20:44:33 +08:00
Ryder Sun d1fa43271a Use login shell settings for custom Agent processes
Share the native shell environment between custom Agent discovery and execution so GUI launches preserve proxy and config directory settings while filtering unrelated secrets.

Checks: custom Agent tests under Node and Electron; desktop pack.
2026-11-06 20:43:07 +08:00
Ryder Sun 413a2182db Preserve network settings for custom Agent CLI
Use the shared network environment contract for custom Agent discovery and execution so proxy and CA settings are retained without passing unrelated credentials.

Checks: custom Agent store tests under Node and Electron.
2026-11-06 20:42:16 +08:00
Ryder Sun 3e023313ed Test group permission failures across discussion modes
Verify blocked multi-agent runs remain incomplete after the ledger is reopened in both agent-led and sequential discussion.

Checks: Node and Electron natural discussion failure regression tests.
2026-11-06 20:40:55 +08:00
Ryder Sun 802ef27217 Preserve connector selections during group recovery
Revalidate the original connector source and capture mode before resuming a legacy group so selected knowledge is not silently lost or changed. Verify context ownership and stop when the selection cannot be restored.

Checks: connector and context pack tests, task graph and recovery tests, Electron recovery tests, npm --prefix desktop run pack.
2026-11-06 20:39:34 +08:00
Ryder Sun 41f43bb119 Preserve selected knowledge during legacy group recovery
Reject unavailable or changed persisted knowledge selections instead of silently resuming without the requested source.

Checks: legacy and V4 recovery tests; Electron recovery regressions; desktop pack.
2026-11-06 20:34:32 +08:00
Ryder Sun 16f62cad09 Fail closed when recovered group Skills are unavailable
Preserve and validate selected Skill identities during legacy automatic recovery instead of silently continuing without them.

Checks: legacy recovery and V4 frozen Skill tests; Electron recovery regression; desktop pack.
2026-11-06 20:32:46 +08:00
Ryder Sun 0282379178 Wait for independent work before task graph completion
Run ready nonterminal branches before terminal nodes and reject graphs that would leave nonterminal work behind, preventing failed branches from being masked by completion.

Checks: task graph workspace and record tests; Electron graph regressions; desktop pack.
2026-11-06 20:29:36 +08:00
Ryder Sun c3f5d01cb8 Reject partial results in task graph acceptance
Require a completed Agent outcome before accepting a task graph node so partial results cannot complete the run or unlock dependent reviewers.

Checks: task graph record and workspace tests; Electron graph regressions; desktop pack.
2026-11-06 20:26:44 +08:00
Ryder Sun 599f1e6969 Preserve native XDG directories across Agent discovery and execution
Capture shell-selected configuration directories and pass them to ordinary local Agents while leaving isolated Connector and OpenClaw handling unchanged.

Checks: readiness, CLI environment and main security tests; Electron environment regressions; desktop pack.
2026-11-06 20:23:41 +08:00
Ryder Sun 66a9fe62ed Revalidate Skill trust before each Agent start
Restore and verify approved Skill snapshots after queueing and immediately before each execution or retry, so revoked trust cannot be reused by a pending run.

Checks: Skill, context and natural discussion tests; Electron trust regressions; desktop pack.
2026-11-06 20:21:07 +08:00
Ryder Sun 9832736ada Isolate Connector tests and document SDK boundaries
Run Connector probes in a temporary workspace cleaned after success or failure, and document supported providers and lifecycle limitations.

Checks: Connector conformance; main-process security tests; Electron IPC regressions; desktop pack.
2026-11-06 20:16:38 +08:00
Ryder Sun 76814b9ee9 Preserve unreported Connector usage for budget estimates
Return only usage fields supported by declared events and reject undeclared or inconsistent result usage, preventing absent token counts from being recorded as zero.

Checks: Connector and invocation budget tests; Electron runtime regressions; desktop pack.
2026-11-06 20:12:37 +08:00
Ryder Sun 53c8d995b4 Enforce private Connector package directories
Repair existing managed directory permissions and fail closed when privacy cannot be enforced; reject a symlink root before creating package contents.

Checks: package store, Connector SDK and private file tests; Electron directory regressions; desktop pack.
2026-11-06 20:10:19 +08:00
Ryder Sun 82653406bd Recognize Zcode runtime version during discovery
Distinguish the native runtime version from the separate app package version so Zcode remains available without accepting conflicting product versions.

Checks: compatibility, discovery and catalog tests; Electron regression; native read-only invocation; desktop pack.
2026-11-06 20:08:47 +08:00
Ryder Sun a65577ab24 Retain leases while Windows cancellation is unresolved
Keep scheduler capacity after the cancellation grace period until a late runner outcome or process exit confirms it can be released.

Checks: scheduler, workspace and invocation tests; Electron cancellation regressions; desktop pack.
2026-11-06 20:05:55 +08:00
Ryder Sun 0a223fa64f Retain scheduler lease after failed Windows termination
Carry a private process-exit observation through invocation errors and retain scheduler capacity until the still-running child exits.

Checks: process support, scheduler and workspace tests; Electron termination regressions; desktop pack.
2026-11-06 20:03:45 +08:00
Ryder Sun a27d034088 Verify cloud bridge chat payloads before transport
Capture the exact HTTPS and tunneled JSON bodies or SSH input for workspace delivery checks, and restrict tunnel destination exemption to the trusted bridge recipe.

Checks: cloud, Connector and context tests; Electron cloud chat regressions; desktop pack.
2026-11-06 20:01:19 +08:00
Ryder Sun d684cc78b4 Verify HTTP Connector chat payloads end to end
Record the exact JSON body before HTTP transport and verify its prompt against the approved workspace context, without storing authorization headers.

Checks: context pack and Connector tests; Electron workspace chat regression; desktop pack.
2026-11-06 19:57:35 +08:00
Ryder Sun 6312f288e3 Await connector outbound checks before transport
Wait for asynchronous outbound recording before sending HTTP or cloud bridge requests so failures cannot race a network call.

Checks: Connector SDK, local transport and runtime tests; Electron outbound tests.
2026-11-06 19:54:57 +08:00
Ryder Sun 6b36d620eb Preserve unidentified versions in Agent readiness
Carry ambiguous or missing product identity into catalog readiness so successful capability probes cannot make an unidentified Agent eligible for routing.

Checks: discovery, catalog and routing tests; Electron regression; desktop pack.
2026-11-06 19:53:01 +08:00
Ryder Sun cfba8bd81d Durably sync terminal outbox cleanup
Sync the outbox directory after deleting a terminal intent, including retries after a failed sync, so committed cleanup survives a crash.

Checks: Run Ledger and coordinator tests; Electron outbox recovery tests.
2026-11-06 19:51:25 +08:00
Ryder Sun dab9b0ef7f Use native agent configuration during CLI discovery
Apply the same per-agent native environment used for invocation to version and capability probes, while preserving OpenClaw isolation and redacting credentials from catalog versions.

Checks: CLI discovery, native readiness and main security tests; Electron probe tests; desktop pack.
2026-11-06 19:49:43 +08:00
Ryder Sun a8da328e6c Cover unaddressed permission failures in group discussions
Verify both natural discussion modes do not treat an unaddressed failure without a completion receipt as a successful run.

Checks: focused natural discussion tests under Node and Electron.
2026-11-06 19:46:42 +08:00
Ryder Sun 7b441c2b80 Report unconfirmed Windows agent termination as failure
Wait for taskkill and the child exit so cancellation does not report success while termination remains unconfirmed. Preserve the failure through workspace messages and localize the user-facing error.

Checks: targeted CLI and workspace tests; Electron node-mode tests; frontend tests and builds; desktop pack.
2026-11-06 19:43:05 +08:00
Ryder Sun 8dedf2c41d Bind Connector outcome references to validated events
Reject forged or undeclared outcome references and derive returned artifacts and evidence from the Connector event state. Verified Connector, Electron, and desktop packaging tests.
2026-11-06 19:31:48 +08:00
Ryder Sun ea0d596bfa Restrict raw Connector event and permission callbacks
Keep raw CLI progress limited to the trusted delegate and require declared Permission capability for permission callbacks. Verified Connector, main-process, Electron, and packaging checks.
2026-11-06 19:29:54 +08:00
Ryder Sun ccc68ba642 Prevent active runs from bypassing Agent readiness failures
Honor failed prerequisites and recorded authentication failures in fallback routing, and discard blocked snapshots. Verified workspace, Electron, real Hermes and Zcode, and packaging checks.
2026-11-06 19:27:22 +08:00
Ryder Sun f7b97e3b52 Honor native Hermes and Pi configuration roots in readiness
Read credentials from the configured native directories without falling back to unrelated defaults, and preserve Hermes data directory suffixes. Verified readiness, Electron, security, and desktop packaging tests.
2026-11-06 19:21:58 +08:00
Ryder Sun 7d4820d04d Persist terminal run intents for crash recovery
Durably record terminal outcomes before final ledger writes and replay them idempotently after restart, while leaving ordinary uncommitted transactions untouched. Verified ledger, group recovery, abrupt-exit, Electron, and desktop packaging tests.
2026-11-06 19:19:19 +08:00
Ryder Sun 5c8a2c5dc4 Continue pending terminal commits during shutdown
Keep retrying retained terminal outcomes while shutting down and preserve their original status and reason. Verified coordinator, Electron, main-process tests, and desktop packaging.
2026-11-06 19:12:09 +08:00
Ryder Sun 2f7036b588 Preserve unresolved group blockers across discussion rounds
Rebuild outstanding blockers from run-bound receipts and require the reporting participant to clear them before completion. Verified natural discussion, Electron, and desktop packaging checks.
2026-11-06 19:09:48 +08:00
Ryder Sun dbb40a7027 Preserve explicit blockers in group task decisions
Treat contradictory completion receipts with explicit blockers as blocked instead of silently discarding the blocker. Verified natural discussion, Electron, and desktop packaging tests.
2026-11-06 19:06:33 +08:00
Ryder Sun fe1635746a Stream verified media ranges without loading entire attachments
Store per-chunk integrity data for new media, validate only requested chunks, and retain full verification for legacy attachments. Verified attachment and main-process tests, Electron range handling, and desktop packaging.
2026-11-06 19:04:01 +08:00
Ryder Sun b3aefbfbcd Distinguish token counts from sensitive context fields
Allow bounded usage counts while matching forbidden fields on word boundaries; keep strict schemas and precise wire payload diagnostics. Verified context and ledger tests plus desktop packaging.
2026-11-06 18:59:17 +08:00
Ryder Sun 1cdab02454 Preserve Pi native cloud provider environment
Forward Pi-supported Bedrock, Azure and Vertex settings without changing provider selection or exposing unrelated secrets. Verified readiness, execution isolation, desktop startup tests, and packaging.
2026-11-06 18:57:22 +08:00
Ryder Sun 4efa569f1b Restrict permissions on application-owned data directories
Repair broad existing POSIX directory permissions without changing arbitrary caller paths, and reject redirected private roots. Verified startup, private-file, run-ledger tests, and desktop packaging.
2026-11-06 18:54:23 +08:00
Ryder Sun 56b5368f8f Verify Pi JSON protocol during agent discovery
Probe an isolated offline session and require an identified version to avoid marking unrelated executables compatible. Verified discovery and protocol tests, local Pi, and desktop packaging.
2026-11-06 18:51:52 +08:00
Ryder Sun 0dcd652b03 Retry terminal run persistence after transient outages
Keep failed terminal outboxes retrying without acknowledging completion until the ledger commit succeeds. Verified coordinator and V4 durability tests and desktop packaging.
2026-11-06 18:48:52 +08:00
Ryder Sun 706936d961 Prevent false completion and preserve exact task context
Reject incomplete task decisions, detect oversized required task context, and harden attachment import validation. Verified with focused desktop tests, live native agent checks, and desktop packaging.
2026-11-06 18:44:00 +08:00
Ryder Sun d5acec0fc2 Harden run recovery, agent readiness and local persistence
Reject malformed attachment inputs, canonicalize wire payloads, bound context parsing and cache discovery within each pass. Preserve runtime blockers and prevent invalid legacy round recovery.

Checks: desktop suite (1722 passed), focused regression suite (136 passed), desktop package validation.
2026-11-06 18:31:57 +08:00
Ryder Sun 6f998b3eeb Support DeepSeek Harness and refine agent settings
Add native ACP integration and legacy run recovery; improve settings interactions and provider failure reporting.

Checks: frontend tests (389), focused desktop tests (17), desktop packaging and Electron smoke checks.
2026-11-06 17:47:05 +08:00
Ryder Sun 71337581cf Link README product video to YouTube
Make the product video accessible from GitHub README previews. Verified the Markdown links and git diff --check.
2026-11-04 18:47:07 +08:00
Ryder Sun 2af10e11ef Improve cloud agent access and settings
Enable remote OpenCode execution and cloud refresh, refine settings interactions, and update product documentation. Verified frontend tests and builds, cloud bridge tests, desktop packaging, and a real SSH OpenCode chat.
2026-11-04 18:35:17 +08:00
Ryder Sun 607a5dd325 feat: add draggable settings card rails and fix cloud agent visibility
Replace Agent and knowledge-base grids with horizontal rails using velocity-directed snapping and spring settling. Preserve native controls and prevent clicks after dragging. Improve WorkBuddy layout and icon hover feedback, and allow unavailable discovered cloud Agents to retain sidebar preferences.

Validation: frontend 383 tests; desktop 1701 tests; web and desktop builds; desktop pack. Desktop visual review remains blocked by the locked Mac.
2026-11-03 10:50:46 +08:00
Ryder Sun 1ddf3fbdd7 release: prepare 1.0.9 preview and clarify agent readiness
Bundle knowledge-base logos and report unverified or failed native authentication accurately. Update release metadata and document the six-agent discussion validation and known configuration limits.

Checks: frontend tests (379), desktop tests (1700), landing tests (13), frontend builds, desktop pack and dist, DMG and ZIP integrity.
2026-11-02 15:03:02 +08:00
Ryder Sun ef405bb721 fix: finish native collaboration turns on explicit completion
Use an invocation-bound assistant signal to return completed contributions to the scheduler without waiting for repeated native closing replies. Ignore late output, preserve settled native sessions, and retain independent version-bound group approvals.

Verified frontend 376 tests, desktop 1698 tests, both frontend builds and desktop pack. Real Claude, Hermes, Pi, OpenCode, Kimi and MiMo discussion completed after 9 rounds with all six approving candidate b91dddfe and matching immutable file hashes.
2026-10-30 15:59:51 +08:00
Ryder Sun 525901dd9a fix: preserve native tools and allow running conversation rename
Allow running conversations to rename and inspect locked settings. Honor writable group authorization across native discussion and concurrent replies, preserve configured plugins, and invalidate approvals when candidate files change.

Verify frontend 376 tests, desktop 1692 tests, web/desktop builds, and desktop pack. Electron confirms live rename and locked settings; native networking works, but repeated-output completion remains unresolved in OpenCode/MiMo.
2026-10-30 15:32:42 +08:00
Ryder Sun c6b08f4ce1 test: wait for observed tool start before cancelling native CLI
Replace child readiness-file timing with the actual mapped tool_start event so cancellation consistently verifies the matching stopped event. Ensure the fixture process is aborted during cleanup.

Checks: full desktop discovery suite 1685/1685 at concurrency 4; frontend 375/375; web and desktop builds; desktop pack; Electron first-run/returning-user loading, theme colors and narrow-window checks.
2026-10-30 11:09:34 +08:00
Ryder Sun d2f6171ddd fix: show one compact theme-aware discovery loader
Remove the duplicate progress bar and pulsing status dot. Reduce wave geometry to 190 by 72 and inherit existing light/dark accent colors. Record the provider-agnostic native CLI boundary in AGENTS.md.

Checks: frontend 375 tests; web and desktop builds; desktop pack; Electron first-run and returning-user checks with animated loader, light/dark colors and a 560px window.
2026-10-30 11:01:49 +08:00
Ryder Sun 05ec2bfbf0 merge: retain main updates without automatic Agent hard limits
Integrate origin/main while replacing tool-budget termination with a sequential OpenCode regression that verifies twenty native tools can finish normally. Preserve localized diagnostics for historical failures.

Checks: invocation watchdog and sequential OpenCode focused tests passed; frontend loading suite 375 tests passed before integration.
2026-10-30 10:54:32 +08:00
Ryder Sun 59a546e071 fix: require native Agent consensus and show discovery loading first
Preserve native CLI completion and recovery, collect explicit approvals for an immutable candidate, and honor invitation order. Display the existing loading animation before first-run onboarding. Keep provider configuration owned by native Agents.

Checks: desktop 1684 tests, frontend 375 tests, both frontend builds and desktop pack; real six-Agent Electron consensus completed, including a 381-second Hermes invocation.
2026-10-30 10:53:15 +08:00
Ryder Sun 9ed1552cec fix: bound stalled Auto discussion agent turns
Stop Auto V4 proposal and discussion invocations after prolonged answer silence, excessive tool calls, or an absolute turn deadline. Preserve streamed output when OpenCode or another native Agent never returns a terminal result.

Checks: watchdog and sequential OpenCode focused tests passed; frontend 370 tests and both builds passed; isolated Electron pack and code signature verification passed. Full desktop suite was attempted but blocked by unrelated concurrent test load and timeout failures.
2026-10-28 17:52:39 +08:00
Ryder Sun 4f10fd14d9 site: simplify landing messaging and refresh collaboration stepper
- Drop the 'V4' suffix from Auto Discussion branding across copy and structured data
- Remove the Capabilities section; fold its boundary/shared-context messaging into existing sections
- Rework the scenario stepper with round-by-round agent activity states (Waiting/Thinking/Working/Done) and bilingual runtime labels
- Bump asset cache versions; update landing tests (13 pass via node --test)
2026-10-28 13:43:40 +08:00
Ryder Sun d11deef490 release: prepare 1.0.8 with verified native CLI fixes and explicit limits
Align app, README and website downloads on 1.0.8. Document the separate Magpie
finish-reason defect and native configuration failures instead of treating CLI
detection as successful execution. Keep Apache-2.0 commercial-use terms
consistent and retain the ad-hoc signing/notarization disclosure.

Validation: desktop 1655 tests, frontend 370 tests, website 12 tests and packaging
8 tests; web/desktop builds; DMG/ZIP integrity and packaged signatures/licenses.
Electron five-Agent unlimited acceptance completed 19 native calls without
failed attempts or retries. Auto mode survives sending, completion and group
navigation. Native acceptance used an isolated Magpie hotfix; existing user
gateways were not replaced or restarted.
2026-10-27 23:01:40 +08:00
Ryder Sun bd395b7a2f fix: finish native Agent queries and preserve discussion state
Keep Kimi read-only queries in manual approval mode rather than forcing a
planning cycle, and prefer its terminal rejection over feedback-free revision.
Preserve MiMo's same-message tail after the terminal RPC, forward supported
native configuration variables, cancel repetitive native output while retaining
partial content, and preserve each group's selected composer mode.

Validation: frontend 370 tests; desktop 1655 tests; web and desktop builds;
desktop pack and packaged signature verification. Audited all 13 installed
CLIs. A real five-Agent unlimited discussion completed 14 native calls through
an isolated Magpie null-tool-call fix. Five CLIs still have native configuration
failures; production Magpie remains unchanged. Electron UI acceptance awaits
macOS unlock; this commit does not certify production acceptance or publish a
release.
2026-10-27 21:09:46 +08:00
Ryder Sun 4c522c0997 release: unify 1.0.7 downloads and Apache-2.0 commercial-use terms
Update bilingual documentation and website, include the official Zcode logo and deployable license notices, and replace the unverified display font with the bundled OFL font. Guard version, download and licensing consistency in CI. Give the native ACP multi-phase integration test a realistic completion budget.

Validation: frontend 369/369, landing 12/12, deterministic eval 6 cases/18 results, web/desktop builds, pack/dist, DMG/ZIP integrity and extracted-app signature/license checks passed. Packaged Electron startup passed. The ACP restart group passed 10/10 after one full-suite timing failure; the full desktop suite is rerunning before publication.
2026-10-27 15:28:09 +08:00
Ryder Sun 9971659e8a Merge remote-tracking branch 'origin/main' into 1.0.7
# Conflicts:
#	README.md
#	README.zh-CN.md
#	meldwork-landing/index.html
2026-10-27 15:17:12 +08:00
Ryder Sun 83709a13e8 fix: preserve interrupted discussion output and support native Zcode and Hermes formats
Prevent terminal-only plan dialogs from blocking headless Agents, preserve buffered replies on stop, and keep Chinese slash-separated text intact. Add Zcode discovery, execution, credentials diagnostics, and its official logo; use Hermes native streaming when supported.

Validation: 1637 desktop tests and 369 frontend tests passed; 91 follow-up CLI tests, web/desktop builds, packaging, Electron cancellation/reload checks, and live WorkBuddy invocation passed. ZGCI finish_reason and missing Zcode default credentials remain external limitations.
2026-10-27 15:14:40 +08:00
Ryder Sun 5a938f60d2 feat(landing): refresh landing page with ember background and bilingual i18n
- Add glyph-embers.js canvas background and i18n.js language switching
- Refresh index.html, main.js, and styles.css layout and content
- Bundle Fusion Pixel fonts for CJK display type
- Add landing test suite (node --test meldwork-landing/test/landing.test.cjs)
- Update landing docs and switch README license notice to Apache 2.0

Checks: node --test meldwork-landing/test/landing.test.cjs (11 passed)
2026-10-21 12:46:35 +08:00
Ryder Sun a97fd91975 feat: use 0-100 scoring for delivery preferences and refresh settings layout
- Replace three-step work preference dials with 0-100 score scale
  (desktop schema, prompts, frontend form, presets, styles, locales)
- Restructure general settings panel layout with WorkPreferences section
- Update landing page copy and README download details
- Update related frontend and desktop tests

Checks: npm --prefix frontend test (369 passed),
node --test desktop/test/workspace/work-preferences.test.cjs (3 passed)
2026-10-21 10:39:54 +08:00
Ryder Sun fbc6e08e80 chore: untrack local docs folder from version control
docs/ is already in .gitignore but two files remained tracked.
Removed them from the index so future docs stay local-only.
2026-10-21 10:39:50 +08:00
Ryder Sun 0591472bc4 docs: refresh V1.0.6 downloads and release details
Commit the previously retained documentation changes separately from V1.0.7
runtime work. Update English/Chinese README and landing download links, release
metadata and verification notes to the published V1.0.6 Apple silicon preview,
including the Ryder-Sun repository and website URLs.

Keep the existing product copy and document preview signing and acceptance limits.
Checks: reviewed the three-file diff and git diff --cached --check passed.
This is documentation only; no additional runtime tests were needed.
2026-10-20 14:32:58 +08:00
Ryder Sun 35b8075b87 perf: compact shared discussion context without acknowledging excerpts as full
Bound shared transcripts to 16000 characters with fair per-peer budgets. Retain
source IDs, opening/closing context, constraints, evidence and unresolved issues
using deterministic source excerpts; deduplicate repeated sentences and retain
early user anchors in frozen history. Partial text never proves full delivery.

Document native Agent findings, common-context guarantees and limitations.
The fixed 32-peer repetition sample drops 338018 to 6512 characters (98.07%);
this is not a universal compression rate or a lossless semantic guarantee.

Checks on the combined working tree: desktop full suite 1629/1630, with the sole
failure being an obsolete requirement for prompts longer than 24000 characters.
After replacing that assertion with compaction and fidelity checks, the complete
V4 natural-discussion file passed 40/40. Focused context/preferences/MiMo/preload
passed 47/47; frontend 365/365, builds, pack and Electron smoke passed.
git diff --cached --check passed.
2026-10-20 14:32:58 +08:00
Ryder Sun c92b30c2c6 feat: add optional delivery preferences to onboarding and settings
Let users start from a preset and tune delivery detail, initiative, evidence,
preferred collaborators and a short custom note. Make first-run setup skippable
and editable later; refresh collaborator choices after startup detection.

Validate and persist preferences through narrow local IPC with rollback and
active-run protection. Defaults add no prompt, custom notes are bounded, and
preferences never grant write access or add group participants automatically.

Checks on the combined working tree: frontend 365/365; focused context,
preferences, MiMo and preload 47/47; web/desktop builds and pack passed.
Isolated Electron verified presets, custom save, persistence, narrow-window
layout and Escape dismissal. git diff --cached --check passed.
2026-10-20 14:32:35 +08:00
Ryder Sun 67d4084de6 fix: isolate WorkBuddy CLI credentials and native model compatibility
Distinguish the bundled lite CLI's protected desktop login from independent
CLI installs. Do not infer authentication from unrelated provider keys or choose
the first local model. Offer explicit native tool-model selection as a collapsed
advanced option, pass its model through resume, and keep provider auth failures
run-scoped. Renderer APIs expose only model names and IDs; native secrets stay
in the main-process invocation environment.

Desktop-account/current-model reuse remains unresolved. The official assistant
API requires registered application credentials, OAuth and cloud forwarding;
this change does not bypass authentication or claim that integration is complete.

Checks on the combined working tree: readiness/WorkBuddy/catalog 71/71 and
frontend 365/365 passed; builds, pack and isolated Electron checks passed.
Earlier explicit-provider live direct/resume/group checks passed; these do not
prove automatic desktop-account reuse. git diff --cached --check passed.
2026-10-20 14:32:14 +08:00
Ryder Sun 7f9e95d86c fix: honor MiMo native provider configuration when checking readiness
Recognize the selected custom Provider instead of treating official-account
whoami output as proof that a configured native model cannot run. Keep native
model and credential selection inside MiMo and isolate auth probes in tests.

Checks on the combined working tree: focused context/preferences/MiMo/preload
47/47 passed; readiness/WorkBuddy/catalog 71/71 passed. Real MiMo ACP first call,
native Session continuation, and production Electron group invocation passed
without forcing a model. git diff --cached --check passed.
2026-10-20 14:31:41 +08:00
Ryder Sun f77fd8b0c0 fix: remove chat delivery controls and reclaim conversation space
Remove Tasks & delivery from direct and group conversations, including composer
binding and the obsolete UI smoke. Retain stored delivery records and backend APIs.
Hide the sidebar scrollbar while preserving scrolling, and remove the reported
conversation and execution-detail borders without redesigning the workbench.

Checks on the combined working tree: frontend 365/365; web and desktop builds,
desktop pack, and isolated Electron UI/native MiMo smoke passed. The smoke checked
three-row chat layout, no delivery panel, and a scrollable sidebar with no scrollbar.
git diff --cached --check passed.
2026-10-20 14:31:01 +08:00
Ryder Sun 6983e58791 fix: restore local Agent discovery startup animations
What changed
- Restore PixelBlast and WavePhysicsLoader with the three dependency.
- Show the discovery overlay and block workspace interaction while local Agent
  CLI detection runs; release the overlay on both success and failure.
- Restore theme styles and Chinese/English startup copy, with App coverage for
  pending detection, completion, errors and animation wiring.

Why
Restore the intended local Agent discovery startup experience while retaining
the existing workbench once detection settles. Keep this separate from P0 delivery.

Checks
- Final vitest run of DeliveryPanel.spec.js and App.spec.js: 54/54 passed.
- Previously completed combined-tree validation: frontend 362/362, web build,
  desktop build, desktop pack and Electron smoke passed. Pack lacks a Developer
  ID signing identity; deterministic delivery smoke does not test live providers.
- git diff --check and git diff --cached --check passed.

README.md, README.zh-CN.md and meldwork-landing/index.html remain uncommitted.
2026-10-19 22:28:22 +08:00
Ryder Sun c01f96b4c7 feat: add V1.0.7 P0 task delivery and human acceptance
What changed
- Document P0 scope, interactions, task/run/delivery states and acceptance criteria.
- Add optional task cards to existing conversations and bind execution through
  the existing composer; freeze inputs and retain independent delivery versions.
- Support version-specific revision, human review/acceptance, native Markdown
  and file exports, and reuse in a fresh read-only conversation.
- Persist delivery state and retained attachments through validated main-process
  IPC and narrow preload APIs; add component, workspace and Electron smoke tests.

Why
Turn conversations into reviewable work deliveries without replacing the existing
chat, Agent selection or permission flow. Run completion never implies acceptance.

Checks
- Final node --test desktop/test/workspace/delivery-tasks.test.cjs: 9/9 passed.
- Final frontend vitest DeliveryPanel.spec.js and App.spec.js: 54/54 passed
  on the combined working tree (startup restoration is committed separately).
- Earlier combined-tree frontend test: 362/362; web and desktop builds, desktop
  pack and deterministic Electron delivery smoke passed.
- Desktop full suite: 1607/1609. Both failures were 10-second V4 restart waits;
  each passed two isolated reruns. Unmodified main 7deb8a3 also passes in similar
  time (Gate 8.9s, ACP 5.9s), supporting load-sensitive timeout flakiness rather
  than an observed delivery regression. Full suite is not claimed all-green.
- git diff --check and git diff --cached --check passed.

Frontend three dependency and startup animation changes are intentionally left
for the next commit. Existing README and landing-page edits remain uncommitted.
2026-10-19 22:28:04 +08:00
Ryder Sun a501718400 docs: update README and website release information for V1.0.6
Point downloads to the published 0.1.6 Apple silicon asset, use the renamed repository, and describe the release improvements and verification limits. Checked README versions, website metadata, release links, local assets, and git diff --check.
2026-10-19 14:18:39 +08:00
Ryder Sun 7deb8a3d12 Merge feature/1.0.6 into main: Meldwork V1.0.6, and the V1.0.5 line that never reached main
What this brings into main (53 commits)
- The V1.0.5 line, published as a prerelease on 2026-09-17 (tag Meldwork-V1.0.5
  at bb55fad). It was released from feature/1.0.5 and never merged: local Agent
  readiness and recovery, isolated per-conversation Agent sessions, workspace
  preference and titlebar changes, the Pi capability probe, and the General
  Agent workspace positioning.
- Meldwork V1.0.6, the 38 commits after that tag plus the release-prep commit:
  - Agent reliability: tiered authentication failures with transient retries,
    long-lived ACP Agents that survive stops, restarts and hangs, a watchdog that
    no longer kills Agents that are still working, unsafe ACP permission requests
    refused in read-only turns, and WorkBuddy's bundled CLI launching again.
  - Group conversations: replies shown in dispatch order, natural multi-round
    discussions that no longer end on one slip, attachments and knowledge bases
    delivered to group Agents, a group no longer blocked by one unready member,
    and conversations that no longer wait for each other.
  - Interface: startup no longer blocked by Agent detection, marks for
    conversations waiting on a decision, a General settings tab with
    System/Light/Dark appearance, notes explaining why an installed Agent is
    unavailable, and a shared type scale and status colors. The chat page and the
    dialogs keep their previous look.
  - Internal: the 6000-line auto-runner split into modules grouped by purpose.
- Package version 0.1.6 and the CHANGELOG entry.
- docs/ is no longer tracked. That was done by 6aef211 on the V1.0.5 line; the
  files stay in history.

Why
main stopped at eac015d while the V1.0.5 release line, and then V1.0.6, moved
on. This joins them so main is the source for the next release. The V1.0.6 work
had been developed under the name feature/1.0.5; it lives on feature/1.0.6 now
and feature/1.0.5 is back at the published V1.0.5.

Checks
- main had no commit the branch lacked, so there are no conflicts and the merged
  tree is identical to feature/1.0.6 at 11a25df.
- On the code of ce995cb: npm --prefix frontend test 357/357, npm --prefix
  desktop test 1603/1603, deterministic evaluation 6 cases / 18 results, and the
  frontend build and build:desktop passed.
- The release build is verified again on this merge commit before it is tagged.
2026-10-17 19:14:26 +08:00
Ryder Sun 11a25df584 release: prepare Meldwork V1.0.6
What changed
- Package version 0.1.5 -> 0.1.6 in desktop and frontend (package.json and the
  lock files).
- CHANGELOG entry for 0.1.6 (Meldwork-V1.0.6 prerelease).

Why
V1.0.5 was published on 2026-09-17 from the tag Meldwork-V1.0.5 (bb55fad).
Everything after it, 38 commits of Agent reliability, group conversation and
interface work, is version 1.0.6. This line was developed under the name
feature/1.0.5 by mistake. It is feature/1.0.6 now, and feature/1.0.5 is back at
the published V1.0.5.

Checks
- Only version fields and the changelog change, and no test pins the version.
- The code under test is that of ce995cb: frontend 357/357, desktop 1603/1603,
  deterministic evaluation 6 cases / 18 results.
2026-10-17 19:09:45 +08:00
Ryder Sun ce995cb93d feat: refresh the app UI and surface what needs the user
The chat page (header, messages, composer, run status and the animated empty
state) and the modal dialogs are deliberately left as they were before this
refresh: those files are byte-identical to 17c57f5.

What changed
- Design tokens: an 8-step type scale (readable text no smaller than 12px
  outside badges), semantic success/warning/danger/info colors for both themes,
  AA-contrast muted and accent text, a focus-ring token, elevation, layer and
  motion tokens, and a dark backdrop that is darker than the workspace card.
- Startup is no longer blocked by Agent detection. The full-screen WebGL overlay
  and the physics loader are removed (three is dropped from the dependencies);
  the sidebar, home page and composer show "Detecting local Agents" instead, and
  an empty Agent list is not reported as a setup problem while detection runs.
- Needs your decision: conversations waiting on a permission or decision are
  marked in the sidebar (rows, Agent icons, collapsed switcher) and listed on the
  home page. Group runs that finish while you are elsewhere get the same finished
  mark direct chats already had. Collapsed sidebar sections show a count.
- Settings: a new General tab (language, appearance: System/Light/Dark). The
  theme now follows the OS until one is chosen; it used to freeze on first
  launch. Agent cards explain why an installed Agent is unavailable (installed
  version, verified range, failed check) with a Re-detect action. Cloud
  connections ask before deleting.
- Gemini: a detected Gemini CLI gets its own logo. It stays out of the built-in
  list so nobody sees a "Not installed" card for an Agent Meldwork cannot install.
- Toasts: the duration grows with the message length and pauses on hover; errors
  use the alert role.
- Onboarding: a Skip button and arrow-key navigation.
- Unsent text is kept per conversation while the app is open.
- Copy: engineering terms (circuit breaker, hard budget, delivery owner) are
  gone from user-facing status text.
- Status colors, focus ring and the run-details waiting state use the new tokens.

Why
Small text and low-contrast grey carried the most important state (Agent
availability, time, hints); the brand color meant ready, done, running and
warning at once; and the app could not be used until local Agent detection
finished although history is local. This makes states legible and separable and
shows the ones that need the user.

Checks
- npm --prefix frontend test: 43 files, 357 tests passed
- npm --prefix frontend run build and build:desktop: passed
- Not verified in a running Electron window; the changed screens need a visual
  check in light and dark, Chinese and English.
2026-10-16 23:25:46 +08:00
Ryder Sun ef9933d571 fix: launch the WorkBuddy app's bundled CLI, which now ships only the lite bundle
What changed
- WorkBuddy is launched with CODEBUDDY_FORCE_LITE_WB_BUNDLE=1 when the install
  carries only dist/codebuddy-lite-wb.mjs, both for the capability probe
  (--help) and for real runs (the child environment). Installs that carry a
  regular bundle launch exactly as before, and other Agents are unaffected.
- Tests cover the lite-only layout, an install with a regular bundle, another
  Agent kind, an unreadable layout, a symlinked launcher, and the run
  environment.

Why
WorkBuddy 5.8.x updated its bundled CLI (2.161.0) to the lite bundle, which its
launcher loads only when that variable is set. Without it every headless call,
--help included, exits with MODULE_NOT_FOUND ("Cannot find module
'../dist/codebuddy'"). The capability probe therefore failed, WorkBuddy was
marked incompatible, and it disappeared from the usable Agent lists (new group,
@ menu). A CLI version outside the verified range does not block an Agent on its
own; only a failed capability probe does.

Checks
- npm --prefix desktop test: 1603 passed, 0 failed
- detectAgents() on this machine now reports WorkBuddy 2.161.0 as compatible; it
  was incompatible with a failed workbuddy-print probe before
2026-10-16 23:24:37 +08:00
Ryder Sun 17c57f57f8 test: a conversation stuck on an Agent must not stop another from using it
End-to-end version of the blocking fix, on the real LocalWorkspace: one direct
chat's turn never finishes, and a second direct chat with the same Agent has to
answer anyway while the first is still marked running. On the commit before the
fix this test times out, which is the symptom that was reported (the second
conversation sat on 'waiting for another Agent that is writing in the same
folder' until the first finished).
2026-10-16 16:10:03 +08:00
Ryder Sun 5054553a16 fix: the agent avatars no longer draw over the composer's @ menu
In a group chat the strip of member avatars above the composer (the run
status for the latest topic) was painted on top of the @ menu, covering its
'Agents' heading and the first rows.

The avatars overlap each other and order themselves with z-index 100, 99, 98
and so on. Nothing around them created a stacking context, so those numbers
competed with the page directly and beat the menu (z-index 4). The strip is
now its own stacking context, so the ordering only applies between the
avatars. The composer box gets the same treatment for the member chips (z-index
1-6): they were only contained while the box was focused, because the box's
focus transform happened to create a context.

Checked in a real renderer (headless Electron, the built frontend with a stub
bridge, a six-member group with a finished run and with a running run, composer
focused and not): the points of the menu covered by something else go from 36
to 0 in both states. jsdom has no layout, so composerStacking.spec.js pins the
CSS that keeps the numbers contained; two of its three cases fail without the
fix.

Checked with: npm --prefix frontend test (336 pass), build:desktop.
2026-10-16 16:02:48 +08:00
Ryder Sun e1500a73ef fix: conversations no longer wait for each other, even in the same folder
Sending to an Agent while the same Agent (or any Agent) was busy in another
conversation made the new conversation sit on 'Running' until the first one
finished. The cause was the writer lease, which was keyed by the real folder:
every writable conversation that used the same folder (and most keep the
default one) took turns, one at a time, across groups and direct chats.

9198bf6 had deliberately scoped the lease of direct chats to the chat so that
one writable chat could not block another Agent. 17cbd32 reverted that as a
regression and added a 'waiting for another Agent writing in the same folder'
notice. That was the wrong call: it made the product behave like a single
queue. This commit reverts 17cbd32 and goes one step further than 9198bf6:

- the writer lease is scoped to the conversation for groups as well as direct
  chats, so conversations never wait for each other; writers inside one
  conversation still take turns
- the waiting notice, the waitingForWorkspaceKinds snapshot field and the
  scheduler queue callbacks that only existed for the folder lock are removed
  with the revert
- the old rule 'group chats serialize writes across symlink aliases' is
  replaced by its opposite; new tests cover the same Agent in two direct chats
  and a group at once, group chats sharing a folder through a symlink alias,
  and that turns of one conversation share a lease scope while another
  conversation in the same folder does not
- architecture.md states the new rule and the trade-off

Trade-off: two Agents in different conversations can now edit the same files
at the same time, like two terminals in one directory. Output capture compares
.meldwork-output/ before and after a run, so when two conversations produce
deliverables in the same folder at the same time one may attach a file the
other wrote. No guard was added for that.

The new write-scheduling tests fail on the previous commit (two fail, one
never gets its second Agent started).

Checked with: local-workspace-write-scheduling (6 pass), run-scheduler,
run-coordinator, state, harness, npm --prefix frontend test (333 pass).
2026-10-16 15:10:35 +08:00
Ryder Sun 4a0915594f test: pin that unsupported ACP client requests get an error, never a success
fs/* and terminal/* requests from an Agent that never was offered those
capabilities, and unknown methods, must each receive an error response. The
behaviour held partly through parameter validation; the test now states the
contract (no result field, numeric error code, unknown methods get -32601).
2026-10-16 11:55:16 +08:00
Ryder Sun 5641969445 fix: switching between read-only and write no longer restarts the ACP process
The ACP runtime was identified by a signature that included the session mode
(plan/build for MiMo and OpenCode, default/accept_edits for Hermes). Group
discussions alternate between a read-only proposal and a write-enabled turn, so
the signature changed, the process was closed and the stored native session was
reported invalid, losing the conversation's context on every switch.

The mode is applied on every turn with session/set_mode, so it does not
identify a process. What genuinely needs another process (Hermes' and
OpenCode's permission policy is passed through the environment) is already part
of the signature through the environment, so those still start separately.
MiMo, whose arguments and environment are identical in both modes, now keeps
one process and one session across a read-only turn, a write turn and another
read-only turn.

The new test fails on the previous commit with LOCAL_AGENT_SESSION_INVALID.

Checked with: cli-native-acp-lifecycle (16 pass).
2026-10-16 11:51:00 +08:00
Ryder Sun 4b8ddd917f docs: describe the module layout and reliability rules, and fix dead links
architecture.md pointed at docs/flows.md, permissions.md, variables.md and
automation.md, which exist on no branch, and at three more that exist only on
main (this branch keeps /docs/ untracked). The related-documents list now
links only files tracked in the repository.

Adds a Code Map (what each invocation, prompt and auto-runner module owns and
where new behaviour should go) and the Reliability Rules the harness now
enforces, so the next change starts from the layout instead of the 6000-line
class.
2026-10-16 11:43:34 +08:00
Ryder Sun 0e4f1620e6 refactor: split the 6000-line auto-runner into files grouped by what they do
LocalWorkspaceAutoRunner was one class of 6000 lines. Its 99 largest methods now
live in eleven files named for their topic (recovery, collaboration,
natural-discussion, v4-context, v4-delivery, v4-synthesis, v4-results,
v4-phase, v4-discussion, task-graph, rounds) and are assembled onto the same
prototype the way the existing natural-decisions and v4-outcomes parts already
were. local-workspace-auto-runner.cjs keeps the constructor, start/resume and
run-level helpers (285 lines); the largest remaining file is 965 lines.

This is a move, not a rewrite. The split was generated by a script that copies
method source verbatim, and checked three ways before the tests ran:
- all 135 prototype methods exist with the same name and an identical source
  hash before and after (only the class constructor text differs, because the
  mixins are listed after it)
- a scope analysis finds no identifier that is read but not declared or
  imported in any of the 15 auto-runner files
- node --check on every file

The V2 round and V3 task-graph runs, which only exist to resume older runs,
are in their own files so they can be deleted together when that support ends.

Checked with: npm --prefix desktop run test:workspace (627 pass).
2026-10-16 11:43:34 +08:00
Ryder Sun 958d18152b fix: MiMo/OpenCode turns with tool calls are no longer cut short, and a CLI's own error is no longer masked by EPIPE
Two ways a CLI run ended early or with the wrong reason:

- MiMo and OpenCode print a step_finish after every model step. A step that
  ends with reason tool-calls is followed by more steps, but the transport
  terminated the process 250 ms after the first step_finish, so every turn
  that used tools came back as a partial answer. Only a final step now ends
  the run.
- Codex reads its prompt from stdin. A CLI that exits at once (expired
  sign-in, rejected flag) while a prompt larger than the pipe buffer is still
  being written produces EPIPE, which was reported as LOCAL_AGENT_SEND_FAILED
  instead of the CLI's own message. A closed pipe now waits for the process to
  finish and reports its stderr (so a sign-in failure is recognised as one);
  a process that stays alive without reading is still a send failure after
  5 seconds.

Both regressions fail on the previous commit.

Checked with: cli-adapters (74 pass incl. cli-process-support).
2026-10-16 11:29:28 +08:00
Ryder Sun bf49afcd92 feat: a member that is not ready no longer blocks messages to the whole group
Sending to everyone in a group failed with 'this local Agent is unavailable'
as soon as one member was not ready, so one broken CLI made the whole group
unusable. When the recipients are implicit (everyone, or auto discussion),
members that are not ready are now left out and the user is told which ones
were skipped. Agents the user picked or @mentioned explicitly are kept so the
send can still report them, and when nobody is ready the full list is kept so
the problem is not hidden.

Checked with: composerContext spec (3 new cases), npm --prefix frontend test
(333 pass).
2026-10-16 11:21:36 +08:00
Ryder Sun 49ed8aee0f fix: an Agent stays available through an inconclusive probe, and keeps its shell environment
Two ways a healthy Agent used to drop out of use without anything being wrong
with it:

- readiness: OpenClaw's 7 second status check, or any probe that crashes,
  marked the Agent unavailable at once, so it vanished from the picker until
  the next successful probe. A timeout is not a 'not signed in' answer. An
  Agent that was available now stays available through inconclusive results
  for up to 10 minutes; a definitive answer (signed out, incompatible version,
  a recorded auth failure) still applies immediately, and an Agent that was
  never available is not made available by it.
- login shell: when the shell used to capture PATH and API keys did not answer
  (slow rc file, cold boot), the bare app environment was used and cached for
  30 seconds, so Agents started without the user's PATH or keys and failed
  with sign-in errors. The last environment the shell produced is kept and
  reused; the timeout is 10 seconds; with no earlier success the failure is
  cached for 5 seconds instead of 30, measured from when the capture ends.

Both new tests fail on the previous commit.

Checked with: local-workspace-agent-catalog (22 pass), local-agent-readiness
(39), cli-network-environment, main-security (51).
2026-10-16 11:21:36 +08:00
Ryder Sun 462c1bd783 fix: an ACP turn no longer dies on traffic this client does not know
The ACP transport failed the whole turn on anything it did not recognise: a
banner line on stdout, a notification with an unknown method, a session update
kind added by a newer Agent, or an unknown request. Every Agent upgrade that
adds a message therefore broke Meldwork until it was updated, and a single
large tool result (over 1 MiB on one line) failed the turn.

- stdout lines that are not JSON objects are ignored
- unknown notifications and session updates that fail validation are dropped
  before they reach the SDK, which logs rejected messages with their params
- unknown requests are forwarded with their params stripped, so the Agent gets
  a proper 'method not found' answer instead of waiting, and nothing
  secret-bearing is logged
- the per-line limit is 4 MiB (still bounded; total input and reply limits are
  unchanged)
- truncated JSON that starts like a message, an invalid response payload and
  invalid permission requests still fail closed

The Kimi 'unsafe protocol input' test described the old behaviour; it now
checks that malformed JSON still fails closed and that unknown traffic carrying
a secret neither fails the turn nor reaches a log. The new tolerance test
fails on the previous commit (LOCAL_AGENT_PROCESS_FAILED).

Checked with: cli-adapters-agent-protocols (69 pass), cli-native-acp-lifecycle
(15), cli-acp-tolerance, cli-adapters (64).
2026-10-16 11:21:36 +08:00
Ryder Sun a97afa9b5b feat: deliver attachments and knowledge bases to group Agents; one prompt module per mode
Group chats with several Agents run on the V4 path, which never forwarded the
user's attached files to the Agents (the send was validated against what each
Agent can read, then the files were dropped), and its prompts omitted the
knowledge-base hints, the answer language and the deliverable folder contract.

- attachments are now staged for manual group replies and for every turn of
  an automatic discussion; frozen prompts only gain the staged-file section
- manual group replies no longer use the phased 'proposal' template (asking
  for capabilities, work items and a receipt). prompts/manual-reply-prompt
  tells each Agent that everyone answers on their own, whether it may change
  files, and to answer in the user's language
- natural discussion prompts move to prompts/natural-discussion-prompt with
  named sections (brief, writer, owner, routing, receipt), now including the
  group name and topic, knowledge bases, the answer language and, for the
  writer, the .meldwork-output contract
- shared language and delivery contracts live in prompts/prompt-contracts

Also made the hanging-ACP-initialize test independent of spawn counting: under
load the 300 ms timeout killed the first process before it recorded itself, so
the next spawn counted as the first one and hung (16 of 32 stressed runs).

Checked with: new tests for each prompt module and for attachment delivery
(both fail on the previous commit); the V4, context-pack and delivery test
files pass.
2026-10-16 10:49:44 +08:00
Ryder Sun 3ce566f152 refactor: split the invocation's session, outputs, budget, prompt and failure handling into modules
The 1500-line invokeLeased still mixed session bookkeeping, workspace output
capture, budget accounting, prompt assembly and failure classification with
the run lifecycle. Each is now a small module with unit tests:

- InvocationSession: one place that resumes, adopts, discards (rebuilds) and
  completes the native session; the three copies of the 'start a fresh
  session' dance, one of them dead code, are gone
- InvocationOutputs: workspace baseline, media import and outcome descriptors
- invocation-budget: usage recording; a payload without a recorded wire size
  no longer fails the turn
- invocation-prompt: the prompt one Agent receives (frozen, direct, group)
- invocation-failure: why an invocation ended (stopped, timed out,
  interrupted, cancelled, failed) and the hidden error metadata
- permission helpers for resumed decisions moved next to the permission policy

invokeLeased is about 1000 lines shorter and reads as the run lifecycle.
Behaviour is unchanged; focused invocation tests and the new module tests
pass.
2026-10-16 10:21:25 +08:00
Ryder Sun 68145eb319 refactor: extract receipt parsing, watchdog, stream marker filter and permission policy from the invocation
local-workspace-agent-invocation.cjs held four independent pieces of logic
inside one 1500-line method. They now live in small modules with their own
tests, and the invocation only wires them together:

- collaboration/collaboration-receipt.cjs: parse and normalise the receipt
  (unchanged behaviour; the existing receipt tests now import it directly)
- runs/invocation-watchdog.cjs: startup, inactivity and stall windows
- collaboration/answer-marker-filter.cjs: hides control markers while the
  answer streams
- agents/agent-permission-policy.cjs: automatic answers to permission
  requests, read-only turns only approve side-effect-free tools

Two latent defects fixed on the way:
- the watchdog cleared its timer on the first event without re-arming it, so
  an Agent that went silent right after its first event was never stopped
- the marker filter upper-cased the buffer to find markers, which shifts the
  cut position for characters whose upper-case form is longer (e.g. ss)

Checked with: node --test on the new module tests; npm --prefix desktop run
test:workspace (595 pass).
2026-10-16 10:09:38 +08:00
Ryder Sun c3c090f83b fix: write state compactly and durably, and record main-process faults
- The workspace state is rewritten synchronously on the main process for
  every save. It is now compact JSON written through the private atomic
  writer, which fsyncs before renaming (the old path could leave a
  truncated file after a crash). The Run Ledger snapshot is compact too.
- Unhandled rejections and uncaught exceptions in the main process are
  written to a size-capped, secret-redacted meldwork-diagnostics.log in
  userData instead of disappearing, and a renderer that crashes is
  reloaded instead of leaving a blank window.

Checks: run-ledger, local-workspace-state, local-workspace and
main-security tests (185 pass). Full desktop suite: 1540 pass; frontend
web and desktop builds pass (existing large-chunk warning only).
2026-10-15 19:38:33 +08:00
Ryder Sun 26d4be2404 fix: compare installed Agent CLI versions as releases
The installer decided "up to date" and "already installed" with a
substring test, so 11.2.3 counted as 1.2.3 and a newer CLI was offered a
downgrade. It now extracts both versions and compares them as releases
(installed is current when it is the recipe's version or newer), falling
back to the old string match only when a version cannot be parsed.

Checks: agent-installer and the installer facade API test (37 pass,
including a new version comparison test). Full desktop suite: 1540 pass.
2026-10-15 19:38:33 +08:00
Ryder Sun 00ba2d3b38 fix: import only files an Agent wrote, including paths with spaces
28127b3 imports files whose absolute paths appear in a reply, to catch
outputs written outside .meldwork-output. Those candidates skipped the
baseline and start-time checks, so a file the Agent merely mentioned
(the README it read, the user's staged attachment) was imported as an
output, and paths with spaces were cut at the first space.

Reported paths are now imported only when their content changed during
this run and they are outside the .meldwork-input staging folder.
Backticked, quoted and Markdown-linked paths keep their spaces.

Checks: agent-output-importer (11 pass, including a new reported-path
test). Full desktop suite: 1540 pass.
2026-10-15 19:38:33 +08:00
Ryder Sun 17cbd32387 fix: keep one writer per folder and show when an Agent waits for it
9198bf6 gave every direct chat its own scheduler key so that one writable
chat would not block another. That broke the one-writer-per-folder rule:
two Agents could edit the same files at once, and a direct chat and a
group could write the same folder concurrently. The underlying problem
was that a writer waiting for the folder lease showed nothing and looked
like a dead Agent (no queued event, no watchdog, no output).

- Scheduler requests report onQueued/onGranted when they actually wait.
- Direct chats use the real-folder key again; while an Agent waits for
  the folder, the run lists it in waitingForWorkspaceKinds and the run
  status card says it is waiting for another Agent writing in the same
  folder (zh/en). A stopped wait clears the mark.

Checks: run-scheduler (new queue notice test), write-scheduling (the
concurrent-direct-chat test now asserts the second writer waits, is shown
as waiting, then runs), run-coordinator public run keys. Full desktop
suite: 1537 of 1538 before adding the new public key to that list, which
now passes; npm --prefix frontend test (330).
2026-10-15 19:17:03 +08:00
Ryder Sun 8d8f9e65f6 fix: let discussion peers see workspace file paths instead of [path]
Peer transcripts in natural discussions replaced every token that starts
with "/" by [path]. Agents working on the same repository could not tell
each other which file they changed, and API routes such as /api/users or
commands such as /login were erased too.

Natural discussion transcripts now show files inside the shared
workspace as workspace-relative paths, keep non-filesystem tokens, and
still redact other absolute local paths (/Users, /tmp, ...) and secrets.
Phased-V4 delivery text keeps the previous redaction because its hashes
take part in resume checks.

Checks: collaboration-records (new peer path test), natural discussion
(new end-to-end relative path test), natural transcript and delivery
suites. Full desktop suite: 1537 pass.
2026-10-15 19:04:42 +08:00
Ryder Sun b2249f0859 fix: stop skipping every WorkBuddy permission check in write mode
28127b3 added --dangerously-skip-permissions next to acceptEdits when a
conversation allows workspace writes. That flag lets the CLI run any
command and write anywhere on the machine without confirmation, which
goes beyond Meldwork's opt-in workspace-write boundary and differs from
every other Agent. Write mode now relies on acceptEdits, which accepts
file edits in the working directory.

The local machine has no CodeBuddy CLI, so an edit-only tool allowlist
was not added; an unverified flag could break every WorkBuddy turn.

Checks: new cli-adapters test for WorkBuddy write/read-only arguments;
WorkBuddy adapter and protocol tests (7 pass). Full desktop suite: 1537.
2026-10-15 19:04:42 +08:00
Ryder Sun 6e0becfb70 fix: refuse unsafe ACP permission requests during read-only turns
Local invocations answer ACP permission prompts automatically. The
handler picked the "allow" option for every request, ignoring whether the
turn was read-only, so a read-only discussion participant (for example
Hermes running with command approval prompts) had edit, delete, move,
command execution and mode-switch requests approved.

Read-only turns now approve only side-effect-free tool kinds (read,
search, fetch, think) and refuse everything else, including requests
without a recognised kind. Writable turns and the explicit Human Gate
mode are unchanged.

Checks: new local-workspace-permission-policy tests (read-only refusal
matrix, writable approval). Full desktop suite: 1537 pass.
2026-10-15 19:04:42 +08:00
Ryder Sun 6c0f773d3f fix: ask discussion Agents to engage with each other and stop nudging them to stop
The natural discussion prompt told the owner "another reviewer is
optional", told every Agent it "may finish with nextKinds: []", and showed
a receipt example with empty nextKinds and deliverables. Proposals were
told to "engage with peers" although they run independently; sequential
discussions advertised routing that the code ignores; and the response
scope pointed at an "authoritative" label that natural prompts never
show. Agents therefore rarely requested each other and discussions
collapsed into one owner turn.

- Proposals: say peers answer independently and will be discussed next
  round. Discussion turns: respond to peers' specific points, dispute
  with reasons, build on their work; end every turn with the receipt.
- Owner: before completing, make sure the relevant peers reviewed the
  actual deliverables, otherwise request them with a concrete question.
- Peers: answer the points raised; blocked or needs-human goes to the
  owner, who decides.
- Routing: list every peer whose input is needed and ask them a concrete
  question; the receipt example names a real peer and shows the completed
  form with deliverables.
- Sequential discussions no longer advertise routing and instead say the
  next participant runs after this invocation ends.
- The response scope refers to "the current user task stated above".

Checks: local-workspace natural discussion, natural writer, delivery,
transcript, context-pack and harness suites (152 pass). Full desktop
suite: 1532 pass.
2026-10-15 18:50:46 +08:00
Ryder Sun d22c8eb1f0 fix: keep natural discussions going instead of ending on one slip
Automatic group discussions (the production auto path) ended as
"partial" for reasons that had nothing to do with the task:
- any receipt whose nextKinds named an absent or paused Agent stopped the
  whole discussion with "no valid task completion decision";
- a peer's blocked or needs-human status ended the run on the spot,
  although only the delivery owner judges the task;
- an owner who failed, was paused, or handed off to an absent Agent ended
  the run silently;
- one missing owner receipt stopped the run without asking again;
- strict receipt and taskDecision grammar failed the Agent's whole turn
  for "Done", an @handle, a single string instead of a list, a trailing
  comma or code fence, an extra key, or a long summary, and the failed
  Agent was then excluded from the discussion.

Now absent recipients are dropped with a notice and valid requests still
run; a peer blocker is forwarded to the owner, who decides; the next
active selected Agent takes over ownership with a notice; an ignored
handoff keeps the current owner; an owner without a readable decision is
asked once more and the run stops only after two undecided owner turns;
completing without stopped participants explains why the run is partial.
Discussion receipts are normalised (status synonyms, @handles, case,
lists, smart quotes, trailing commas, closing fences, extra keys, missing
summary) before the strict parser runs, and an unreadable decision is
treated as no decision instead of failing the turn. Phased-V4 receipts
keep their strict contract. New zh/en notices explain each case.

Checks: natural discussion, natural writer, receipt, task-decision and
human-gate recovery suites (99 pass on this tree, including a new fenced
loose-receipt test and updated tests for the new routing, blocker and
handoff behaviour); npm --prefix frontend test (330). Full desktop suite
with the following prompt commit: 1532 pass.
2026-10-15 18:50:46 +08:00
Ryder Sun f994225990 test: give the hanging ACP initialize test a realistic second-spawn timeout
The test used a 100 ms initialize timeout for both turns. Under a loaded
full-suite run the healthy second fake-Agent process also needed more than
100 ms to start, so it fell back too. Only the first process hangs; the
second turn now uses the default timeout.

Checks: the lifecycle test passes three runs in a row.
2026-10-15 18:28:52 +08:00
Ryder Sun 06e1e8772c fix: render group replies in the order Agents were dispatched
No message carried a turn order. The timeline rebuilt one from round,
phase, the Agent's current position in the group and the save time, and
those disagreed:
- live bubbles had numeric startedAt, which Date.parse turns into NaN, so
  every running Agent was pinned under the user message and the list
  reshuffled each time a reply was saved;
- manual concurrent mode wrote failure notices before the barrier
  committed successful replies, so failures always rendered first;
- round-less notices sorted after every round, the 'discussion' phase
  had no rank, and editing the group reordered history;
- natural proposals were saved in completion order and peers read the
  transcript in that order, unlike what the user saw.

Now every group topic message gets a per-topic turnSeq. Batch
dispatchers (manual concurrent replies, natural proposal and discussion
batches) reserve numbers in slot order before dispatching; other turns
reserve at invocation start; failure and stop notices take their turn's
number; other topic messages take the next number when written. The
harness exposes the number on live Agent runs, so a live bubble and the
reply that replaces it share one position. Numbers are derived from saved
messages after a restart (no ledger or journal field changes). The
renderer sorts group topics by turnSeq with one shared rule for the
timeline and the trace panel; older history keeps its saved chronological
order. A failed attempt whose retry was already saved no longer lingers
as a ghost bubble, and peers read turns in dispatch order.

Checks: new local-workspace-turn-order tests (slot order with a failing
middle Agent and reversed completion; numbering continues after restart;
none on direct chats), natural discussion, manual durability and write
scheduling tests (63 pass on this tree); npm --prefix frontend test
(330 pass, including a new conversationOrdering spec and a
production-shaped live round test). Full desktop suite on the combined
tree: 1529 of 1530, the remaining failure being the ACP timing test fixed
in the next commit.
2026-10-15 18:28:52 +08:00
Ryder Sun 6522d357e0 fix: keep long-lived ACP Agents usable across stops, restarts and hangs
Persistent ACP runtimes (Hermes, Kimi, MiMo, OpenCode) were fragile:
- any turn error, including a user stop or watchdog timeout, destroyed
  the process and its native session; Hermes rejects cross-process
  resume, so the conversation then fell back to the silent legacy CLI;
- the process identity hashed the whole child environment, so a fresh
  login-shell PATH (fnm uses a per-shell directory) restarted the process
  and invalidated the stored session;
- initialize had no timeout and ignored stop while holding the
  conversation lock, leaving that Agent stuck until the app restarted;
- stderr kept the first megabyte of the process lifetime, so new
  failures were classified from stale lines.

Now a stopped or timed-out turn waits up to 3 s for the Agent to answer
the cancelled prompt and keeps the process and session when it does;
volatile shell values (PATH, TERM, TMPDIR, fnm variables, ...) no longer
change the process identity; initialize times out after 60 s (settable
per call) and honours stop without falling back; stderr keeps only the
current turn's last 64 KB. Disposable runtimes still close after every
turn.

Checks: cli-native-acp-lifecycle with three new fake-ACP tests (hanging
initialize releases the lock, a cancelled turn reuses one process and
session, a changed PATH keeps the process), plus the existing lifecycle,
adapter and protocol suites (185 pass). Full desktop suite: 1528 pass.
2026-10-15 18:04:17 +08:00
Ryder Sun 28fb01eff5 fix: stop the watchdog from killing Agents that are still working
Group runs gave an Agent three minutes to produce a mapped runtime event,
and only mapped events lifted that deadline. ACP agent_thought_chunk was
mapped to nothing, so Hermes, Kimi, MiMo, OpenCode and OpenClaw were
killed while thinking, and auto mode never retries timeouts. The 3/5
minute inactivity windows also cut off real builds and slow providers.

- Any sign of life (stdout/stderr bytes, ACP traffic) now lifts the
  startup deadline. In automatic discussions a separate no-progress cap
  (three inactivity windows) still stops CLIs that only emit heartbeats,
  so a stalled participant cannot block the round; manual and direct
  turns keep running while the process is active.
- Defaults are 10 minutes of inactivity and 20 minutes while a tool runs.
- ACP thinking is reported as a text-free reasoning status event
  (running, then completed when the answer starts); the private thought
  text never leaves the main process.

Checks: local-workspace-harness (74, including a new slow-starting Agent
test that failed with the old hard deadline, and the existing
heartbeat-only stop test), cli-adapters and cli-adapters-agent-protocols
(event order now includes the reasoning status). Full desktop suite:
1528 pass.
2026-10-15 18:04:17 +08:00
Ryder Sun 5fc70afde9 fix: explain run failures in Chinese and English instead of raw codes
Several codes that end an Agent turn or a whole run had no translation,
so the timeline showed a generic error or the raw code (for example
LOCAL_RUN_PERSIST_FAILED), and users could not tell why a discussion
stopped. The round-limit notice also claimed "no consensus" although
natural discussions have no consensus step.

- Map access-denied, session-invalid, output-limit, send-failed,
  refused, permission race, version/capability/protocol mismatch,
  task-decision and receipt errors, context overflow, persistence
  failures and the circuit breaker to zh/en messages.
- The round-limit notice now says the latest replies are the current
  result and how to continue (backend fallback text updated too).

Checks: npm --prefix frontend test (325 pass); updated assertions that
expected the raw code and the old round-limit wording.
2026-10-15 17:40:04 +08:00
Ryder Sun 35ed0d4e4a fix: send Hermes image attachments over ACP instead of dropping them
eb2234b stopped switching Hermes to its legacy transport for image
attachments to preserve the ACP session, but the ACP runner only sends a
text block, so images reached Hermes as nothing at all (the adapter test
for this failed on bb55fad).

Hermes 0.21.5 advertises promptCapabilities.image during initialize. The
runner now keeps the initialize capabilities, and when an ACP spec
carries imageAttachments it sends them as ACP image content blocks
(png/jpeg/gif/webp, up to 10 MB each) on the same session. An Agent that
does not advertise image prompts fails before prompting, so the existing
setup fallback uses the legacy transport with its --image argument.

Checks: cli-adapters (Hermes invocation spec) and a new
cli-native-acp-lifecycle test with fake Hermes ACP agents covering the
image-capable path and the legacy fallback. The capability response was
verified against the locally installed Hermes 0.21.5 with an
initialize-only handshake (no session or model call).
2026-10-15 17:39:43 +08:00
Ryder Sun 624a099bdd fix: give direct chats the current request, delivery contract, and full task
Commit 9198bf6 made direct chats send only the latest message text and
rely on the native session. After 1c9aabb a rebuilt session received the
continuation text instead, and that text deliberately excludes the
latest user message, so the Agent never saw the request it was answering.
Direct prompts also lost the deliverable contract (.meldwork-output/),
the read-only media warning, staged attachment paths, and regeneration
and recovery instructions, which is why generated files were not
captured and three media tests failed on bb55fad.

- New directPromptFor() leads with the user's own words. A new or rebuilt
  native session gets the earlier conversation plus "Current request:";
  a resumed session gets the request alone. The full delivery and
  language contract is sent once per native session, a one-line reminder
  afterwards. Staged inputs, recovery and regeneration instructions are
  appended again.
- The shared deliveryContract() is used by group and direct prompts.
- Isolated (task-graph) invocations again neither resume nor persist the
  conversation's native session (3ce8ee4 made them resume it, breaking
  the "approved prompt only" contract).
- The current task is no longer cut to 6,000 characters, and longer
  messages no longer fail every turn with
  LOCAL_RUN_REQUIRED_CONTEXT_OVERFLOW: submitted messages are already
  capped at 20,000 characters, so the full request is delivered.

Checks: local-workspace-harness (73) and local-workspace-context-packs
(15) pass on this tree, plus the direct, media and custom-label tests in
local-workspace and native-streaming (18), five of which failed on
bb55fad.
2026-10-15 17:39:18 +08:00
Ryder Sun 2da40db63d fix: tier authentication failures and retry transient provider errors
The merged branches disagreed on authentication failures: feature/1.0.5
marked every runtime auth failure 'unknown' so readiness never flipped,
while main's 22f811c classified any auth-looking diagnostic as
LOCAL_AGENT_AUTH_REQUIRED. Together with broad regexes (a bare
"forbidden" anywhere, a credential regex matching "login" or "403"),
proxy refusals stopped Agents while real sign-outs left them "ready".

- New pure module runs/failure-signals.cjs classifies CLI text:
  explicit sign-in or API-key statements are definitive; line-leading
  403/forbidden/unauthorized and permission_error are ambiguous; tool
  output that merely mentions 403 is neither. It also recognises rate
  limits (with Retry-After hints), network failures, provider 5xx and
  context overflow.
- Definitive failures stay LOCAL_AGENT_AUTH_REQUIRED and now mark the
  credential 'missing' (the catalog clears it after the retry window or
  when native credentials verify). Ambiguous refusals become
  LOCAL_AGENT_ACCESS_DENIED, are retried once, and never touch readiness.
- Process failures whose diagnostics show throttling, network loss or a
  provider outage are retried in that category. Rate limits honour
  Retry-After up to 60 s and wait at least 2 s without a hint.
- A resumed session that no longer fits the model context is classified
  as LOCAL_AGENT_SESSION_INVALID so the existing fresh-session retry
  rebuilds it; Rust panics and JSON parse errors no longer discard a
  healthy session.
- Automatic retries no longer re-run the whole Agent catalog refresh
  before each attempt, which flipped busy Agents to unavailable.

Checks: failure-signals (new), failure-policy, cli-process-support,
cli-adapters-agent-protocols, agent-runtime-contract and the auth/retry
tests in local-workspace and local-workspace-auto pass on this tree,
including three readiness tests that failed on bb55fad.
2026-10-15 17:37:57 +08:00
Ryder Sun 1033d086d3 fix: keep group members after Agent failures and recover emptied groups
An authentication or compatibility failure in an automatic discussion
called removeAgent, which rewrote and persisted group.agentKinds. One
proxy 403 or expired OAuth refresh made the Agent disappear from every
later turn of that group, and when the last member was removed the group
could no longer load: normalizeLoadedGroup rejects an empty member list,
so the next start silently dropped the group together with all of its
messages.

- The auto runner now pauses a failed Agent only for the current run.
  Group membership is never edited; the run-scoped exclusion already
  persisted in orchestration activeKinds keeps resume behaviour intact.
- The notice says the Agent is paused for this discussion, with the
  failure reason, and stays in the group (system.agentPausedAfterFailure,
  zh/en). The removal notice text is kept so history still renders.
- removeAgent refuses to remove a group's last member.
- Loading rebuilds an emptied group's members from the Agents that
  replied in it instead of dropping the group and its history.

Checks: node --test local-workspace-state.test.cjs (3 pass, including a
new recovery test) and the membership tests in
local-workspace-auto.test.cjs. Two tests there that also expect a
'missing' credential state stay failing until the next commit (they
already failed on bb55fad).
2026-10-15 17:36:07 +08:00
Ryder Sun 4f31b77503 chore: remove scratch files committed at the repository root
Commit 6aef211 added temporary icon exports, two large PNG screenshots
and a confirmation.json backup to the repository root. AGENTS.md forbids
placing screenshots and temporary files there and nothing references
them.

Checks: rg found no references to the removed paths.
2026-10-15 16:33:10 +08:00
Ryder Sun 6b6be1403e Merge main into feature/1.0.5
Bring main's 22f811c (auth classification from errors[], OpenClaw runtime
scope per CLI version, full participant round and cross-review gate for
natural discussions) and eac015d (removal of the managed shared Provider)
into the V1.0.5 branch before the stability work.

Conflict resolution:
- local-workspace-agent-catalog.cjs: keep feature's guard that an
  'unknown' runtime credential does not downgrade an authenticated Agent;
  drop the provider-credential-unavailable clause removed on main. The
  tiered auth policy is reconciled in a follow-up commit.
- README.md / README.zh-CN.md: keep feature's V1.0.5 summary and apply
  main's wording that Agents use their own CLI's model provider.
- docs/*: keep feature's untracking of local documentation drafts.
- cli-adapters-discovery.test.cjs: both sides were equivalent.

Checks: npm --prefix frontend test (325 pass). npm --prefix desktop test:
1503/1515 pass. The 12 failures are pre-existing on feature/1.0.5 at
bb55fad (verified by running them on a detached worktree of bb55fad) and
are addressed by the follow-up fixes.
2026-10-15 16:32:53 +08:00
Ryder Sun eac015dbfa refactor: remove the managed shared Provider capability
Each Agent CLI now runs only with its own native credentials. The central
path of configuring one provider inside Meldwork for every Agent added
instability without being realistic to maintain, and it depended on
machine-local configuration details.

- Delete the provider store/options modules, the local-agent-provider IPC
  handlers and preload API, per-CLI env injection (CODEBUDDY_*/ANTHROPIC_*/
  KIMI_* ...), the managed OpenClaw provider runtime, and shared-provider
  catalog gating; startup removes the legacy encrypted
  meldwork-provider.json.
- OpenClaw always resolves its native runtime now; native credential
  passthrough, the isolated gateway/guard runtime, and the
  runtime-auth-failure state machine stay unchanged.
- Remove the Provider settings UI, onboarding slide, providerMode display,
  ~100 i18n keys per language, provider CSS, and update the architecture,
  security, and discoverability docs to the native-credentials boundary.

Checks: npm --prefix desktop test (1512 pass), npm --prefix frontend test
(325 pass), frontend build and build:desktop, desktop pack.
2026-10-14 16:59:21 +08:00
Ryder Sun 22f811cde2 fix: harden local agent runs and broaden natural discussion rounds
- Extract WorkBuddy/Claude/Qwen error-result lists (errors[]) into failure
  diagnostics and classify authentication failures as
  LOCAL_AGENT_AUTH_REQUIRED instead of a bare LOCAL_AGENT_PROCESS_FAILED,
  so the catalog marks the Agent as needing a fresh login and retries stop.
- Scope the isolated OpenClaw runtime directory by session, workspace, and
  CLI version, so an upgraded OpenClaw gets a fresh managed scope instead
  of failing on legacy workspace state written by an older CLI.
- Natural agent-led discussions now open with a full participant round and
  the delivery owner cannot declare completion before peers had a
  discussion round; sequential runs get the same cross-review gate
  (system.autoCrossReview).

Checks: npm --prefix desktop test (1574 pass), npm --prefix frontend test
(343 pass), frontend build and build:desktop, desktop pack.
2026-10-14 16:52:25 +08:00
Ryder Sun bb55fad008 docs: reposition product as General Agent workspace
Refresh bilingual README and landing copy around evidence-backed review, local boundaries, and V1.0.5 capabilities. Update public download metadata and links.
2026-09-17 16:13:45 +08:00
Ryder Sun 8995f5f627 docs: point download links to V1.0.5 2026-09-17 15:24:22 +08:00
Ryder Sun f562c68075 test: align Pi capability timeout expectation 2026-09-17 14:57:48 +08:00
Ryder Sun 6aef211188 chore: sync current workspace changes 2026-09-16 10:10:02 +08:00
Ryder Sun 28127b3706 fix: import reported agent outputs and authorize workbuddy writes 2026-09-15 18:38:50 +08:00
Ryder Sun d47bff5433 fix: retain agent readiness after transient auth failures 2026-09-15 18:29:55 +08:00
Ryder Sun 1c9aabbf8e fix: preserve direct context after session recovery 2026-09-15 18:16:55 +08:00
Ryder Sun eb2234b598 fix: preserve Hermes sessions across transport changes 2026-09-15 18:05:44 +08:00
Ryder Sun e7c0223bfa fix: canonicalize conversation session persistence 2026-09-15 17:28:29 +08:00
Ryder Sun 3ce8ee4f49 fix: persist isolated group agent sessions 2026-09-15 17:10:10 +08:00
Ryder Sun 07396fbf29 fix: remove ineffective duplicate attachment cleanup
Keep deletion on the existing IPC attachment cleanup path; remove an unused callback wired to the wrong coordinator and a nonexistent blob-store method. Verified focused conversation deletion test and git diff --check.
2026-09-15 17:02:26 +08:00
Ryder Sun 9198bf6548 fix: isolate chat runs and preserve agent sessions
Keep direct and multi-round conversations on stable Agent sessions, isolate writable direct chats in the scheduler, and remove orphaned local attachments when deleting a conversation.
2026-09-15 16:57:51 +08:00
Ryder Sun 956ddd0dd9 fix: unify window titlebar controls and distinguish conversation settings
Move preferences and shortcuts into a global titlebar 12px from the right edge. Remove hidden duplicate controls and preserve shortcut dismissal. Replace conversation settings gear with sliders.

Verified: frontend tests 343/343, web and desktop builds, Electron position and interaction checks.
2026-09-14 15:21:04 +08:00
Ryder Sun 5465609d54 fix: place workspace preferences in titlebar 2026-09-14 14:01:33 +08:00
Ryder Sun 31f2d43826 fix: place workspace preferences in header 2026-09-11 17:16:20 +08:00
Ryder Sun 2d0d7a9b55 feat: improve agent setup and move sidebar controls 2026-09-11 17:13:44 +08:00
Ryder Sun 6f52081e51 fix: avoid provider calls during Pi discovery 2026-09-11 08:20:38 +08:00
Ryder Sun 2c0857a85b fix: allow slow Pi protocol probes 2026-09-11 07:51:21 +08:00
Ryder Sun 411d0f92d1 fix: make Pi capability probe non-interactive 2026-09-11 06:59:20 +08:00
Ryder Sun 58054c5e3b fix: harden multi-agent session and discovery flows
Prevent group completion from masking peer blockers, avoid replaying delivered group context, validate Pi protocol capability, and restrict child environments while preserving supported runtime configuration. Update the technical iteration plan and related discoverability documentation.
2026-09-11 06:40:37 +08:00
Ryder Sun e2b8b08eef fix(desktop): recover stale agent sessions in group runs
Recover reused ACP sessions that refuse before producing output so generic workspace recovery can establish a fresh session. Keep Pi processes alive through internal tool turns until agent_end, and extract V4 natural discussion decision methods into a dedicated module without changing the runner API or persisted structures. Verified with focused CLI, natural discussion, workspace recovery, syntax, diff, and full desktop test suites.
2026-09-10 19:05:15 +08:00
Ryder Sun 2acabc56cd fix(desktop): replace DMG artwork with generated installer scene
Use the Qwen-generated bitmap with rasterized English install guidance, keep Finder root resources hidden by repointing the background into the signed app bundle, and update packaging coverage. Verified with the full desktop suite, focused packaging tests, codesign, and hdiutil.
2026-09-10 14:34:51 +08:00
Ryder Sun 38c480575d fix(desktop): refine DMG installer layout
Use a compact 640x420 branded background and reposition the Finder icons so the installer opens without excess canvas or scrollbars. Keep key content clear when hidden DMG resources are visible.

Checks: node --test test/packaging/package-security.test.cjs; npm run dist; npx electron-builder --mac dmg; hdiutil verify; codesign --verify --deep --strict; git diff --check
2026-09-10 13:11:58 +08:00
Ryder Sun 717d21373d release: prepare validated V1.0.5 local prerelease
Bump desktop/frontend package metadata to 0.1.5 and document CLI detection, OpenClaw migration, generic group routing and recovery improvements with their evidence boundaries.

Validation: frontend 343/343; desktop 1562/1562; deterministic eval 6 cases/18 results; web and desktop builds; pack/dist; DMG, ZIP, checksum and ad-hoc signature integrity; packaged Codex/OpenClaw group, cancellation and restart acceptance. No push, merge or installed-app replacement.
2026-09-09 21:28:52 +08:00
Ryder Sun 95d5c93165 fix: preserve exact answers and native model region settings
Clarify that completion receipts are stripped transport metadata, preserving user-visible exact output. Include validated dynamic Vertex model region keys in shell collection, cache identity and native Claude environment without changing packaged Electron fuses.

Validation: natural discussion/receipt 56 passed; final readiness/main 91 passed; real Codex and OpenClaw exact-answer runs completed; real Electron shell extraction passed. Full release checks remain pending.
2026-09-09 21:17:50 +08:00
Ryder Sun 6e8f59ce16 fix: leave media execution to native agents
Remove keyword-triggered shared media calls, reply-driven retries and implicit ZGCI fallback from chat orchestration. Preserve native Agent output import and visible responses.

Validation: desktop 1554/1554; five workflow routing regressions; real Electron completed with two Codex calls and zero shared media calls. Document the separate missing completion-receipt sample.
2026-09-09 21:08:11 +08:00
Ryder Sun eb0e1bb2ef fix: resume group tasks after human clarification
Bind clarification gates to completed task decisions and preserve them across restart. Make repeated gate responses idempotent to prevent invocation replay.

Validation: desktop 1549/1549; real Electron pending restart, input response, completion and duplicate-response checks passed with exactly one continuation call.
2026-09-09 20:57:13 +08:00
Ryder Sun ee0bd66708 fix: route group discussion through explicit agent requests
Use persisted taskDecision.nextKinds instead of prose mentions so negated and historical references cannot dispatch peers. Normalize identifier case generically, validate active recipients, and retain owner judgment and legacy ledger compatibility.

Validation: desktop suite 1542/1542 with native Claude integration; final protocol and discussion tests 37/37 after aligning the recipient limit to 32. Real Electron Codex/OpenClaw write, readback, completion and history reload passed. Frontend builds and release packaging remain pending.
2026-09-09 20:31:30 +08:00
Ryder Sun 17c38f6ede fix: refresh native shell settings before agent detection
Bind cached settings to allowed environment values and reload the login shell before serialized Agent refreshes so changed credentials and proxies do not reuse stale settings.

Validation: readiness and main security 87/87; Agent suite 459 passed, 1 optional integration skipped; isolated Electron refresh observed updated synthetic configuration with all 12 Agent entries preserved. Full desktop/build/package checks remain pending for this checkpoint.
2026-09-09 20:13:00 +08:00
Ryder Sun af7e4c5d30 fix: display persisted task outcomes after recovery
Project minimal latest-run outcomes for visible topics through the workspace snapshot. Prefer those outcomes over historical stop messages and Agent call statuses in the topic rail and retained run card, preserving historical errors and private ledger boundaries.

Validate 1536 desktop tests, 343 frontend tests, desktop build, and real Electron reloads of recovered, completed, and blocked histories without additional Agent calls. Keep remaining V1.0.5 work explicit in the development record.
2026-09-09 20:00:55 +08:00
Ryder Sun 9229046e3b fix: persist incremental discussion delivery and path-safe decisions
Track acknowledged source messages per native session and resend uncertain or changed input. Sanitize task decision text before hashing public receipts so absolute deliverable paths cannot strand completed group runs. Keep original Agent decisions for recovery.

Validate 1535 desktop tests including real Claude provider routing, 84 focused discussion checks, real Electron Codex/OpenClaw delivery, and recovery of the prior failed run without additional Agent calls. Synchronize the ACP stream test on received events. Record remaining recovery UI and release work.
2026-09-09 19:49:42 +08:00
Ryder Sun f3794a9732 fix: preserve native cloud auth and selected provider routing
Forward Agent-scoped cloud configuration and explicit empty overrides. Keep configured Provider credentials isolated and use Claude's native key helper to prevent settings from overriding the selected endpoint or exposing secrets in argv. Treat cloud/API-key status as configuration evidence so refresh does not erase recent auth failures.

Validation: desktop suite 1525/1525 before final credential-evidence classification; final readiness/catalog regressions 53/53; native Claude helper and endpoint tests 3/3; isolated Electron Provider save/refresh/chat passed; git diff --check passed. See progress document for platform and cloud-account limits.
2026-09-09 19:11:50 +08:00
Ryder Sun f08fe96529 docs: distinguish commercial validation stages
Separate paid problem validation, software contribution, repeat sales, and resilience to upstream changes so service revenue does not imply software demand.

Validation: git diff --check -- docs/product-iteration-plan.md passed; documentation-only change.
2026-09-09 18:54:47 +08:00
Ryder Sun f61d3beecd fix: bound natural discussion history and clarify external handoffs
Recover long replies from hash-verified, run-bound conclusion artifacts so closing peer requests survive message truncation. Bound shared history with explicit omissions and prevent incomplete excerpts from proving repetition.

Clarify read-only proposal deliverables and require external peer requests to return control to Meldwork. Update the native-session fixture to explicitly continue its requested rounds.

Verified: 1515 desktop tests, focused context and recovery tests, real Electron Codex/OpenClaw write/readback/completion, and git diff --check. Document failed validation samples and remaining release work.
2026-09-09 18:42:47 +08:00
Ryder Sun 9867ad9b4e fix: refresh agent catalog after workspace readiness settles
Prevent stale readiness in the installer catalog and preserve a successful scan when catalog loading fails. Invalidate skills before publishing readiness so reactive loaders retain their new requests.

Verified: 339 frontend tests, desktop frontend build, real Electron unavailable-agent/settings/restart checks, and git diff --check. Record remaining release work in docs.
2026-09-09 18:10:06 +08:00
Ryder Sun 3f0ba50130 fix: keep installed unavailable agents visible with recovery guidance
Separate sidebar preference from invocation readiness. Show localized failure reasons without enabling new runs, retain history access and preserve explicit hiding across recovery and restart.

Validation: frontend 334/334; related desktop 146/146; desktop frontend build; git diff --check. Real Electron verified unavailable Pi visibility, settings navigation, no chat creation and hidden preference after restart.
2026-09-09 18:03:13 +08:00
Ryder Sun ff226e8bdd fix: preserve native CLI proxy and CA configuration
Share the network environment contract across login shell collection, discovery, credential probes and child execution, including isolated OpenClaw. Preserve explicit empty overrides and redact proxy credentials in diagnostics and streaming output.

Validation: 158 focused checks; Agent suite 447/447; final network/stream checks 34/34; local shell-to-curl proxy request; two real Electron scans retained 12 installations; git diff --check. Full release checks remain pending.
2026-09-09 17:57:25 +08:00
Ryder Sun 2750a344cb fix: authorize natural discussion writers and recover interrupted writes
Freeze the capability-selected writer across snapshot, slot, plan and invocation. Require durable one-shot approval before replaying an unknown write and preserve pending approvals across shutdown. Validate capability and permission bindings, completed-result reuse and repeated crashes.

Validation: 8 natural writer tests, 139 existing ledger/schema/gate checks, 159 automatic/manual/receipt checks; git diff --check. Real Electron Codex/OpenClaw automatic task wrote and independently read back exact file bytes. Full release verification remains pending.
2026-09-09 17:49:53 +08:00
Ryder Sun 489f3d615e docs: clarify research source hash provenance
Identify the collector hash input and distinguish retained extracted text from the unarchived HTTP response. Verified 30 cited sources, five document link sets, report structure and git diff --check. No runtime changes.
2026-09-09 17:43:58 +08:00
Ryder Sun 6021ca6a64 fix: persist explicit task judgments and allow single-agent automatic work
Replace natural-discussion silence heuristics with delivery-owner decisions and explicit handoff. Preserve bound decisions through harness, ledger and journal recovery; reject fabricated responsibility receipts. Allow single-agent auto submission and avoid showing task success after a blocking judgment.

Validation: 52 natural/receipt/parser tests, 136 ledger/harness/record tests, 91 automatic workflow tests, 332 frontend tests; desktop build and diff check. Real Electron with native Codex verified completed and blocked tasks plus reopened history. Automatic writable recovery and remaining readiness work are still pending.
2026-09-09 17:26:18 +08:00
Ryder Sun d288ee3a1b docs: define upstream substitution tests for product strategy
Tie defensibility claims to measurable customer value as native agents improve. Retain the existing research snapshot and distinguish proposed experiments from market evidence.

Validation: git diff --check -- docs/product-strategy.md. Documentation-only change; runtime tests not run.
2026-09-09 17:09:59 +08:00
Ryder Sun 2df032a596 fix: align agent readiness with selected provider availability
Keep unreadable Provider credentials distinct from native login failure and prevent late runtime success from clearing that state. Preserve installed peers and display localized diagnostics. Checks: 71 main/catalog tests, 328 frontend tests, desktop build, Electron failure/recovery and diff check.
2026-09-09 16:55:15 +08:00
Ryder Sun d0115c2908 fix: serialize workspace writers and distinguish discussion budget stops
Preserve permission leases through approval recovery, isolate directory aliases, retain valid peer requests and reject incomplete Pi CLIs. Correct historical topic labels and record real native Electron validation. Checks: 111 runtime tests, 23 natural discussion tests, 28 conversation trace tests, desktop frontend build and diff check.
2026-09-09 16:46:54 +08:00
Ryder Sun 67cc32d714 docs: consolidate commercial research decisions for discussion
Clarify evidence limits, durable value hypotheses, scoped pilot offer and investment gates. Validate local document links, source references and git diff --check.
2026-09-09 16:40:59 +08:00
Ryder Sun 006d433c11 fix: tolerate safe OpenClaw migrations and isolate agent probes
Refresh guarded config identity only after equivalent startup migration, including failed startup retries. Retain registered agents and per-agent probe errors. Checks: 89 focused desktop tests; 327 frontend tests; web/desktop builds and pack; real OpenClaw fixture and Electron catalog refresh. Full desktop suite still has two restart timeouts, isolated rerun passes.
2026-09-09 16:22:02 +08:00
Ryder Sun ffdcc12747 docs: clarify defensibility tests and a scoped commercial pilot
Distinguish customer-owned work records from company advantages and compare against native agents with ordinary documents. Define a bounded noncoding review offer before investing in subscriptions. Checks: git diff --check; research JSON and local Markdown links validated.
2026-09-09 16:18:12 +08:00
Ryder Sun 6fe4314360 fix: avoid classifying ordinary diagnostics as lost authentication
Require explicit authentication failure phrases and preserve successful HTTP error explanations. This prevents unrelated file and model errors from requesting login.

Validation: 79 runtime, process-support and CLI protocol tests passed; git diff --check passed. OpenClaw live gateway config migration failure reproduced separately and remains under investigation.
2026-09-09 15:59:44 +08:00
Ryder Sun 011da08524 docs: ground product strategy in commercial research
Archive market sources, user feedback and corrected positioning; separate evidence from pricing and defensibility hypotheses.

Checks: staged diff whitespace, research JSON parsing and core document links. Source body hashes could not be verified and are documented as a limitation. No runtime code changed.
2026-09-09 15:52:42 +08:00
Ryder Sun 21a647161b fix: stabilize local CLI readiness and group session continuity
Distinguish discovery timeouts from missing installations, preserve native and encrypted provider readiness, correct Pi stream handling, and reuse group Agent sessions across rounds and restarts. Support inline peer routing with fairness and repetition guards.

Validation: desktop 1454 tests, frontend 326 tests, web and desktop builds, desktop pack, real Electron Pi/Claude multi-round and restart session checks; git diff --check.
2026-09-08 16:46:23 +08:00
Ryder-MHumble 381b8fefef landing: add Meldwork landing page
- Complete multi-section scrollable site (nav, hero w/ generated bg video,
  agents marquee, definition bento, workflow stepper, modes, capabilities,
  scenario macOS window w/ auto/sequence playback, CardSwap screenshots,
  comparison, boundary, FAQ, CTA, footer)
- React Bits ports in vanilla JS: SplitText, DecryptedText, CountUp,
  ScrollReveal, SpotlightCard, Magnet, ScrollVelocity, Stepper, Accordion,
  CardSwap (single-rAF timeline, clip-envelope), GradientText, StarBorder,
  MagicBento-style definition grid
- Dark theme default; session-only light toggle
- GEO: JSON-LD SoftwareApplication + FAQPage, AI-answer-ready definition,
  query-aligned FAQ, entity-first meta description
2026-09-04 21:22:57 +08:00
Ryder Sun 709a9e9391 release: publish Meldwork V1.0.4
Ship natural group discussion routing and task-scoped native session continuity. Update package versions, bilingual release links, changelog, and verification notes.
2026-09-01 17:47:51 +08:00
Ryder Sun 897b4ee054 docs: expand supported cli table to six columns 2026-09-01 14:11:30 +08:00
Ryder Sun fa56c3575d docs: restore README banner and CLI layout 2026-09-01 12:52:18 +08:00
Ryder Sun fad1cba603 docs: clarify README agent landscape and demos 2026-09-01 11:53:12 +08:00
Ryder Sun 050a457a40 docs: sharpen README category keywords 2026-09-01 11:39:37 +08:00
Ryder Sun 7ab51fd621 docs: align bilingual README terminology 2026-09-01 11:36:46 +08:00
Ryder Sun 91a0aad3f2 docs: refine bilingual README for discoverability 2026-09-01 11:31:58 +08:00
Ryder Sun a68c53bd86 docs: align main license references 2026-09-01 10:21:39 +08:00
Ryder Sun 3a539d8e7c docs: improve AI discoverability and project positioning 2026-09-01 10:20:36 +08:00
Ryder Sun 2faad0c322 test: align Pi invocation expectations 2026-08-31 18:38:17 +08:00
Ryder Sun 33842bc959 Merge branch 'feature/Logic-Refactoring-Conversation-Mode'
# Conflicts:
#	desktop/src/agents/cli/cli-event-profiles.cjs
#	desktop/src/agents/cli/cli-invocations.cjs
#	desktop/src/agents/cli/cli-output-parsers.cjs
#	desktop/src/agents/cli/cli-runtime-event-mappers.cjs
#	desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs
#	desktop/test/agents/cli/cli-event-profiles.test.cjs
#	desktop/test/agents/cli/cli-runtime-event-protocols.test.cjs
#	desktop/test/fixtures/agent-output/manifest.json
#	desktop/test/fixtures/agent-output/pi.jsonl
#	desktop/test/workspace/local-workspace-native-streaming.test.cjs
2026-08-31 18:37:56 +08:00
Ryder Sun 61a71f3439 refactor: make group discussions natural and session-continuous
Run sequential mode strictly in configured CLI order, let auto-beta route later turns from canonical mentions, and persist one native Agent session per task across rounds and restarts. Remove rigid review framing from natural prompts and add recovery, routing, and continuity coverage.
2026-08-31 18:05:14 +08:00
Ryder Sun 5fef68fc4b fix: strip partial Pi protocol echoes 2026-08-28 17:06:09 +08:00
Ryder Sun 21a85b682c fix: reuse ACP sessions across group rounds 2026-08-28 14:02:17 +08:00
Ryder Sun a54e84a208 fix: retain active CLI snapshots across refresh gaps 2026-08-28 11:47:15 +08:00
Ryder Sun e68699e61f fix: preserve active agents across catalog refreshes 2026-08-28 11:44:10 +08:00
Ryder Sun 540a96fa5e fix: strip echoed Pi collaboration prompts 2026-08-28 11:22:16 +08:00
Ryder Sun 5efe559c03 Merge branch 'feature/ui-ux-refinement' into feature/cloud-agents-interconnected
# Conflicts:
#	desktop/src/agents/cloud/cloud-agent-bridge.cjs
#	desktop/src/agents/connectors/agent-connector-local.cjs
#	desktop/test/agents/cloud/cloud-agent-bridge.test.cjs
#	desktop/test/workspace/local-workspace-harness.test.cjs
2026-08-28 10:55:29 +08:00
Ryder Sun 092f38bd37 fix: preserve structured CLI results before nonzero exit 2026-08-25 15:33:20 +08:00
Ryder Sun 782f839d4f feat: sync license and agent runtime updates 2026-08-25 14:54:58 +08:00
Ryder Sun 7752489936 fix cloud agents chat continuity and avatars 2026-08-25 12:50:44 +08:00
463 changed files with 53516 additions and 14372 deletions

No files matched your search

+70
View File
@@ -8,6 +8,73 @@ permissions:
contents: read
jobs:
windows-process-tree:
name: Windows process tree termination
runs-on: windows-latest
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "22.12"
cache: npm
cache-dependency-path: desktop/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Install desktop dependencies
run: npm ci
working-directory: desktop
- name: Test actual process tree termination
run: node --test desktop/test/agents/cli/windows-process-tree.test.cjs
- name: Test native environment key casing
run: node --test --test-name-pattern="Windows native environment canonicalizes" desktop/test/agents/local-agent-readiness.test.cjs
- name: Test selected native configuration directories
run: node --test desktop/test/agents/native-config-directory.test.cjs
- name: Test Skills in selected native directories
run: node --test --test-name-pattern="selected native directories control" desktop/test/skills/local-skill-catalog.test.cjs
- name: Test Connector package storage
run: node --test --test-name-pattern="persists the complete trust lifecycle|reimporting a removed package|uncertain audit append|upgrades one installed Connector" desktop/test/agents/connectors/agent-connector-package-store.test.cjs
- name: Build unpacked Windows application
run: npm run pack
working-directory: desktop
- name: Test packaged process tree termination
shell: pwsh
run: |
$env:ELECTRON_RUN_AS_NODE = '1'
$env:MELDWORK_TEST_PACKAGED_APP = (Resolve-Path 'desktop/dist/win-unpacked/resources/app.asar').Path
& 'desktop/dist/win-unpacked/Meldwork.exe' --test desktop/test/agents/cli/windows-process-tree.test.cjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
private-file-portability:
name: Private file portability (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "22.12"
- name: Test private file persistence
run: node --test desktop/test/security/private-file.test.cjs
frontend:
name: Frontend tests and builds
runs-on: ubuntu-latest
@@ -31,6 +98,9 @@ jobs:
- name: Run unit tests
run: npm test
- name: Check website and release licensing consistency
run: node --test ../meldwork-landing/test/landing.test.cjs
- name: Build web application
run: npm run build
+2 -1
View File
@@ -11,7 +11,8 @@
- Meldwork is a local-first desktop application. Do not introduce a required server, container, or cluster dependency unless the task explicitly calls for it.
- Keep Agent execution in the Electron main process. Renderer access must go through narrow, validated preload APIs.
- Never expose executable paths, tokens, provider credentials, or unrestricted shell execution to renderer code.
- Respect native Agent configuration and keep CLI integration provider-agnostic. Do not add gateway-specific adapters, provider overrides, or bundled patches for external tools such as Magpie.
- Never expose executable paths, tokens, credentials, or unrestricted shell execution to renderer code.
- Store secrets only through operating-system-backed secure storage. Do not add plaintext fallbacks.
- Keep workspace write access opt-in. Conversations and groups stay local; do not add remote participants, cloud channels, or remote conversation storage.
+98 -1
View File
@@ -1,5 +1,102 @@
# Changelog
## 1.0.7 - 2026-10-27 (Meldwork-V1.0.7 prerelease)
- Preserve streamed discussion replies across cancellation and reload; scope ACP notifications to the active session.
- Support Hermes native stream JSON and avoid MiMo interactive plan prompts in headless runs.
- Add Zcode with its official logo before Pi Agent, native default configuration, and explicit missing-key errors.
- Let WorkBuddy use an explicitly selected model from its desktop configuration, without exposing credentials to the renderer.
- Keep Chinese slash-separated text intact while retaining credential and local-path redaction.
- Unify application, artifact, README, and website versions at 1.0.7.
- License Meldwork's original source, documentation, and website under Apache-2.0: commercial use is permitted without separate authorization or license fees. Third-party notices remain applicable. Older entries below describe their historical release terms.
- Native acceptance includes a real WorkBuddy response; six-Agent unlimited discussion cancellation and reload were checked with simulated adapters. Zcode's default provider still needs a valid API key; the observed OpenCode router's invalid `tool_calls` finish reason remains an upstream issue.
- Apple silicon DMG and ZIP remain ad-hoc signed and unnotarized.
## 0.1.6 - 2026-10-17 (Meldwork-V1.0.6 prerelease)
### Agent Reliability
- Tier authentication failures instead of treating any authentication-looking diagnostic as a sign-in problem, and retry transient provider errors.
- Keep long-lived ACP Agents (Hermes, Kimi, MiMo, OpenCode) usable across stops, restarts, and hangs, including when a group alternates between read-only and write turns.
- Stop the watchdog from killing Agents that are still thinking or building, and keep an ACP turn alive through traffic this client does not recognise.
- Keep an Agent available through an inconclusive readiness probe and preserve its shell environment.
- Let MiMo and OpenCode turns that call tools finish instead of returning a partial answer, and report a CLI's own error instead of EPIPE.
- Launch WorkBuddy's app-bundled CLI again (it now ships only the lite bundle) and stop skipping every WorkBuddy permission check in write mode.
- Refuse unsafe ACP permission requests during read-only turns.
- Give direct chats the current request and the delivery contract again after a native session is rebuilt.
- Compare installed Agent CLI versions as releases, so a newer installed CLI is not offered a downgrade.
- Write workspace state compactly and durably, and record unhandled main-process faults in the diagnostics log.
- Internal: split the auto-runner and the Agent invocation into modules grouped by purpose.
### Group Conversations
- Show group replies in the order Agents were dispatched.
- Keep natural discussions going instead of ending on one slip, ask Agents to engage with each other, and let peers see workspace file paths.
- Keep group members after an Agent failure, so one proxy 403 or an expired sign-in no longer removes an Agent from later turns, and recover groups that were emptied.
- A member that is not ready no longer blocks a message to the whole group; skipped members are named.
- Deliver attachments and knowledge bases to group Agents, and send Hermes image attachments over ACP.
- Conversations no longer wait for each other, even in the same folder; writers inside one conversation still take turns.
- Import only files an Agent wrote, including paths with spaces.
- Explain run failures in Chinese and English instead of showing raw codes.
### Interface
- Start without waiting for Agent detection: the sidebar, home page, and composer show that detection is running.
- Mark conversations that wait for your decision in the sidebar and list them on the home page; mark group runs that finished while you were in another conversation.
- Add a General settings tab with language and a System, Light, or Dark appearance; System follows the operating system until you choose.
- Explain on each Agent card why an installed Agent is unavailable: its installed version, the verified range, and the failed check.
- Use a shared type scale and semantic status colors, keep secondary text within contrast guidelines, and keep long notices on screen long enough to read.
- Keep unsent text per conversation, add Skip and arrow-key navigation to first-run onboarding, ask before deleting a cloud server connection, and show Gemini CLI's own logo.
### Verification And Distribution
- Frontend 357/357, desktop 1603/1603, and deterministic evaluation 6 cases / 18 results passed on a clean checkout.
- DMG and ZIP are ad-hoc signed, without Apple Developer ID signing or notarization. No installed user application is replaced automatically.
- Live coverage is limited to observed CLI versions and configurations; this release did not repeat the live Agent acceptance run.
## 0.1.5 - 2026-09-09 (Meldwork-V1.0.5 prerelease)
### Local Agent Reliability
- Accept validated OpenClaw gateway configuration migrations without disabling path, permission, or credential isolation checks.
- Keep installed Agents visible when credential or capability checks fail; isolate failed probes and refresh the catalog after current readiness settles.
- Refresh native shell settings before manual detection and preserve supported proxy, CA, cloud Provider, and model-region settings without silently replacing a selected Provider.
### Group Conversations
- Route peer requests through explicit Agent decisions instead of interpreting prose mentions or media keywords as commands.
- Persist AI task completion separately from individual invocation success and human adoption; support single-Agent automatic work and exact visible answers.
- Resume human clarification gates after restart, reject conflicting responses, and prevent repeated responses from replaying calls.
- Serialize workspace writers, recover interrupted writes through bound approvals, retain partial results, and send acknowledged session context incrementally.
- Preserve completed and stopped task states after restart and show healthy contributions alongside failed participants.
### Verification And Distribution
- Frontend 343/343, desktop 1562/1562, and deterministic evaluation 6 cases / 18 results passed.
- Web and desktop builds, packaging, real packaged Codex/OpenClaw group execution, cancellation, and restart acceptance passed on Apple silicon macOS.
- DMG and ZIP are ad-hoc signed, without Apple Developer ID signing or notarization. No installed user application is replaced automatically.
- Live coverage is limited to observed CLI versions and configurations; cloud credential forwarding is not cloud-account certification. Full evidence: `docs/releases/Meldwork-V1.0.5.md`.
## 0.1.4 - 2026-09-01 (Meldwork-V1.0.4 prerelease)
### Natural Group Discussions
- Sequential discussion mode now runs one Agent at a time in the configured CLI order and records every round visibly.
- Auto Discussion starts with concurrent proposals, then lets Agent responses route the next turn through exact final-line `@Agent` mentions. One mention runs one Agent; multiple mentions run concurrently.
- Natural discussion prompts allow Agents to exchange views before work and review instead of forcing every turn into a review-only role.
### Session And Recovery
- Reuses one native Agent session per Agent task across discussion rounds and restarts.
- Persists round messages and checkpoints before and after Agent execution so completed turns are recovered without duplicate reruns.
- Rejects stale operation and snapshot bindings before late results can be committed.
### Verification And Distribution
- Focused natural discussion coverage: 17/17 tests passed.
- Desktop syntax checks and `git diff --check` passed.
- The Apple silicon DMG and ZIP are ad-hoc signed and not notarized. Live CLI behavior still depends on each installed Agent's version, authentication, Provider, and capabilities.
## 0.1.3 - 2026-08-18 (Meldwork-V1.0.3 prerelease)
### Concurrent Conversations
@@ -66,7 +163,7 @@
- Sets the desktop package version to `0.1.2` with permanent Bundle ID `com.rydersun.meldwork`.
- Produces Apple silicon DMG and ZIP prerelease artifacts using ad-hoc signing. This is not Apple Developer ID signing or notarization and may require Open Anyway on first launch.
- Keeps `dist:public` as the fail-closed future formal-distribution path requiring a Developer ID identity, Hardened Runtime, complete Apple notarization credentials, and post-sign validation.
- Uses the Meldwork Non-Commercial Source License; commercial use requires prior written permission.
- This historical release used the Meldwork Non-Commercial Source License and required prior written permission for commercial use. Current releases use Apache-2.0 as stated above.
### Verification
+51
View File
@@ -0,0 +1,51 @@
# Code of Conduct
Meldwork is a workspace for people and Agents to build together. We want issues,
pull requests, discussions, and community spaces to stay useful, direct, and
respectful.
## Expected Behavior
- Be clear, constructive, and respectful when giving feedback.
- Assume good intent while keeping discussions focused on facts, tradeoffs, and outcomes.
- Respect different experience levels, backgrounds, languages, and working styles.
- Keep disagreements technical, specific, and connected to the project.
- Help maintainers keep issues and pull requests focused.
- Protect private prompts, credentials, customer information, and other sensitive data.
## Unacceptable Behavior
The following behavior is not acceptable in Meldwork project spaces:
- Personal attacks, insults, harassment, or sustained disruption.
- Demeaning comments about identity, background, language, ability, or experience.
- Threats, intimidation, stalking, or unwanted contact.
- Publishing another person's private information without consent.
- Spam, trolling, discrimination, or repeated off-topic posting.
- Submitting malicious code, secrets, private data, or intentionally misleading reports.
- Any behavior maintainers reasonably judge to make the project unsafe or unproductive.
## Maintainer Responsibilities
Maintainers may edit or remove comments, close issues, limit participation, or
block participants when needed to protect the project community. Enforcement
should be proportional, documented when practical, and focused on keeping
collaboration healthy.
## Reporting
Report conduct concerns privately through the repository's GitHub contact or
security reporting channel. Do not open a public issue for sensitive reports.
Please include:
- a short description of what happened;
- links, screenshots, or message references when available;
- whether you want follow-up, confidentiality, or no direct reply.
Reports will be reviewed by project maintainers. We will handle reports with
care and limit access to people who need the information to respond.
This Code of Conduct applies wherever the project or its maintainers represent
the Meldwork community. It is adapted from the Contributor Covenant and the
community standards used by comparable open-source projects.
+18 -12
View File
@@ -1,17 +1,23 @@
# Commercial Use And Licensing
# Commercial use / 商业使用
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). The public repository can be inspected, evaluated, run, and modified for non-commercial purposes, but it is not licensed for commercial use.
Meldwork's original source code, documentation, README files, and official website
are licensed under [Apache License 2.0](LICENSE). Commercial use, private use,
modification, redistribution, and inclusion in paid products or services are
permitted under its terms. No separate commercial authorization or license fee
is required.
Any commercial use requires prior written permission from the copyright holder. This includes monetizing Meldwork, reselling or hosting it, embedding it in a proprietary product, using it to provide paid services, or using it as part of a for-profit organization's business operations.
When redistributing, preserve the license, applicable copyright and attribution
notices, and NOTICE information, and identify modified files as required by the
license. Apache-2.0 does not grant trademark rights (Section 6).
The copyright holder may offer a separate commercial license for organizations that need permission for:
Third-party dependencies, fonts, Agent names, and logos retain their own licenses
and terms; their inclusion does not imply endorsement. In particular, the
website's bundled Fusion Pixel font retains its OFL license and notices.
- proprietary redistribution or resale of Meldwork or a modified version;
- embedding Meldwork code in a closed-source product;
- offering a hosted, managed, or network-accessible version;
- internal business operations at a for-profit organization;
- separately licensed commercial features, enterprise integrations, support, or managed deployment.
Meldwork 原创源代码、文档、README 和官网均采用 Apache-2.0。依照许可证条款,
允许商业使用、私人使用、修改、再分发,以及用于收费产品或服务;无需另行取得商业
授权或支付许可费。再分发时应保留许可证、适用的版权与归属声明及 NOTICE 信息,并
按许可证要求标明修改。Apache-2.0 第 6 条不授予商标权。第三方依赖、字体、Agent
名称和标志仍遵循各自条款;官网附带的 Fusion Pixel 字体保留 OFL 许可证与声明。
A commercial license does not change the licenses of third-party dependencies, Agent products, names, models, or logos. The repository license also does not grant trademark rights to the Meldwork name, logo, screenshots, or other brand assets.
This page is a practical summary and does not replace the license text or legal advice. When a planned use may be commercial, contact the repository owner through GitHub before using, distributing, hosting, reselling, or integrating the product.
This summary does not replace LICENSE. 本说明不替代 LICENSE 正文。
+7 -6
View File
@@ -1,18 +1,19 @@
# Contributing
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). Contributions are welcome when they improve the local-first desktop product, documentation, tests, or security posture.
Meldwork is licensed under the [Apache License 2.0](LICENSE). Contributions are welcome when they improve the local-first desktop product, documentation, tests, or security posture.
Unless a separate written agreement applies, a contribution accepted into this repository is available under the repository license. The project may also offer separately licensed commercial editions. Before external code is incorporated into a separately licensed commercial build, the maintainer may require a reviewed contributor-license agreement that grants the necessary relicensing rights. Opening a pull request by itself does not transfer copyright or silently grant those additional rights.
Unless a separate written agreement applies, a contribution accepted into this repository is available under the repository license. Opening a pull request does not transfer copyright beyond the rights granted by the Apache License 2.0.
## Ground Rules
- Keep changes small and tied to a clear product or engineering need.
- Do not submit secrets, tokens, private logs, private prompts, or user data.
- Do not add remote services, telemetry, credential collection, or server dependencies without explicit project approval.
- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, Provider credential, or executable-path access.
- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, credential, or executable-path access.
- Include focused tests for behavior, security boundary, storage, or IPC changes.
- Confirm that all submitted code and assets are yours to license and do not introduce incompatible license obligations.
- Confirm that all submitted code and assets are yours to contribute and do not introduce incompatible license obligations.
## Commercial Editions
## Community Standards
Commercial use of Meldwork requires prior written permission. Commercial editions, proprietary integrations, support, and other paid offerings may use separate terms; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
Please follow the [Code of Conduct](CODE_OF_CONDUCT.md) in issues, pull
requests, discussions, and other project spaces.
+177 -58
View File
@@ -1,82 +1,201 @@
Meldwork Non-Commercial Source License 1.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (c) 2026 Ryder-MHumble.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
This license applies to Meldwork and the source code, documentation, assets, and
other materials in this repository, except where a file or third-party notice
states a different license.
1. Definitions.
Meldwork is source-available, not open source in the OSI sense. Commercial use is
not permitted without prior written permission from the copyright holder.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
1. Allowed non-commercial use
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
You may download, inspect, run, copy, and modify Meldwork for personal learning,
research, evaluation, security review, and other non-commercial purposes.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
You may share unmodified or modified copies for non-commercial purposes if you:
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
- keep this license and all copyright, attribution, and trademark notices;
- clearly mark your changes when you distribute a modified copy;
- do not remove or obscure third-party notices; and
- do not imply that the copyright holder endorses your copy or service.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
2. Commercial use requires permission
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
You may not use Meldwork for a commercial purpose without a separate written
license or other written permission from the copyright holder.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
Commercial purpose includes, without limitation:
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
- selling, renting, sublicensing, or otherwise monetizing Meldwork or a modified
version;
- offering Meldwork or a modified version as a hosted, managed, consulting,
outsourcing, integration, or support service;
- embedding Meldwork code, UI, workflow, or packaged binaries in a proprietary,
paid, or revenue-generating product;
- using Meldwork to provide paid deliverables, paid automation, paid Agent
operations, or other business services to a customer; and
- use by or for a for-profit company, commercial product team, or revenue-driven
organization as part of its business operations.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
If you want to use Meldwork commercially, contact the repository owner and obtain
written permission before using, distributing, hosting, reselling, or integrating
the product.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
3. No trademark license
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
This license does not grant rights to use the Meldwork name, logo, product
identity, screenshots, or other brand assets except as needed to describe an
unaltered copy of the software in a truthful, non-confusing way.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Third-party materials
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
Third-party software, Agent products, models, names, logos, dependencies, and
other materials remain subject to their own licenses and terms. This license does
not give you rights that the copyright holder does not own.
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
5. Contributions
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
Unless a separate written agreement says otherwise, contributions submitted to
this repository may be used under this license. The copyright holder may require
a separate contributor agreement before accepting contributions intended for a
commercially licensed edition.
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
6. Termination
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
If you violate this license, your rights under it terminate automatically. The
copyright holder may reinstate rights in writing after the violation is cured.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
7. No warranty
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
Meldwork is provided "as is", without warranties or conditions of any kind,
including implied warranties of merchantability, fitness for a particular
purpose, title, and non-infringement. You are responsible for evaluating whether
the software is suitable and lawful for your use.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
8. Limitation of liability
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
To the maximum extent permitted by law, the copyright holder and contributors
will not be liable for any damages arising from or related to the software or
this license, including direct, indirect, incidental, consequential, special,
exemplary, punitive, or business-interruption damages.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5 -3
View File
@@ -1,9 +1,7 @@
Meldwork
Copyright 2026 Ryder-MHumble and Meldwork contributors.
Meldwork is licensed under the Meldwork Non-Commercial Source License. See
LICENSE. Commercial use requires separate written permission from the copyright
holder.
Meldwork is licensed under the Apache License 2.0. See LICENSE.
Third-party Agent names, product names, and logos remain the property of their
respective owners. They are used only to identify compatible integrations and
@@ -12,3 +10,7 @@ do not imply sponsorship or endorsement.
The OpenCodeReview logo is sourced from Alibaba's OpenCodeReview project:
https://github.com/alibaba/open-code-review/blob/main/imgs/logo-core.svg
The upstream project is licensed under Apache License 2.0.
The Zcode logo is sourced unchanged from the official Zcode website:
https://zcode.z.ai/favicon-192x192.png?v=20260707-transparent
The logo identifies the integration; its trademark rights remain with its owner.
+106 -78
View File
@@ -1,126 +1,154 @@
<!-- <p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/logos/meldwork-wordmark-v3-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="frontend/public/logos/meldwork-wordmark-v3.svg">
<img src="frontend/public/logos/meldwork-wordmark-v3.svg" alt="Meldwork" width="360">
</picture>
</p> -->
<p align="center">
<img src="frontend/public/logos/meldwork-readme-banner-en.png" alt="Meldwork README banner" width="100%">
</p>
<p align="center">
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
</p>
# Meldwork — Let Agents collaborate to move tasks forward
# Meldwork builds the organization layer for AI agents.
**Set the goal. Let Agents work together. Meldwork brings your existing Agents into one working group to share context, discuss approaches, carry out work, and check each other's results — with less relaying, prompting, and stitching outputs together.**
**Most tools help humans run more agents. Meldwork is built for a different job: make multi-agent work legible, accountable, and reusable.**
Meldwork is a local-first multi-Agent collaboration workspace for the Agent tools you already use on your Mac. Once you choose the participants, goal, context, and permissions, Agents in Auto Discussion can build on each other's work, request contributions or reviews, and advance the task within the authorized scope. Direct conversations and independent concurrent responses also support research, analysis, writing, planning, review, and implementation.
The current preview is a local work cell for supported Agent CLIs. It keeps participants explicit, context scoped, collaboration bounded, run state inspectable, and human review in the loop. Selected Agents can now respond concurrently or move through independent proposals, peer negotiation, responsibility-based work, single-writer integration, and independent verification around one goal.
You do not need to direct every round, but you still set the goal, grant permissions, and make key decisions. Missing information, permission requests, or tradeoffs may require your input. Workspace writes are opt-in; collaboration history, run state, and deliverables stay local so you can revisit the work and continue it.
<p align="center">
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg"><strong>Download Meldwork V1.0.3 for Apple silicon macOS</strong></a>
· <a href="architecture.md">Architecture</a>
· <a href="LICENSE">License</a>
<a href="https://github.com/Ryder-Sun/Meldwork/releases"><img src="https://img.shields.io/badge/download-macOS-1677FF?style=plastic&amp;logo=apple&amp;logoColor=white" alt="Download for macOS" height="26"></a>
&nbsp; <a href="https://youtu.be/ttyJxo1s9ns"><img src="https://img.shields.io/badge/demo-YouTube-EC4899?style=plastic&amp;logo=youtube&amp;logoColor=white" alt="Watch demo on YouTube" height="26"></a>
&nbsp; <a href="architecture.md"><img src="https://img.shields.io/badge/architecture-docs-8B5CF6?style=plastic" alt="Architecture documentation" height="26"></a>
&nbsp; <a href="LICENSE"><img src="https://img.shields.io/badge/license-view-14B8A6?style=plastic" alt="View license" height="26"></a>
&nbsp; <a href="README.zh-CN.md"><img src="https://img.shields.io/badge/语言-简体中文-1677FF?style=plastic" alt="切换为简体中文" height="26"></a>
</p>
## The missing layer in the Agent workforce
## What Meldwork is for
Agent supply is growing quickly. The organization around it is not.
[![Watch the Meldwork product video on YouTube](assets/media/meldwork-handoff-preview.jpg)](https://youtu.be/ttyJxo1s9ns)
Today, people still choose every Agent, move every piece of context, explain every role, reconcile conflicting answers, and carry the final accountability. Parallel managers improve throughput. Agent frameworks let developers program workflows. Single-Agent products improve one worker.
[Watch the product video on YouTube](https://youtu.be/ttyJxo1s9ns)
<table>
<tr>
<td width="33%" align="center" valign="top">
<a href="assets/meldwork-agent-discovery.png"><img src="assets/readme/agent-discovery.svg" alt="Agent discovery" width="240" height="150"></a>
<p><sub>Agent discovery</sub></p>
</td>
<td width="33%" align="center" valign="top">
<a href="assets/meldwork-auto-discussion.png"><img src="assets/readme/auto-discussion.svg" alt="Auto discussion &amp; run details" width="240" height="150"></a>
<p><sub>Auto discussion &amp; run details</sub></p>
</td>
<td width="33%" align="center" valign="top">
<a href="assets/meldwork-direct-multimodal.png"><img src="assets/readme/direct-multimodal.svg" alt="Direct multimodal conversation" width="240" height="150"></a>
<p><sub>Direct multimodal conversation</sub></p>
</td>
</tr>
</table>
Meldwork is focused on the work between them:
Meldwork is useful when several Agents need to advance the same task and you want to spend less time passing context and coordinating next steps. Typical work includes:
- one shared goal and acceptance boundary;
- independent viewpoints instead of hidden routing;
- explicit authority, responsibility, handoffs, and stopping conditions;
- artifacts and evidence that survive Agent changes;
- a human-controlled adoption decision.
- research and information synthesis across local Agent tools and selected knowledge sources;
- product, market, operational, and technical analysis;
- writing, planning, review, and structured document work;
- multimodal work with local documents, images, audio, video, PDFs, and code or configuration files;
- implementation in an authorized workspace, with other Agents checking the deliverables and requesting revisions.
## What Meldwork is building
## How a task moves through Meldwork
The collaboration mechanism is not a hidden preset A → B → C assignment. The user selects the participants; those Agents expose the solution space, negotiate responsibilities, and execute inside Harness-enforced context, permission, budget, recovery, and evidence boundaries.
1. **Select** the Agents that will work on the task together.
2. **Scope** the goal, working directory, context, attachments, Skills, knowledge sources, and permissions.
3. **Collaborate** in Auto Discussion: let Agents discuss approaches, carry out authorized work, and check results. Direct conversations and Concurrent Responses are also available.
4. **Decide** when the work needs information, permission, or a tradeoff resolved.
5. **Check the result** and request changes when more work is needed.
The selected-Agent proposal, challenge, responsibility, work, integration, and verification loop is available today. Automatic participant selection from a broader roster, remote Agents, enterprise governance, and outcome-driven reputation remain future direction.
## Collaboration modes
### Demo
| Local Agent discovery | Direct multimodal work | Concurrent multi-Agent work |
| Mode | What happens | Best for |
| --- | --- | --- |
| [![Meldwork detecting supported local Agent CLIs](assets/meldwork-agent-discovery.png)](assets/meldwork-agent-discovery.png) | [![A direct Agent conversation returning local files and media](assets/meldwork-direct-multimodal.png)](assets/meldwork-direct-multimodal.png) | [![A group conversation with user-selected Agent replies](assets/meldwork-group-collaboration.png)](assets/meldwork-group-collaboration.png) |
| See which supported Agents are ready. | Keep prompts, files, permissions, and compatible sessions together. | Run concurrent replies or an Agent-negotiated collaboration with the participants you selected. |
| **Direct** | One selected Agent keeps its conversation and native session when supported. | Focused research, writing, analysis, or execution. |
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent responses in stable order. | Comparing perspectives before choosing a path. |
| **Auto Discussion V4** | Agents propose independently, discuss, request peer contributions, carry out authorized work, and check deliverables. A delivery owner judges progress and requests user decisions when needed. Native sessions continue when supported. | Multi-step collaboration with less manual coordination. |
## What makes Meldwork different
Participants are selected by the user. Automatic selection from a larger roster is outside the current preview.
### Harness design
## Revisit the process and continue the work
The Harness is the control plane that keeps conversations, context, adapters, streams, and durable state aligned in one workspace. It is what makes a run continuous across sessions, timeouts, recovery, and Agent changes.
Meldwork keeps task context, Agent replies, run state, and deliverables locally so you can see how work progressed, where it stalled, and what needs attention next. The Run Ledger preserves phase, participant, attempt, artifact, recovery, and Human Gate state. Diagnostic tool output and sensitive runtime details stay behind the Electron main-process boundary.
<p align="center">
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
</p>
These records help you inspect collaboration; they do not establish that every claim has been fact-checked. Peer review does not guarantee correctness, and important deliverables still need acceptance checks appropriate to their use.
This is a comparison of working models, not a feature-scorecard claiming Meldwork should replace every tool.
## General Agent catalog
| Approach | Examples | Best at | Main tradeoff | Meldwork's choice |
| --- | --- | --- | --- | --- |
| One Agent CLI or AI IDE | Claude Code, Codex, Cursor, Windsurf | Deep work inside one Agent or product ecosystem | Moving to another Agent still requires a manual handoff | Preserve work across heterogeneous Agents |
| Parallel coding-Agent managers | Superset, Vibe Kanban, Nimbalyst, Conductor | Running multiple coding tasks and isolated workspaces in parallel | Primarily optimized for throughput and code delivery | Optimize proposal quality, responsibility, evidence, and adoption |
| Cloud Agent control planes | GitHub Agent HQ, Warp Oz, Devin, Factory, OpenHands Cloud | Dispatching and observing remote jobs, sandboxes, and Agent fleets | Work is organized around cloud or platform infrastructure | Start local-first and bring your existing Agents |
| Programmable Agent frameworks | LangGraph, CrewAI, AutoGen, Agents SDK, ADK | Building custom graphs, roles, routing, and automation | Developers must design and maintain the orchestration | Deliver an organization and acceptance experience to users |
| Multiple terminals and scripts | tmux, shell scripts, manual copy and paste | Maximum access to native Agent capabilities | The user becomes the router, context bus, and final judge | Make the work, boundaries, and decisions durable |
| **Meldwork** | Supported local Agent CLIs; remote supply is direction | Concurrent independent responses and selected-Agent proposal, negotiation, responsibility, evidence, and verification | Local single-user preview today; participants are still selected by the user | Build the organization layer for the Agent workforce |
Meldwork detects and invokes compatible locally installed Agent commands. The catalog currently includes:
If one Agent and one work surface are enough, use the native tool. Use Meldwork when handoff, independent judgment, authority, evidence, and acceptance become part of the problem.
<table>
<tr>
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
</tr>
<tr>
<td align="center" valign="top"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/deepseek.png" alt="DeepSeek Harness icon" width="32" height="32"><br><strong>DeepSeek Harness</strong></td>
</tr>
</table>
## Install
Approved Agent Connectors and custom executable Agents use the desktop connector paths. The [desktop guide](desktop/README.md) contains the current adapter, installation, provider, and capability matrix.
The [Agent Connector SDK guide](desktop/AGENT_CONNECTOR_SDK.md) explains supported packages, approval, upgrades, and conformance checks.
The current release-validated target is Apple silicon macOS.
DeepSeek Harness (`dsh`) supports direct chats and group replies through ACP, with separate resumable sessions and workspace permissions. Its default route is ZGCI `smart-router-v2`; provide `ZGCI_API_KEY` through native credentials or the launch environment. In Settings → Agents → DeepSeek Harness, you can select an existing native `DSH_HOME` directory; detection, execution, and local Skills use that selection. The native `DSH_AGENTS_HOME` environment variable also controls the shared Skills directory. The icon comes from the official macOS application.
An explicit ACP provider or model in the native profile or DSH home patch takes precedence over this default; credential environment variables declared there are passed to DSH.
1. Open the [Meldwork V1.0.3 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3).
2. Download Meldwork-0.1.3-arm64.dmg.
3. Drag Meldwork into Applications and try to open it once.
4. This prerelease is ad-hoc signed, not signed with an Apple Developer ID, and not Apple-notarized. If macOS blocks it, open **System Settings → Privacy & Security**, find the Meldwork warning, choose **Open Anyway**, and confirm.
5. Connect a supported Agent CLI already installed on the computer, or configure an independent Provider profile where supported.
WorkBuddy also supports selecting its existing native configuration directory in Settings → Agents. Its model list, execution environment, and local and plugin Skills use the selected directory.
Use only artifacts downloaded from the official Release.
OpenClaw also supports selecting its native state directory in Settings → Agents. Meldwork reads the selected Agent's model configuration from that directory and runs it in its guarded local runtime.
<details>
<summary><strong>Build from source</strong></summary>
When switching an Agent's native configuration directory, Meldwork keeps separate local conversation sessions for each selected directory and restores them when switching back. Configuration changes are blocked while a run is active or awaiting recovery.
Requires Node.js 22.12 or newer and npm.
For Zcode, choose an existing `.zcode/cli` directory containing `config.json`. Zcode runs with that directory's parent user home, which also affects its home-relative session and other file paths; project configuration may still override user configuration.
~~~bash
For Qwen Code, choose an existing `.qwen` directory containing settings or credentials. Qwen uses its parent user home for native sessions, Skills and other home-relative files; project and system settings retain their native precedence.
For OpenCodeReview, choose an existing `.opencodereview` directory containing `config.json`. OCR runs with that directory's parent user home, including its native session and other home-relative paths.
Hermes uses its native `config.yaml` and Provider settings. When its model, fallback model, or provider entries declare credential variables with `key_env` or `api_key_env`, Meldwork reads those variables from the launch environment or login shell for Hermes; unrelated variables and unsafe process controls are excluded. Hermes may prepare its own dependencies when started with a new native data directory.
## Context and local boundaries
Meldwork runs as a local Electron application. Conversations, group configuration, run records, and app-owned attachments stay in the local user data directory. The renderer receives only validated snapshots through a narrow preload API; executable paths, credentials, native session references, Skill paths, and unrestricted shell access remain in the main process.
Selected Agents may send prompts, attachments, or Skills to the model provider configured in their own CLI. Knowledge access is explicit and bounded: the current implementation supports local Obsidian retrieval and CLI-owned Feishu or DingTalk access modes. Local-first describes where Meldwork stores and coordinates work; it does not promise that an Agent's model provider is offline.
## Run from source
Prerequisites: Node.js `22.12+` and npm.
```bash
npm --prefix frontend ci
npm --prefix desktop ci
npm --prefix desktop run dev
~~~
```
Run the repository checks before distributing a build:
Before distributing a build:
~~~bash
```bash
npm --prefix frontend test
npm --prefix frontend run build
npm --prefix frontend run build:desktop
npm --prefix desktop test
~~~
```
</details>
## Documentation
## Trust boundaries
- Conversations and Meldwork orchestration state stay local.
- Model requests still follow the Agent and Provider you choose; local-first does not mean every model runs offline.
- Credentials use operating-system-backed secure storage where supported.
- Workspace writes are explicit and opt-in.
- Raw chain-of-thought, executable paths, secrets, and unrestricted command output are not exposed to the renderer.
- Cloud, Channel, automatic participant selection, enterprise governance, and an Outcome Network are not current release promises.
Read the [architecture](architecture.md) and [Agent Connector contract](docs/agent-connector-sdk.md) for the public technical boundary.
- [Architecture and product boundary](architecture.md)
- [Desktop setup and Agent matrix](desktop/README.md)
- [Contributing](CONTRIBUTING.md)
- [Security policy](SECURITY.md)
## License
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). You may inspect, run, modify, and share it for non-commercial purposes. Commercial use requires prior written permission; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
Meldwork is open source under the [Apache License 2.0](LICENSE). Commercial use, private use, modification, and redistribution are permitted under the license terms. No separate commercial authorization or license fee is required. Apache-2.0 does not grant trademark rights; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
+103 -79
View File
@@ -1,128 +1,152 @@
<!-- <p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/logos/meldwork-wordmark-v3-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="frontend/public/logos/meldwork-wordmark-v3.svg">
<img src="frontend/public/logos/meldwork-wordmark-v3.svg" alt="Meldwork" width="360">
</picture>
</p> -->
<p align="center">
<img src="frontend/public/logos/meldwork-readme-banner-cn.png" alt="Meldwork README banner" width="100%">
</p>
<p align="center">
<a href="README.md">English</a> · <strong>简体中文</strong>
</p>
# Meldwork:让 Agent 自主协作推进任务
# Meldwork 为 AI Agent 构建组织层
**提出目标,让 Agent 们协作完成。Meldwork 把你已有的 Agent 组织成一个工作小组,共享上下文、讨论方案、协作执行并相互检查,减少你在它们之间转述、催办和整合结果的工作。**
**多数工具是在帮人类管更多 Agent;Meldwork 解决的是另一件事:让多 Agent 协作变得可见、可追责、可复用。**
Meldwork 是一个本地优先的多 Agent 协作工作空间,用来连接你已经在电脑上使用的 Agent 工具。你选定参与者、目标、上下文和权限后,自动讨论中的 Agent 可以接续彼此的工作,请求同伴补充或检查,并在授权范围内推进任务。它也支持单个 Agent 的直接会话和多个 Agent 的独立并发回复,覆盖研究、分析、写作、规划、评审和实施。
当前预览版是一个面向已支持本地 Agent CLI 的本地工作单元。它把参与者、上下文、协作边界、运行状态和人类复核放在同一个工作空间里。已选 Agent 现在可以并发回复,也可以围绕同一目标先独立提案、再协商职责、分工执行、单写者整合并由其他 Agent 独立复核。
你无需安排每一轮发言,但仍负责目标、权限和关键决策。遇到信息缺失、权限请求或需要取舍的问题时,任务可能等待你处理。工作区写入需要明确授权;协作过程、运行状态和产物保存在本地,方便回看和继续工作。
<p align="center">
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg"><strong>下载 Meldwork V1.0.3 Apple 芯片 macOS 版</strong></a>
· <a href="architecture.md">架构</a>
· <a href="LICENSE">许可证</a>
<a href="https://github.com/Ryder-Sun/Meldwork/releases"><img src="https://img.shields.io/badge/下载-macOS-1677FF?style=plastic&amp;logo=apple&amp;logoColor=white" alt="下载 macOS 安装包" height="26"></a>
&nbsp; <a href="https://youtu.be/ttyJxo1s9ns"><img src="https://img.shields.io/badge/演示-YouTube-EC4899?style=plastic&amp;logo=youtube&amp;logoColor=white" alt="在 YouTube 观看演示" height="26"></a>
&nbsp; <a href="architecture.md"><img src="https://img.shields.io/badge/架构-文档-8B5CF6?style=plastic" alt="项目架构文档" height="26"></a>
&nbsp; <a href="LICENSE"><img src="https://img.shields.io/badge/许可-查看-14B8A6?style=plastic" alt="查看许可证" height="26"></a>
&nbsp; <a href="README.md"><img src="https://img.shields.io/badge/language-English-1677FF?style=plastic" alt="Switch to English" height="26"></a>
</p>
## Agent workforce 缺少的那一层
## Meldwork 适合什么工作
Agent 的供给正在快速增长,但围绕它们工作的组织能力并没有跟上。
[![在 YouTube 观看 Meldwork 产品宣传视频](assets/media/meldwork-handoff-preview.jpg)](https://youtu.be/ttyJxo1s9ns)
今天,人类仍然需要自己选 Agent、搬运上下文、解释角色、处理冲突答案,并承担最终责任。并行管理器优化吞吐量,多 Agent 框架让开发者编写 Workflow,单 Agent 产品优化一个 Worker。
[在 YouTube 观看产品宣传视频](https://youtu.be/ttyJxo1s9ns)
<table>
<tr>
<td width="33%" align="center" valign="top">
<a href="assets/meldwork-agent-discovery.png"><img src="assets/readme/agent-discovery.svg" alt="Agent 发现" width="240" height="150"></a>
<p><sub>Agent 发现</sub></p>
</td>
<td width="33%" align="center" valign="top">
<a href="assets/meldwork-auto-discussion.png"><img src="assets/readme/auto-discussion.svg" alt="自动讨论与运行详情" width="240" height="150"></a>
<p><sub>自动讨论与运行详情</sub></p>
</td>
<td width="33%" align="center" valign="top">
<a href="assets/meldwork-direct-multimodal.png"><img src="assets/readme/direct-multimodal.svg" alt="多模态直接会话" width="240" height="150"></a>
<p><sub>多模态直接会话</sub></p>
</td>
</tr>
</table>
Meldwork 关注的是中间那层工作:
当一个任务需要多个 Agent 共同推进,而你不想反复传递上下文、协调下一步时,Meldwork 更适合:
- 一个共享 Goal 和明确验收边界;
- 独立判断,而不是隐藏路由;
- 明确的权限、责任、交接和停止条件;
- 不随 Agent 切换而丢失的 Artifact 与 Evidence;
- 由人类掌握的最终采用决定。
- 研究与信息综合:结合本地 Agent 工具和明确选择的知识源;
- 产品、市场、运营和技术分析;
- 写作、规划、评审和结构化文档工作;
- 处理本地文档、图片、音频、视频、PDF、代码和配置文件的多模态工作;
- 在授权工作目录内执行、由其他 Agent 检查成果并继续修改的实施工作。
## 一个任务如何在 Meldwork 中推进
## Meldwork 正在构建什么
1. **选择**一起处理任务的 Agent。
2. **限定**目标、工作目录、上下文、附件、Skill、知识源和权限。
3. **协作**:启动 Auto Discussion,让 Agent 讨论方案、接续执行并检查成果;也可选择直接会话或并发回复。
4. **决策**:在需要补充信息、授权或取舍时介入。
5. **查看结果**:检查交付物,必要时提出修改,让工作继续推进。
当前协作机制不是 Harness 暗中预设的 A → B → C 分配链。用户选定参与者后,Agent 先展开解空间、自主协商职责,再在 Harness 管理的上下文、权限、预算、恢复和证据边界内执行。
## 协作模式
已选 Agent 的独立提案、交叉质询、职责协商、分工执行、整合和复核现在已经可用。从更大候选池自动选人组队、远程 Agent、企业治理和 Outcome 驱动的声誉系统仍是未来方向。
### 效果演示
| 本地 Agent 检测 | 单聊中的多模态工作 | 并发多 Agent 协作 |
| 模式 | 运行方式 | 适合场景 |
| --- | --- | --- |
| [![Meldwork 检测已支持本地 Agent CLI](assets/meldwork-agent-discovery.png)](assets/meldwork-agent-discovery.png) | [![Agent 在直接会话中返回本地文件和媒体](assets/meldwork-direct-multimodal.png)](assets/meldwork-direct-multimodal.png) | [![用户选择多个 Agent 并分别检查结果](assets/meldwork-group-collaboration.png)](assets/meldwork-group-collaboration.png) |
| 查看哪些受支持 Agent 已就绪。 | Prompt、文件、权限和兼容 Session 留在一起。 | 让已选 Agent 并发回复,或进入由 Agent 自主协商职责的协作流程。 |
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 聚焦研究、写作、分析或执行。 |
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同视角。 |
| **Auto Discussion V4** | Agent 独立提出方案后,接续讨论、请求同伴贡献、在授权范围内执行并检查成果。交付负责人判断任务进度,必要时请求用户决策;支持时保持原生会话。 | 希望减少人工协调的多步骤协作任务。 |
参与者始终由用户选择。从更大候选池自动选人组队不属于当前预览版。
## Meldwork 和别的产品有什么区别
## 过程可回看,工作可继续
### Harness设计
Meldwork 在本地保留任务上下文、Agent 回复、运行状态和产物,方便了解任务如何推进、哪里遇到阻塞,以及接下来需要处理什么。Run Ledger 保留阶段、参与者、尝试、产物、恢复状态和人工审批门;诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
Harness 是把会话、上下文、适配器、流和持久化状态拴在一起的控制平面。它让一次任务可以跨会话、超时、恢复和 Agent 切换继续下去。
过程记录帮助检查协作情况,不代表每条结论都经过事实核验。Agent 的相互检查也不能保证结果正确,重要交付物仍需按实际用途验收。
<p align="center">
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
</p>
## General Agent 目录
这张表比较的是不同工作范式,不是一张声称 Meldwork 应当替代所有工具的功能勾选表。
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的 Agent 命令。当前目录包括:
| 范式 | 例子 | 最擅长什么 | 主要代价 | Meldwork 的选择 |
| --- | --- | --- | --- | --- |
| 单 Agent CLI / AI IDE | Claude Code、Codex、Cursor、Windsurf | 在一个 Agent 或产品生态内完成深度工作 | 换 Agent 时仍需手工交接 | 保留跨异构 Agent 的工作 |
| 并行 Coding-Agent 工作台 | Superset、Vibe Kanban、Nimbalyst、Conductor | 并行运行多个 Coding 任务和隔离工作区 | 主要优化吞吐量和代码交付 | 优化方案质量、责任、证据和采用 |
| Cloud Agent 控制面 | GitHub Agent HQ、Warp Oz、Devin、Factory、OpenHands Cloud | 派发和观察远程 Job、Sandbox 与 Agent Fleet | 工作围绕云基础设施和平台边界 | 先从 Local-first 和 BYO Agent 切入 |
| 可编程多 Agent 框架 | LangGraph、CrewAI、AutoGen、Agents SDK、ADK | 开发者自定义 Graph、角色、路由和自动化 | 需要开发者设计并维护编排 | 面向使用者交付组织与验收体验 |
| 多终端与脚本 | tmux、Shell、手工复制粘贴 | 最大化原生 Agent 访问权 | 人类成为 Router、Context Bus 和最终裁决者 | 让工作、边界和决定保持持续 |
| **Meldwork** | 已支持本地 Agent CLI;远程供给属于方向 | 并发独立回复,以及已选 Agent 之间的提案、协商、责任、Evidence 和复核 | 当前仍是本地单用户预览;参与者仍由用户选定 | 构建 Agent workforce 的组织层 |
<table>
<tr>
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
</tr>
<tr>
<td align="center" valign="top"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/deepseek.png" alt="DeepSeek Harness 图标" width="32" height="32"><br><strong>DeepSeek Harness</strong></td>
</tr>
</table>
如果一个 Agent 和一个工作面已经足够,直接使用原生工具。只有当交接、独立判断、权限、证据和验收成为问题时,才需要 Meldwork。
已批准的 Agent Connector 和自定义可执行 Agent 使用桌面端的连接器入口。完整的适配器、安装、Provider 和能力矩阵见[桌面端指南](desktop/README.md)。
[Agent Connector SDK 指南](desktop/AGENT_CONNECTOR_SDK.md)介绍当前支持的包、审批、升级和一致性检查。
## 安装
DeepSeek Harness(`dsh`)通过 ACP 支持私聊与群聊,各对话使用独立、可续接的原生会话,并遵守工作区权限。默认模型为 ZGCI `smart-router-v2`;凭据通过原生服务或启动环境中的 `ZGCI_API_KEY` 提供。在「设置 → Agent → DeepSeek Harness」中可以选择已有的原生 `DSH_HOME` 目录,探测、运行和本地 Skill 都会使用该目录。原生环境变量 `DSH_AGENTS_HOME` 也可指定共享 Skill 目录。图标使用官方 macOS 客户端资源。
如果原生 ACP profile 或 DSH home 补丁显式指定了 Provider 或模型,Meldwork 会优先使用该配置,并传递其中声明的凭据环境变量。
当前经过发布验证的目标是 Apple 芯片 macOS。
WorkBuddy 也可以在「设置 → Agent」中选择已有的原生配置目录;模型列表、执行环境以及本地和插件 Skill 都会使用所选目录。
1. 打开 [Meldwork V1.0.3 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3)。
2. 下载 Meldwork-0.1.3-arm64.dmg。
3. 将 Meldwork 拖入“应用程序”,然后先尝试打开一次。
4. 当前预发布版使用临时签名,没有 Apple Developer ID 签名,也尚未经过 Apple 公证。如果 macOS 阻止启动,请打开“系统设置 → 隐私与安全性”,找到 Meldwork 提示,点击“仍要打开”并确认。
5. 连接电脑上已安装的受支持 Agent CLI,或在兼容情况下单独配置 Provider。
OpenClaw 也可以在「设置 → Agent」中选择原生状态目录。Meldwork 会从该目录读取所选 Agent 的模型配置,并在受保护的本地运行环境中执行。
请只使用官方 Release 下载的产物。
切换 Agent 的原生配置目录时,Meldwork 会按目录分别保存本地会话,切回原目录后恢复对应会话。任务运行中或等待恢复时,暂时不能切换配置。
<details>
<summary><strong>从源码运行</strong></summary>
Zcode 请选择包含 `config.json` 的 `.zcode/cli` 目录。运行时 Zcode 会使用该目录所属的用户主目录,这也会影响其会话及其他文件中的主目录相对路径;项目配置仍可能覆盖用户配置。
需要 Node.js 22.12 或更高版本以及 npm。
Qwen Code 请选择包含设置或认证文件的 `.qwen` 目录。Qwen 会使用其所属的用户主目录保存原生会话、读取 Skill 和其他主目录相对文件;项目和系统设置仍遵循原生优先级。
~~~bash
Hermes 使用自身的 `config.yaml` 和 Provider 设置。如果模型、备用模型或 Provider 条目通过 `key_env` 或 `api_key_env` 声明凭据变量,Meldwork 会从启动环境或登录 shell 中读取这些变量并交给 Hermes;无关变量和不安全的进程控制变量会被排除。首次使用新的原生数据目录启动时,Hermes 可能会自行准备依赖。
## 上下文与本地边界
Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记录和应用管理的附件保存在本地用户数据目录。渲染进程只通过受约束的 preload API 获取校验后的状态;可执行路径、凭据、原生会话引用、Skill 路径和任意 Shell 权限都留在主进程中。
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
## 从源码运行
前置条件:Node.js `22.12+` 和 npm。
```bash
npm --prefix frontend ci
npm --prefix desktop ci
npm --prefix desktop run dev
~~~
```
分发前运行:
分发构建前运行:
~~~bash
```bash
npm --prefix frontend test
npm --prefix frontend run build
npm --prefix frontend run build:desktop
npm --prefix desktop test
~~~
```
</details>
## 文档
## 信任边界
- Meldwork 会话和编排状态保存在本地。
- 模型请求仍然按照你选择的 Agent 和 Provider 发出;Local-first 不等于所有模型离线运行。
- 兼容环境使用操作系统安全存储保存凭据。
- 工作目录写入必须显式开启。
- Renderer 不暴露 raw chain-of-thought、可执行路径、Secret 或不受限命令输出。
- Cloud、Channel、自动选人组队、企业治理和 Outcome Network 不是当前发布承诺。
公开技术边界见[架构](architecture.md)和 [Agent Connector 契约](docs/agent-connector-sdk.md)。
- [架构与产品边界](architecture.md)
- [桌面端设置与 Agent 矩阵](desktop/README.md)
- [贡献指南](CONTRIBUTING.md)
- [安全策略](SECURITY.md)
## 许可证
Meldwork 采用 [Meldwork 非商用源码许可](LICENSE)。你可以为非商用目的查看、运行、修改和分享;商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
Meldwork 以 [Apache License 2.0](LICENSE) 开源。在许可证条款范围内,可以商用、私用、修改和再分发。商用无需另行授权或支付许可费。Apache-2.0 不授予商标权;详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
+3 -3
View File
@@ -1,6 +1,6 @@
# Security Policy
Meldwork is a local-first desktop application that invokes local Agent CLIs, stores local conversations, imports validated image, media, document, code, and archive attachments, and manages Provider credentials through the Electron main process.
Meldwork is a local-first desktop application that invokes local Agent CLIs, stores local conversations, imports validated image, media, document, code, and archive attachments, and runs each Agent with its own native CLI credentials through the Electron main process.
## Reporting
@@ -11,10 +11,10 @@ Do not open a public issue with exploit details, secrets, local file paths, or p
## In Scope
- Renderer-to-main IPC authorization bypasses.
- Exposure of executable paths, attachment paths, native session references, Provider API keys, or Skill file paths to renderer code.
- Exposure of executable paths, attachment paths, native session references, CLI API keys, or Skill file paths to renderer code.
- Arbitrary shell execution, arbitrary file read/write, unsafe navigation, or unsafe installer behavior.
- Attachment validation, private storage, checksum, or preview isolation failures.
- Credential storage or Provider configuration failures that expose secrets.
- Credential storage failures that expose secrets.
## Out of Scope
+59 -19
View File
@@ -2,7 +2,7 @@
## Product Boundary
Meldwork is a local Electron desktop product for direct and multi-Agent work with explicitly selected Skill, image, and knowledge-source context. It has no account system, tenant model, required application server, remote conversation store, email delivery, scheduled jobs, or public SEO surface. Optional Agent, Cloud, and Channel Connectors run behind the Electron main-process trust boundary and are disabled until explicitly configured.
Meldwork is a local Electron desktop product for multi-agent orchestration and review with explicitly selected Skill, image, and knowledge-source context. It has no account system, tenant model, required application server, remote conversation store, email delivery, scheduled jobs, or hosted web surface. Optional Agent, Cloud, and Channel Connectors run behind the Electron main-process trust boundary and are disabled until explicitly configured. The landing page at https://ryder-mhumble.github.io/Meldwork/ is the public marketing surface for search engine and AI assistant discoverability.
The supported product surface is the packaged Electron application. The frontend is a renderer bundle; without the local preload bridge it shows a desktop-required state and cannot operate the workspace.
@@ -11,15 +11,15 @@ The supported product surface is the packaged Electron application. The frontend
| Component | Responsibility | Primary evidence |
| --- | --- | --- |
| Vue renderer | Agent-first multi-session UI, direct-session rename/delete, live direct-chat conclusions and inline execution summaries, group-chat result streaming with an expandable per-Agent/right-side trace panel, per-round navigation, background completion markers, first-run carousel, conversation/Skill/image/knowledge composer UI, bounded attachment-preview cache, theme/i18n, user confirmation and input collection | `frontend/src/App.vue`, `frontend/src/components/RunTracePanel.vue`, `frontend/src/composables/useAttachmentPreviews.js`, `frontend/src/desktop.js`, `frontend/src/knowledgeBaseCatalog.js` |
| Electron preload | Exposes narrow workspace, Human Gate, Cloud control, installer/Skill, attachment, Provider, and knowledge-source methods through `window.meldworkDesktop` only to a local `file:` document; Channel ingress has no renderer API | `desktop/src/preload.cjs` |
| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Connector registration, Skill and knowledge-source validation, local/Cloud execution, Channel receivers, navigation, sanitized run events, operating-system notifications, Provider storage, and installer control | `desktop/src/main.cjs` |
| Electron preload | Exposes narrow workspace, Human Gate, Cloud control, installer/Skill, attachment, and knowledge-source methods through `window.meldworkDesktop` only to a local `file:` document; Channel ingress has no renderer API | `desktop/src/preload.cjs` |
| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Connector registration, Skill and knowledge-source validation, local/Cloud execution, Channel receivers, navigation, sanitized run events, operating-system notifications, and installer control | `desktop/src/main.cjs` |
| Local workspace | Persists Meldwork conversations, final replies, compact evidence capsules, safe message metadata, task-scoped group Sessions, conversation-scoped direct Sessions, legacy Session migration, Agent-specific transcript deltas, Agent preferences/runtime evidence, and active run coordination | `desktop/src/workspace/local-workspace.cjs` |
| V4 orchestration | Validates frozen snapshots, phase-aware slots, typed collaboration receipts, negotiated responsibility graphs, work receipts, synthesis recovery, verification, and idempotent commits | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-message-submission.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs` |
| V4 orchestration | Validates frozen snapshots, phase-aware slots, typed collaboration receipts, negotiated responsibility graphs, work receipts, synthesis recovery, verification, and idempotent commits | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-message-submission.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs` and its `local-workspace-auto-runner-*.cjs` parts (see Code Map) |
| Run Ledger | Atomically checkpoints bounded, sanitized per-run and per-Agent state for stop/crash recovery, marks nonterminal runs and Agent attempts interrupted after restart, supports idempotent reconciliation of missing terminal Agent messages, and purges records with their conversation | `desktop/src/runs/run-ledger.cjs` |
| Context Pack and delivery stores | Capture immutable, content-addressed Task inputs, attempt-specific approved prompts, exact outbound fingerprints, source hashes, target scope, and native Session provenance | `desktop/src/collaboration/context-pack-store.cjs`, `desktop/src/workspace/local-workspace-context-packs.cjs` |
| Outcome store | Persists typed Artifacts, Evidence, Findings, and Adoption records separately from diagnostic reasoning and tool summaries | `desktop/src/collaboration/outcome-store.cjs`, `desktop/src/collaboration/outcome-records.cjs` |
| Human Gate store | Persists permission, budget, and decision requests and their terminal user/system decisions without exposing raw request payloads to the renderer | `desktop/src/gates/human-gate-store.cjs`, `desktop/src/gates/human-gate-coordinator.cjs` |
| Scheduling and failure policy | Enforces run budgets, retry policy, per-Agent controls, and global/workspace scheduling | `desktop/src/runs/run-scheduler.cjs`, `desktop/src/runs/failure-policy.cjs` |
| Scheduling and failure policy | Enforces run budgets, per-Agent controls, and one writer at a time within a conversation (conversations never wait for each other); classifies failures (sign-in, rate limit, network, provider, context overflow) and decides retries with `Retry-After`; stops a silent Agent through the invocation watchdog | `desktop/src/runs/run-scheduler.cjs`, `desktop/src/runs/failure-policy.cjs`, `desktop/src/runs/failure-signals.cjs`, `desktop/src/runs/invocation-watchdog.cjs` |
| Agent Connector registry/runtime | Validates approved manifests and instances, separates CredentialRefs, records Connector/upstream versions, and reduces versioned idempotent Run Events | `desktop/src/agents/connectors/agent-connector-manifest.cjs`, `desktop/src/agents/connectors/agent-connector-registry.cjs`, `desktop/src/agents/connectors/agent-connector-runtime.cjs` |
| Attachment store | Imports validated local images, media, documents, code/configuration files, Office files, and archives into private app-owned storage, verifies metadata/checksums on read, and removes unreferenced entries | `desktop/src/attachments/attachment-store.cjs`, `desktop/src/attachments/image-dimensions.cjs` |
| Local Skill catalog | Scans bounded known roots per Agent and returns sanitized Skill coordinates without exposing paths or contents | `desktop/src/skills/local-skill-catalog.cjs` |
@@ -30,9 +30,53 @@ The supported product surface is the packaged Electron application. The frontend
| Knowledge-source store | Persists only the user-selected Obsidian Vault path; Feishu and DingTalk credentials remain owned by their local CLIs | `desktop/src/knowledge/knowledge-base-store.cjs` |
| CLI adapters | Detect and invoke supported local Agent executables with per-Agent arguments and constrained child environments | `desktop/src/agents/cli/cli-adapters.cjs` |
| Agent installer | Detects Agents and runs allowlisted npm packages or official installer scripts after user confirmation | `desktop/src/agents/installer/agent-installer.cjs` |
| Provider store | Validates Provider metadata and encrypts the API key with Electron `safeStorage` | `desktop/src/providers/provider-store.cjs` |
| Managed OpenClaw runtime | Creates isolated local configuration and a restricted tool policy for OpenClaw | `desktop/src/agents/cli/openclaw-runtime.cjs` |
## Code Map
The workspace is composed of small modules around a thin coordinator. A module either owns one piece of state or is a pure function of its inputs; each has a test file with the same name under `desktop/test/`.
**One Agent invocation** (`desktop/src/workspace/local-workspace-agent-invocation.cjs`) is the run lifecycle: lease, run, race against stop and timeout, record the result. It delegates to:
| Module | Owns |
| --- | --- |
| `invocation-session.cjs` | The native Agent session of one invocation: resume, adopt a new id, discard and rebuild, record the turn |
| `invocation-outputs.cjs` | Workspace snapshot before a writable turn, media import and artifact descriptors after it |
| `invocation-prompt.cjs` | The prompt one Agent receives (frozen, direct chat, group) |
| `invocation-budget.cjs` | Usage recorded against the run budget |
| `invocation-failure.cjs` | Why an invocation ended (stopped, timed out, interrupted, cancelled, failed) and the hidden error metadata |
| `desktop/src/runs/invocation-watchdog.cjs` | Startup, inactivity and stall windows; liveness versus substantive progress |
| `desktop/src/agents/agent-permission-policy.cjs` | Automatic answers to permission requests; a read-only turn only approves side-effect-free tools |
| `desktop/src/collaboration/collaboration-receipt.cjs` | Parsing and normalising the control receipt at the end of a turn (lenient for natural discussion, strict for phased V4) |
| `desktop/src/collaboration/answer-marker-filter.cjs` | Hides control markers while an answer streams |
**Prompts** live in `desktop/src/workspace/prompts/`: `natural-discussion-prompt.cjs` (named sections: brief, writer, owner, routing, receipt), `manual-reply-prompt.cjs` (concurrent replies in a group), and `prompt-contracts.cjs` (the language and deliverable contracts shared by all of them). Direct chats are built by `directPromptFor` in `local-workspace-context.cjs`.
**The automatic run** is `LocalWorkspaceAutoRunner`; its methods are grouped into files by what they do and assembled with `Object.assign` onto the class prototype:
| File | Responsibility |
| --- | --- |
| `local-workspace-auto-runner.cjs` | Constructor, `start` and `resume`, run-level helpers |
| `…-natural-discussion.cjs`, `…-natural-decisions.cjs` | The production path: transcript, prompts, the sequential and agent-led loops, and what happens after each round (owner takeover, ignored routes, peer blockers, missing decisions) |
| `…-v4-discussion.cjs`, `…-v4-phase.cjs`, `…-v4-results.cjs` | The phased V4 run, one phase, and turning results into receipts and commits |
| `…-v4-context.cjs`, `…-v4-delivery.cjs`, `…-v4-synthesis.cjs`, `…-v4-outcomes.cjs` | What a run freezes and shares, how context reaches each Agent, synthesis recovery, and the receipts, review issues, convergence and acceptance checks |
| `…-recovery.cjs` | Retries, pausing a failed Agent for the run, unauthorized-session recovery |
| `…-collaboration.cjs` | Collaboration records written as a run progresses |
| `…-task-graph.cjs`, `…-rounds.cjs` | V3 task-graph and V2 round runs, kept only to resume older runs |
Splitting by file did not change any method; the class still shares one `this`. New behaviour should go into a small module with its own tests and be called from the group that owns the topic, not appended to a large method.
## Reliability Rules
- Conversations are independent. A running chat, direct or group, never makes an Agent in another conversation wait, even when both use the same folder and the same Agent. The writer lease is scoped to the conversation; inside one conversation writers still take turns.
- A failure pauses an Agent for the current run only. Group membership is never rewritten because an Agent failed; a group whose members were all removed by an older build is rebuilt from the Agents that replied in it.
- Only a definitive sign-in failure marks credentials missing. A refusal that could be a proxy or a tool's own output is retried once and reported as a provider failure.
- An Agent that was available stays available through an inconclusive readiness probe for ten minutes. A definitive answer applies immediately.
- A stopped or failed turn keeps a healthy long-lived ACP process and its native session. The process is replaced only when it ignores cancellation or the protocol breaks.
- Unknown ACP traffic (banners, new update kinds, unknown notifications) is ignored; unknown requests are answered with "method not found". Malformed JSON, invalid responses and invalid permission requests still fail closed.
- Messages in a group topic carry a dispatch number (`turnSeq`) assigned before the Agent starts, so live and saved replies share one order. Failure and stop notices take the turn's number.
- A discussion does not end on a malformed receipt, an absent recipient, a peer reporting a blocker or an owner who forgot the decision: those are discarded, handed to the owner, or asked for once.
## Concurrent Collaboration V4
- A group with multiple selected Agents in `manual` mode runs as one concurrent batch. Harness freezes every selected Agent's task snapshot and delivery plan before any Scheduler lease, preserves all selected members, and publishes the batch in stable member order after the barrier. Direct conversations, one-Agent targets, and regeneration keep their existing execution paths.
@@ -45,11 +89,11 @@ Automatic participant selection from a larger roster, production remote Agents,
## Trust Boundaries
1. The renderer is not trusted with Node.js, Agent executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw Provider credentials after saving, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs and the user-selected Obsidian Vault path are intentional UI-visible paths; pasted image bytes use one exact import payload.
1. The renderer is not trusted with Node.js, Agent executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw CLI credentials, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs and the user-selected Obsidian Vault path are intentional UI-visible paths; pasted image bytes use one exact import payload.
2. Preload exposes only named IPC methods. Main validates that every request comes from the current main frame and exact bundled frontend file.
3. Main owns local files, OS dialogs, attachment validation/storage, Skill and knowledge-source discovery/revalidation, Connector approval/registration, Agent discovery, child processes, Cloud observation, Channel authentication, installer downloads, and credential encryption.
4. Agent CLIs are external processes. They receive a selected working directory, a constrained environment, permission flags, main-resolved image paths, target-scoped immutable Skill snapshots, and validated knowledge snapshots or read-only hints after capability checks.
5. Provider, Agent Connector, Cloud Connector, Channel Connector, and installer destinations are external trust boundaries. Provider URLs are user supplied and validated; manifests and Connector instances fail closed unless explicitly approved; Channel signatures and Workspace scope are verified before Task delivery.
5. Agent Connector, Cloud Connector, Channel Connector, and installer destinations are external trust boundaries. Manifests and Connector instances fail closed unless explicitly approved; Channel signatures and Workspace scope are verified before Task delivery.
6. Harness events expose only allowlisted status, conclusion deltas, reasoning summaries, plans, tool lifecycle summaries, bounded result metadata, and source message IDs. Raw chain-of-thought, commands, executable paths, native session references, credentials, unrestricted stdout/stderr, and arbitrary tool payloads remain outside the renderer and shared Agent context.
## Local Data
@@ -61,7 +105,6 @@ Automatic participant selection from a larger roster, production remote Agents,
| Immutable Context Packs, delivery records, content blobs, typed outcomes, and Human Gates | Electron user data under `meldwork-private/` | Content-addressed or atomically written main-only records; renderer snapshots expose only bounded IDs, provenance summaries, budgets, and pending Gate choices |
| Cloud operations and Channel Inbox | Electron user data under `meldwork-private/` | Private `0600` stores keep opaque remote references, idempotency state, signed-event-derived Task data, delivery cursors, Task mappings, and outbound audit records; raw signatures, headers, credentials, and complete IM history are not exposed to the renderer |
| Imported attachments | Electron user data under `attachments/` | App-owned copy; `0700` directories, `0600` files, type/MIME/size validation, image-dimension checks where applicable, metadata checksum, ID-only renderer access |
| Provider metadata and encrypted key | Electron user data: `meldwork-provider.json` | API key encrypted with `safeStorage`; file written atomically with mode `0600` |
| Knowledge-source preference | Electron user data: `meldwork-knowledge-base.json` | Stores the selected absolute Obsidian Vault path only; written atomically as a private file |
| Managed OpenClaw state/config | Electron user data under `openclaw-managed/` | Per-scope directories, mode `0700`; config mode `0600`; API key passed by child environment |
| Theme, locale, and onboarding completion | Renderer `localStorage` | Non-secret UI preferences |
@@ -74,7 +117,6 @@ There is no Meldwork-hosted remote data store or application account namespace.
- Agent CLIs may call their own configured model providers.
- Selected attachments and Skill-influenced prompts may be sent by an Agent CLI to its configured model provider after local validation and capability checks.
- Compatible Agents may receive the user-configured Provider URL/model/key from main.
- Feishu and DingTalk readiness probes may invoke their configured local CLIs; a selected Agent may then use those CLI connections with read-only instructions.
- An explicit Obsidian selection is searched and fetched through the main-owned read-only filesystem Connector, then captured as an immutable snapshot or explicit live reference before Agent invocation.
- Approved Agent Connectors may contact only their declared outbound destinations and receive main-owned CredentialRefs rather than renderer-provided secrets.
@@ -86,6 +128,7 @@ There is no Meldwork-hosted remote data store or application account namespace.
## Known Risks And Assumptions
- Writable conversations that share a folder are not coordinated. Two Agents in different conversations can edit the same files at the same time, as two terminals in one directory can. Output capture compares `.meldwork-output/` before and after a run, so when two conversations produce deliverables in the same folder at the same time, one may attach or record a file the other wrote.
- Installer recipes use mutable upstream packages or scripts and currently do not verify a pinned digest or signature (`desktop/src/agents/installer/agent-installer.cjs`).
- Conversation history is local but not application-encrypted (`desktop/src/workspace/local-workspace.cjs`).
- Sanitized Run Ledger checkpoints are local and private but not application-encrypted. The store assumes one Electron main-process writer and recovers only the last persisted bounded checkpoint, not every in-memory event (`desktop/src/runs/run-ledger.cjs`).
@@ -95,7 +138,7 @@ There is no Meldwork-hosted remote data store or application account namespace.
- Obsidian selections use a main-owned read-only Connector with bounded retrieval, immutable snapshots, and citation hash verification. Generic Feishu and DingTalk access remains CLI-owned and prompt-mediated; their authentication and document permissions are not controlled by Meldwork. Notion, Confluence, Google Drive, and SharePoint are reference-only planned sources and are not active Connectors.
- Native image support is currently limited to Codex, Hermes, and OpenCode, with different per-run limits; mixed groups fail before execution when they cannot receive equal image context (`desktop/src/agents/cli/cli-adapters.cjs`).
- Read-only enforcement is owned by the Obsidian Connector for captured selections. Agent workspace and CLI-mediated remote access still depends partly on each upstream CLI honoring its permission flags; Meldwork adds per-CLI hardening where available (`desktop/src/agents/cli/cli-adapters.cjs`).
- Provider requests and native Agent authentication are external dependencies; Meldwork cannot guarantee their availability or data handling.
- Native Agent authentication and model requests are external dependencies; Meldwork cannot guarantee their availability or data handling.
- Agent/Cloud/Channel Connector contracts validate provenance, scope, and idempotency, but the external implementation must honor its CredentialRef, outbound destination, cancellation, and signature-verification obligations. The production build registers no Cloud or Channel Connector by default.
- macOS packaging uses ad-hoc signing for local validation, not a notarized public release (`desktop/scripts/after-pack.cjs`).
@@ -103,14 +146,11 @@ There is no Meldwork-hosted remote data store or application account namespace.
- No email sending, so there is no `emails.md`.
- No cron or scheduled background work, so there is no `cron.md`.
- No supported public or indexable routes, so there is no `seo.md`.
- No supported public or indexable routes in the app itself, so there is no `seo.md`. The landing page at https://ryder-mhumble.github.io/Meldwork/ serves as the public discoverability surface for search engines and AI assistants.
## Related Documents
- [Flows](docs/flows.md)
- [Permissions](docs/permissions.md)
- [Variables And Secrets](docs/variables.md)
- [Test Coverage](docs/tests.md)
- [Automation](docs/automation.md)
- [Public MVP Release Checklist](docs/public-mvp-release.md)
- [macOS Signing And Notarization](docs/macos-signing.md)
- [Contributing](CONTRIBUTING.md)
- [Security Policy](SECURITY.md)
- [Repository Guidelines For Agents](AGENTS.md)
- [Changelog](CHANGELOG.md)
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 865 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="720" height="450" viewBox="0 0 720 450"><rect width="720" height="450" rx="12" fill="#edf2f4"/><image x="8" y="8" width="704" height="434" preserveAspectRatio="xMidYMid meet" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABaAAAAOYCAIAAACsO8NlAAAQAElEQVR4nOz9B5wV1Zro/a9qMnSTwQCIEhUJCgjmnEVyzlkF9ZwJ9/POzL33P3Pf+87Me9+Zc+YcESTnnJMEcxaQoIKgEkRBJEjOobv+tWtV2t1Vu6p672r27v5954wuVz+9au3atUM9XbWe0mcvXBIAAAAAAACZLEsAAAAAAABkOBIcAAAAAAAg45HgAAAAAAAAGa+0SB1VVWVDUZSAnUGCUzixlG8u1AjB90/h5iBS/UCie3RFfKhE9OgSRAszOuAcxHV9dEXNbf94xwZ+II6dKVL6qNNhZ4Z6maf1s4/Uie5NrHi87qL7eC3iz5TkP14TDyt4rwCADJFxn+ahFOLzzvWhKZEsMhpqH4U520mL4Ex8dMFFd3xHNGEAQMbh07wIggEAKHm4RQUAAAAAAGS8VN6iktGivjuG6z8BAIhaRPdo8GkOAEBGiOYWFQAAAAAAgCLkdwWH/icL7Q8XSpbjZpa8PKORFX+Hi1u/mpsrG0qpUi6R+QZxHTnM5nw6k9iczwNJ9eaK+NElv7kQ+0dE9ejc5+Al+UcXauTEkaGCfSPTJJhHV4hhwwaHEt3IyFDJH5lp/ImQJp/mRf3lwVWavAUBADJLxn2L1hXmFhX7Qs0A/UrCEbwCCr05r2CvoYN2+j2QfIu6iuDBnlNL9tGFmptrf6gRQuyfMI/O81Bxm0a464YTPmQR4DAuYil49lMR7HkIeQ8rkniZF3FwcNE9uuikyTSQPlL4qSTCfPSn/BMh1Cdm8Lml5GWe/OZS8G0nDN4oAACFEfgcIURkwmopXgqT4FDC9AfPeoQNVsLPQSTxpSH59E2oL3zJP7rkv18mvzOTf+5CHSrJf1kPtblQIyeODBIc6rtmdMHBH12aTDjjHl10ZxecriCf5A/jIv40T34EEWZuKXl0yW8uVHDwOYgwmwMAlEDpcAZUCKzBAQAAAAAAMh5VVAAAQIZRr12TDaW0/zcZ9epVI7hMGd9gYY4sAowcPNiaQ9BpAACA8EhwAACATBPmDt5C3O4rUn1FbrgJAwCAQiHBAQAAirUw9/gmv151KsYFAACF4bcGh/XJ7Vi5VM3NNf4jvmSmeuWyEVu2nN1rXZPpuCDTiswXrF69YnSWKZu4Ux/E7C/rE+y5ObcJuw4bKtiag9c0fOeQos25BXtuzjU40Qgi0M702FzCp8l1BBHkUAnz6NyPTI+d6Sr5QyXsyEkGh3x07jsz8ci+w8a47XnvaQR+dB6HSqhhQx1XwUcO9ULwGDbUows14aie6FCHMQoh+SMz1MjunaE+7zw/zaP58hDd5124zQXdFan5eA3+RDtumRHcMgMAJVhKvkWnJljPOQS6kVOmKdyqq6T4Cg5VVfOX5xDJFiv1DPYo8iGCcw1WvGJdL0MNM+HgpVi9hHvIYeYmQuwKjwGS3RVRjaD/wK0rzJHpynvR/BDBHkNEEhzZRdrhRhZJH/PJC/XchRPNZfPpcTV+yP3DBfnRSsHHh/fQIuDmUnFkulfFKuIvD6E+XjPt885j1GgXsQcAZIzovuBdjw+Y6G9Rie5TvGi/NIR4dsJsTgk1SLhvOa6xSZ/MRPbcJa78KoJ8zU3+q3ZKTiOTHzkiKXl0aRuczs9dmPO6UCMkH5wmJWxRCFHdSSHClEEt2k/zUJ8InodrEWcckt5c8mXRPaTBpxIAIB1Ed55yPc6AKBMLAAAAAAAy3vVfZFS9dFE2lPIVREpFNHKeOawmK9VzBgCg2FMvXpANpUJF/+Awn+bBR1Yv23/gUcqVDxjsGynCTvjCeSO4YiUBAACSkwYJjsiueY5oZK7NBgAgKUV8c2WSkSI9JgwAAPxc/wSHEtlHe1Qj810EAIBkhFopJppMRKhVNkPlLJTIUicAACAx1uAAAAAAAAAZz+8KDusPC84as7m58k8ZSmn/C0DyLl1U9D99ZDnuhlXPn7PaSqVsu//saaMzp0riTr3/jNlf2e48d9bozM4p2Jm/33UEt07PkROOEGTkvDOnZCOrctW4zZm7KG7/JOzM3+8+N/edmWf2Zzn3vPtDPu14dFUSb85L8GCv586deSezCHAns+ujK+Jgr0MlyWFDB4d57ryO2ARzEIF2hfuR6REcYsLhjorg07CGDTKy9SJ1vEI95xDdE+32BuIe6fEyRwnh/jJP+jD2/HgN/nkX7tPc48tDwu8JQb48uO4Kr9dXiC8Pob4aRfZGEeo9EwCAsNSLF9RYdkGJ/b/vylZ5eUYjK0sEc51uUQlVNNEzOC9ocLhy8cnXAQ6zucgKy6XF5jxFco90hLc9RxWcDnOIbBqhnrs8t5ezV7AI8URHdFREd3F75tXcRfETquqq+wCulU09XuYhysTmWe34QYr08y7UdxWP/jCbywv8bcdLdJ+kAACkmeu6BkfBT9yka8u7nvmowuNURI3kK5HXmY/HVyKR5OY8uc4tz+OLS+BvYNY3LRHk0YWZm1eoCC7Ud7hQJ8lhgoPP2WtnJjls2OCQz50ILNRzFyI281IAkeWbwh2Z0aRvUAy5fyAkfRgXbcbB8/MuxF8mkp6wxyAhUyQheoPPwTuW/CYAIIOxBgcAACjO1NMnZUOpUi21wcHv2gMAAEXg+ldRAQAASBP24mOhogEAQBogwQEAAIqzCHMW5DcAAEgnJDgAAEBx5lzwKEA0SQsAADKV3xocHmVi9Z+o+cvEXr0aWzdLEUqZsvYAly7K4LgysefOmiOrzrJnuSeOK3p0Vo2aVmfesaOykVWrtnNruWZ/KUe/a2feqZNWO6tqtYL9zk7XEbyC834/ZnTWrGV3Hv/d3pzzgbgGuw3rHCRuhJMnjM5q1QtOON+ctZ1pdFavkXgEr37X+5C9Nuf66HLNzlKOTuG1MxN2iiB73nzIWY6HnKC/IM/Nmbsiy7ErXJ8jL167wn0aYUb2ek4TRAYJDjdhjxep+8geLzH3kT1eIC7DHj1itUvVvkGkbg7hJhzqqHB9v3J97Xscme5zCHxIiDB7WIR5KYUS6siEJaKXkvA4hEJ9ALl+fHgdma7vNrnmC6FUgBeC+6eV24eg8PqscX3I5sREgM8a108lz5HDvMxdgz2/qwR++ec6vquUCvB+BQAorrw+zV2F+p4ZO+vX0whKkBrzV68qiqLlCPInGdyjY9kERXG5NDMDruDw/MNL0uuup6Boi9vcvCbs3u9XFq7QI6Tg0ble05uSzSU9Qgp2RahINfAcwo5chMHRTTgtdkWoYdP4uQv1rhJ82NSIZmSVP5gXTnSFQoO/wXp+QIugI4iUfJoHHtZL4BejCPsiDT5ymP2jhPpwDDwsAKAkiu5r//WQCbeohPpKlPx5bxrnU6KasAjzDSwV6ZuIdma41FLgYT370yBnES44TSYc5m0xqi/r6bwrkj7Y0iWTFc0cYCviwzjMkZlnHiqlnLEex48SeGSvEVw3V8QfQNFtLvl8SrKRAIBirRBfHV0vnUgTlIkFAAAAAAAZj0VGAQBAkco9clg2St1wo39wNIvmWHMIMo1wEw6zhlEooVb5AQCgBCLBAQAAihYL9xQO95UAAJAQCQ4AAFC0WJaoUNJ/aTcAAK6vwqzBoebmGouKlIpfz+vKZWPRkXLl7c5LF2MFZbVOZ5nYs6f1f8Xkr7upd8aVOz16JDas1nnjTc7NuV4veu3Qr7JR+uY6BSPzBece/MXorHtL4mFj/b8dMvpvutnu/PWg0VmnbsE5eE0jbg4HfjY669X33Zzro/PanKs8s5RmVnwdTfdHF+qK3MO/GcGOp8lzZ7oFu7pmTkxT2jE3V16Pzn3Cbg/ZM9jtUEl+Grm/7LfapW651Sc4zISt/VY6QHAooY6KiIZN/shMfuRcx5Hp+4xYT7Tvsyy83xPcg8PsimvmNEoHmEZwER0SSDe5vx6QjVJ16vkHu34ieLyRuvZf+/kn2Shd/zY70uM9M/eQ+Vl8c93Ec/Dqd33PtF6MIv716P7oPF7m18z9Vtqx3xLvH5FvV7jOLczTEUqotyAAAKJlZfazsgL+RiZfwZEWC6EXad3WULXi3EdNSQ0U9yEiKWuSkkfnER3VyMGDIxpWj86wC6QzruZumpQ1CRWsRHRUcM18CZH8Yex1qLiNrIT5TElBAbXIyngFrxETriZadJdy8IoGAGSyDL9FxeVjuIgTHK7BISq9hat7L0JsLviwnoMkfwoXamcmGalfEhQ8OMIzw8DBIU840+IC6ahGTpNrxQMHq2p6lGKNbuRo5oAMFlnKO0Rw0X6ahyvFGiZ9E2oEJdTmksZdMACAjJbxa3DkO6cN9T0g5PenoMFK8t/hvDaXF7hTeE0huu9PoUYIPnJ0Z/VRjRw8zxLqe2SE2ZBQ0iDBEe7KlzCZiIiuLRLRPdHRBV/3YZFmkn/dKcl/Foe7KjBUhj3pj7BQH/1u+8d7D4f58pA0hVc0ACCTFWYNDgAAgGIp1KJLwVmrioj4hUUAAEAKUUUFAADAFM0lDFwZAQBAESDBAQAAYOJmLgAAMha3qAAAAAAAgIzndwWH/gcHVVWVAIVn1StXYv9ShFKmrE/k2TP6qKqihVeumjg478RxPVYtVbOW8BOr364HO29wzd2/z2qXurWB1b76/Q7ZKHN7c9+Rr+7eZQQ3vsPqvLbnR9ko3aiJvTnHfbal/O6zvfbTXmOE2xoKP9f2Line truncated

After

Width:  |  Height:  |  Size: 204 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="720" height="450" viewBox="0 0 720 450"><rect width="720" height="450" rx="12" fill="#edf2f4"/><image x="8" y="8" width="704" height="434" preserveAspectRatio="xMidYMid meet" xlink:href="data:image/png;base64,/9j/4AAQSkZJRgABAQAAkACQAAD/4QB0RXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAKgAgAEAAAAAQAADwCgAwAEAAAAAQAACDQAAAAA/+0AOFBob3Rvc2hvcCAzLjAAOEJJTQQEAAAAAAAAOEJJTQQlAAAAAAAQ1B2M2Y8AsgTpgAmY7PhCfv/AABEICDQPAAMBIgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2wBDAAICAgICAgMCAgMFAwMDBQYFBQUFBggGBgYGBggKCAgICAgICgoKCgoKCgoMDAwMDAwODg4ODg8PDw8PDw8PDw//2wBDAQICAgQEBAcEBAcQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/3QAEAPD/2gAMAwEAAhEDEQA/APCHfFeifC/4R+PPjHrjaN4KsfMjgKi6vZiUtLUN0MjgHLEchFBY+mOaT4R/C/W/jJ48sfBOjM0EcmZr26C7ha2iECSQjoWOQqA9WI7Zr93PAngTwx8NvDFn4Q8I2a2en2a4A6vI5+9JI3V3c8sx/lgD9s4o4p+qL2VLWb/A/COFuFPrj9rW0gvxPkzwB+wh8LdBgiufHlzc+Kr8DLqZGtLQNjoscLByAf78hB9O1fRml/An4LaPAtvYeBdEQL/E+nwSSH6u6M5/E16vRX5Pis1xNd3q1G/n+h+t4TKsNQVqVNL5frucrY+BPA+mHOm+HdNtP+uVnCn/AKCgro4LW1tuLaCOL/cUL/IVPSVwNnoEnmSf3jR5sn941HRSsO7JPNk/vGjzJP7xqOiiwXZJ5sn940ebJ/eNR0UBck82T+8aPNk/vGo6KAuSebJ/eNHmyf3jUf4UfhQF2SebL/eNJ5sv941HiigLsk82X+8aPNl/vGo6KAuyTzZf7xo82X+8ajooC7JPNl/vGjzZf7xqOigLsk82X+8aPNl/vGo8UfhQF2SebL/eNHmy/wB41H+FJQF2S+bL/eNHmy/3jUVFAXZL5sv940nmy/3jUdFAXZJ5sv8AeNHmy/3jUdFAXZJ5sv8AeNHmy/3jUdFAXZJ5sv8AeNHmy/3jUdFAXZJ5sv8AeNHmy/3jUVFFguyXzZf7xo82X+8aioosF2SebL/eNHnS/wB41HRRYLsk86X+8aPOl/vGo6KLBdknmy/3jR5sn941HRRYLsk82X+8aTzZf7xqOiiwXZJ5sv8AeNHmy/3jUdFFguyTzZf7xo82X+8ajo/CiwXZJ5sv940ebL/eNR/hSUWC7JPNl/vGjzZf7xqOiiwXZJ5sv940ebL/AHjUdFFguyTzZf7xo82X+8ajo/CiwXJPNl/vGjzZf7xqKiiwXJfNl/vGk82X+8ajoosFyTzZf7xo82X+8ajoosFyTzZf7xo86X+8ajooC5J50v8AeNHnS/3jUVFAXJfOl/vGjzpf7xqKigLkvnS/3jR50v8AeNRUUBck86X+8aPOl/vVHSUBcl86X+9R50v941FRQFyXzpf7xo86X+8aiooC7JfOl/vGjzpf7xqKj8KAuyXzpf7xo86X+8ai/CkoC7JfOl/vGjzpf7xqKigLsl86X+8aPOl/vGosUUBzMl86X+8aPOl/vGovwo/CgVyXzpf7xpPOl/vGoqKBXJfOl/vGjzpf7xqKigLkvnS/3jR50v8AeNRUUBdkvnS/3jR50v8AeNRUlAXZL50v940edL/eNRUUCuS+dL/fNHnS/wB81FRQFyXzpf75o8+b+8aiooC7JfPm/vGjz5v7xqKkoC7JvPm/vGjz5v7xqGigXMyXz5v75o8+b++aiooC7JfPm/vGjz5v7xqKj8KAuyXz5v7xo8+b+8ahophdk3nzf3jR583981DRQF2TefN/fNHnzf3zUNFAXZL583940efN/eNRfhSU7Bdk3nzf3jR583941DRRYLsm8+b++aPPm/vmoaKdguybz5v75o8+b++ahoosF2TfaJv7xo+0Tf3jUNJRYLsm+0Tf3zR9om/vmoaKdguyb7RN/fNH2ib++ahoosF2TfaJv75o+0Tf3zUNFFguyb7RN/fNH2ib++ahoosF2TfaJv75o+0Tf3zUNJRYLsn+0T/3zR9om/vmoKKLBdk/2ib++aT7RP8A3zUNFFguyb7RN/fNH2ib++ahoosF2TfaJv75o+0T/wB81DRRYLsm+0T/AN80faJ/75qH8KPwosF2TfaJ/wC+aPtE/wDfNQYoosFyf7RP/fNH2if++agoosFyb7RP/fNH2if++ahoosFyb7RP/fNH2if++ahoosFyb7RP/fNH2if++ahxR+FFguTfaJ/75o+0T/3zUP4UlFguT/aJ/wC+aPtE/wDfNQUUWC5P9on/AL5pPtE/981DRRYLk32if++aPtE/981DRRYLk32if++aPtE/981DRRYLk32if++aPtE/981DRRYLsm+0T/3zR9on/vmoKKLBdk/2mf8Avmj7TP8A3zUFFFguyf7RP/fNH2mf++agoosHMyf7TP8A3zR9pn/vmoKKLBzMm+0z/wB80faZ/wC+ahoosLmZN9pn/vmj7TP/AHzUNJRYXMyf7TP/AHzR9pn/AL5qCiiwczJ/tM/980faZ/75qCiiwczJ/tM/980faZ/75qCiiwczJ/tM/wDfNH2mf++agoosLmfcn+0z/wB80faZ/wC+ago/CiwczJ/tNx/fNH2m4/vmoPwpKLBzMn+03H980fabj++agoxRYOZk/wBpuP75o+03H981BRRYOZk/2m4/vmj7Tcf3zUGKKLBzMn+03H980fabj++agoosHMyf7Tcf3zR9puP75qD8KPwosHMyf7Tcf3zSfabj++agoosHMyf7Tcf3zR9puP75qCiiwczJ/tNx/fNH2m4/vmoKKLBzMn+0z/3zR9pn/vmoKKLBzMn+0z/3zSfaZ/75qGiiwczJvtM/980faZ/75qGkosHMyf7TP/fNH2mf++agoosHMyf7TP8A3zR9pn/vmoKKLBzMn+0z/wB80faZ/wC+agoosHMyf7TP/fNH2mf++agoosHMyf7TP/fNJ9pn/vmoaKLBzMm+0z/3zR9pn/vmoKKLBzMn+0z/AN80faZ/75qCiiwczJ/tM/8AfNH2mf8AvmoKKLBzMn+0z/3zR9pn/vmoKKLBzMn+0z/3zR9pn/vmoKPwosHMyf7TP/fNJ9on/vmofwpMUWFzMn+0T/3zR9pn/vmoKKLBdk/2mf8Avmj7Rcf3zUFFFguyf7TP/fNH2if++agoxRYLsn+0T/3zSfaJ/wC+ahoosF2TfaJ/75o+0T/3zUP4UfhRYLsm+0T/AN80faJ/75qCiiwXZP8AaZ/75o+0z/3zUFFFguyf7Tcf3zR9pn/vmoKKLBdk/wBpn/vmj7TP/fNQUUWDmZP9puP75o+03H981BRRYOZk/wBpuP75o+03H981BSUWHzMsfabj++aT7Tcf3zUFFFg5mT/abj++aPtNx/fNQUUWDmZP9puP75o+03H981BRRYXMyf7Tcf3zR9pn/wCeh/OoKKLBzMn+03H98/nR9puP75/OoKKLBdk/2m4/vn86PtNx/fP51BSUWC7J/tNx/wA9D+dH2m4/56H86gooC7J/tNx/z0P50fabj/nofzqCigLsn+03H/PQ/nR9puP+eh/OoKKAuyf7Tcf89D+dH2m4/wCeh/OoKKAuyf7Tcf8APQ/nR9puP+eh/OoPwo/CgLsn+03H/PQ/nSfabj/nofzqDFFAXZP9puP+eh/Oj7Tcf89D+dQUUBdk/wBpuP8Ano350fabj/no351BRQF2T/abj/no350fabj/AJ6N+dQUUBdk/wBpuP8Ano350fabj/no351Bij8KAuyf7Tcf32/Oj7Tcf32/OoPwpKAuyx9puP77fnR9puP+ejfnVeigLssfabj/AJ6N+dJ9puP+ejfnUFFAXZP9quP+eh/Oj7Vcf89D+dQUUBdk/wBquP8Anofzo+1XH/PRvzqCigLsn+1XH/PRvzo+1XH/AD0b86gooC7J/tVx/wA9G/Oj7Vc/89GqvRQFLine truncated

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="720" height="450" viewBox="0 0 720 450"><rect width="720" height="450" rx="12" fill="#edf2f4"/><image x="8" y="8" width="704" height="434" preserveAspectRatio="xMidYMid meet" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABaAAAAOYCAIAAACsO8NlAAAQAElEQVR4nOydBYAcx7Gwe/budKgTnFgnZpZskW1ZtsCWQbZklhnisB0nL/CCf/KS98J5iZ+dxE7MzCSzLFnMzHySDsR0jNt/7fZAz3T37Mzu7IFUn8+lutnZaZy5qeqamtQzVVWaRhiUUs34hemUEthg6DEk25XXY0gCZZl6FF5nnzYWrDYq3ZdsgtqTOBrAet6p63MgusWpE40SY8RFXZDs0Lyu7qHG7q2YJH0+BFiAv0oYhfG6umA2mirdlzSqYOnNcNy9wE4XXvclbZe9+CaQW7e5TyxeT1S6zge2n0pvuhmQhC7R54OpM1Wle7w+NIeG+by4sPHl9XiO0rTXySAGJsFq+iy2WXWQrdaS6yTbo1lcJxuPxEZF/zavx3kkX5JdsAiR6zEvaqpD2g/vPExjXCebHm+jyP6SSnT2r/O+2tm5dt2fTHhgvA1SgrPZT1HNGt2C1q+N/u3rxuoVj+MVuwa+B97+x8PPTNHOVleRpiYpE108qKQAuZEf10XB9S+zp4kY/A1iCz35bcPlf4gU3a8+kKfxSkQyfI9M4OdFY8wHT+edP+n291q/jLn8LdYrodCTKgMegaTPh4DGqzG7x35I5U2hu+NV5oQNtjEJjbunfgjsOtlo0zlBqPRCE4fj1ZtTPjkTV6UnLP3ZLx5a2Gzw13GBzgeGyvgPbOySf50MoAqNQvAniWFMRsdU1C3Jzhen3gKvk3Ka33xQjHWQjhuJdBlryXVSuGYmoR+CQTl2StPH2w1EnCeA/3oGNN+UDg7lpUXZD2LEB99xfiI7fN+Ric0PbMop+6FZXBQSwGdjjGgOuS6XiogP+01DfD3XLHq0UedDgBPRX+WMwnjdXnDCkR2q+SD2UHM5e6QohyVegzPhsZPp8bdG0jKpPKcifRI477zej7FdBJ2RtJYn0DCfUpgP7DO36J4Wc530U9EEqxPr8B5p+g5SLuS0yOtk/CR/PriPddwle7vYWbriAqe1iOtk45HYqOjf5nW3I7mNl18ZY3xb5nWyabC1RR3Z4cmmTmavxDFGXg/k46Ayb41dNosIDhWBT/RAO1rp6fF9gSDCX2z7BA38BrFFn/y87mlYvHazrxPJflIFcLflY/S4G8TknxfJJoELXDx/l/XLHndj5LxJSqBCSRhrvyOT9FJbSPf4P7zeGIXe3Mfdfbgk0tufr4Qub0lslm9iRffwetyRHcnslIDOuwRtFj81bibIO65RI31YJ/J6IJeNIKZY4EU1G/ydMMqx9hbxYTc4FSdP0JeEZNBy5oNifANy3LgmZGC7qCI+2CJuo/VDbDyNXexThCR8o8BQzhRPVZB+U412pqrSmXdDr39y8m7YOoVVQaE37vTw2LnN4MR2rbSPBsjvcMUcHDEjMuL/w990PReTpM+HhMYukQr5KzjQ6AyxV5rduKvQ/1ZyuvQ6792HmdBkarqJ5Tof2H5ueuzqJ5uAusTt3oltdr2/ZQTaD418QeHngN4ajw73FnKd9FI5t2omWAXPNM24qzrL5nwPYt2nEcYuGQQ3Ek00vqJThtd9Hcau+7pOJnBeNC/8j5zSCeu1o+WdnoB3UxyMlnSdbF7IbG3WzX7ybiTfXo5jvAI6EBUWld3+MKjuS5ssgiPpE516+bsgN/LjuijE6c1Shmuebyc8P0RBGJMxThj3kyfOO6wYY63Q1f2gmrbNeT4E1ACvf6+J/O8vJ/VK8Ho8FUryuCe5O9U1CLCAZHaPt2p6Wpln24JYjQ94rL2g7Id4r5M+LnWN1ERPGDc0cj0Jq/FBd4S3CZ3gNdPPdTLuljQysTsuUKc836GsIC0Rg78xLy0JTis/RQWI+/jyuldpmw/+c3AY10k+OiMp45Vsmvd8UIx1kI4b28Itp1tjbVwnFTk4mt2Q2vA0ji4fqG4gjBPA541CjHrGrDMjwX72kYND1g9KLxTbidODkMqOFrsisOnXvC8KPlFcQ7w0RpZ3Q+JxVEV86DcNnJ5YLzZZjzbZfEhg7BLrZnnBSbmJbMbjrsLbdTK4vx3JPV3cJ5mklcQ+BwKaD/E3IHh8nmvx3IOx3QW90RvmuZEeZAKRPvLp3Dya5eFci1WdBItKmEbuIH4O6J1lG3dmYMS6TnpYwAhi7ALtaI+9n8z50Ihj7bzwsYKUFzg27i3hOplcghsVn2MdpONAMTB+zrXGPS+aF7b6x7CpveXdsJ0YWlAXuDjGyGfjvUijFryuPlCzy8Hh8zQNbqL7LtibBROv40a8QeR0VlRybhCbGZ6GJSnGpPqg4oGSf+cl3iBK+iHeozclPs+7eP4u65c6+Y2R0A8+KxSkFGukHKUAaxmjpOQVHET3WPfSiVZH/zavN2XDvI2GxyGisS5pCVwnG61ZCfaDp+iexCI74usIb51C4z/vEnS6+allM8TqrEaN9BGNfF4P8OLh/7zzOX1iH75ZEvuE8eSU95p3w6fB72e8kk3LnA+K8U3AceP2eL4qB4c+vFzejebhdPM9dsqdArxRsErzW0/xKB7Rc3AEn2vD1nixYY1+OhgE/rcjuRVV6T6k/K42jrefnMMr8EmcDwn+9fAyvqwAue5VxrUCz75vrbrbV+BbJPrfRE6nsmu7H1+lzwnk9RQJ7ALhfz5Y4y7TG/dsT243uN4jsc2u97FJ6IckN9htPuit8ehYT/Q6GXizxEr4H5gEq+ah2KYZX78d1HKuk8kluPnQiOPuvKh5uJD5PWTC9n4LxdvIuTtb+fngoaPlne7Ti8nrDK0pr5PnznzwltdSaV/bLpSMpPRKHOMV0IFojMVjtfRwX0qSHsGR9IlOVX8X5IZ9XBcCv3+ZGTY9wVX3c+iET+jGSByKGCeM+8nj4w7L+8iodHs/JPu8SDYBNcD/jZH7DUHCFUrCWMfXnXG3JOkFxNc9ifZDnCvwzXCsPfWDcJ3k9eD/ZDVxc5WIq+68nsQV+KA6IqDzzs2ukV8nAz81k4R7B/F6RLpGtiYyHxhGxzl13+OV7CkW0LRqwvkQTAuokF+D12OvwHN6i75O8jTv+aAYd7cLXJzSGZGhXxsVenM5L2LjaRxdPlAZXXGeADHqGbPOjET7WVGAdqa6StEPgueJ14N5i4rQoUpPVdKnWRwXguZ1MiiuG34bg7k2+G7j9UaaDwkW7K9yRmG8bi840JvIFjDuKpTD4s2PKl7qGv1CE8wFosVH+gR2nfR/D8Z25/7E2f7cNfMGxzMfrHGX6UFP58ClWDl1RROsTqzDx90pSe0gSQtoYrk2knidTLhzfRFg73suIZDSvFzsuAuZRA/yOileM1sowY2Qz3EP0nHgOjDezrXknxfNF1vbJbaVrittavHiqLSXA65zAGPhe4CN3uJ19YFU95/n7ltUvBes694slYQcN6qBYXi9KTxnTnge5XzwPyy+TyRVAX7GMYGZa9PPqUgf5bkWuwHx/F0mbn9/g6xcEsZdNWIB1tIfjdw9MbohjurECP31s5KfxPH1i7If4r1OtlBjUj0faHyRPkHMhyA6wtvkTt51MvlDlxSaLNKHmlEeMj3BCZXYFPM5lWIX1aSoz/hYrRGjPCifU0OWX8O+iMuuk/xbVOIaX0bTdW7LnA+KcU/AceM114Y+1IpcG83jvPA9dsqd1DcNxgng+UbBKs1vPcWjxN8prBIuOTiUniTfhr3Y4EadHi3yxFac194qLb/blUVneMvBEeeNoNhzzeKykPT5kNDY+ZVGAXLdTca1Gs/PB1aUPcS3GYyvX2xD1AiGIiPR0yL4SeZ5PrDvqKIzGncGJOFca36GYqNeUNTzQT7uwUzzRmiWvwoFVjWfxTbNWHupKQ36OpnYPXOTkZz5kORxdzMIFRcy/4dM8DrZPIY3IbyNh+hw53XXe2yfxrzXwQh6YII7L1oe+hDpOubdcJeO9sb4god7Udv8aaQcHHFLs6I+OlfX/f8FDuwvs1hrr1EY8fdDCyHBYfHQ/eoC/JxIAY2ScsQCL6kxUJ5r8TfG/9/iGDcHyZRirwc2GkmfD0kYuwSmfxz94Gllnm0LOjojiIb5JPnXyUQmU+MhW42XjHtyojMY/jslCeeam10jv0422yF14K+zEnbK8/OBYe8hSgMfu2ScawlWLd5iAySYk0SSdyNWRIZx7qiiM5J+eUgGLWc+KMY9UceNLt2iM/Rro1sOjkbsB394GkejOy2d+rmBCGKmxD334u8U14LjycGh9DwF5jgQOzEpU67lXBQ8oLhueGmM/dqifDYsOXk33Hu0UXu3yeZDggXHX1HxisiqEOBNZDMday94GpZG8Kcxgp9kMVpG7HMggfnAqqyK+mlc4u+GRO/BGKpFsubY4ETnA/tO7Egfr/0QfFMSOu8SrEKsw8dBI3cQPwf0FihX1fxcJ5NyzWxckj8fkjzW4sXO0pnq6lEL9jrJmwUtk+Cms89xD8hx4DbuXOUkerL6oWVgq7/tuqe0rfw7IAKrp6TOiZSa0GUp9k5e7j+bOAdHE0x05fVB/Rc4MMeNapDY75rH0N8WfcKrcJnnXoYlVjd7LoBNCP4EC+Buy/vonVuRPuK55lnSxP7+MoQbI+XJH39FAxp31YgFXlL8Yxd49/johjiq0GwjfRJC2Q9Jv04mY5IloR/s4y7ogUR2BD3uCZxrNA77he1+LhiQVmfJIn0Cd8qrBoAVK9MbLine truncated

After

Width:  |  Height:  |  Size: 270 KiB

+63
View File
@@ -0,0 +1,63 @@
# Agent Connector SDK
Meldwork 的 Connector 包是经校验的本地 JSON 文件。包通过内容哈希标识,导入后必须经用户批准和安装,才能配置实例。每个实例独立保存 CredentialRef;凭据由 Electron 的系统安全存储加密,Connector 列表不会返回凭据明文。
使用实例凭据的 Codex 委托 Connector 在每个实例独立的私有原生配置目录中运行,不读取全局 Codex 或 XDG 配置。实例密钥通过 Codex 原生 `CODEX_API_KEY` 环境变量传入,不写入该目录;目录保留会话数据,以支持实例自己的续聊。
## 当前支持范围
SDK 版本为 `1.0.0`,目前提供两个内置 Provider:
- `sdk.local-echo.v1`:本地回显,仅支持文本、只读,无需网络或凭据。
- `sdk.http-json.v1`:向清单声明的 HTTPS 目标发送 JSON 请求,支持文本和只读权限,可选一个必填的 API key 或 token 凭据槽;不跟随 HTTP 重定向。
HTTP 响应为 JSON 对象,必须包含字符串 `text`,可包含字符串 `sessionRef` 和 `outcome`。`outcome` 可为 `completed`、`partial`、`failed` 或 `cancelled`;省略时视为 `completed`。如果响应同时包含非空 `error` 或 `success: false`,则视为失败。失败结果使用通用错误码,避免把服务返回的敏感信息写入运行记录。取消结果要求清单声明取消能力。
HTTP 请求有十分钟传输超时。对于未声明取消能力的 Connector,停止任务会中止本地连接,但不会声称远端执行已取消。
第三方可以为这些 Provider 制作包,无需重建 Meldwork;**当前尚不能通过包注册任意第三方可执行代码或新的传输实现**。清单本身支持的能力不代表每个 Provider 都实现了该能力,包校验会拒绝超出 Provider 范围的声明。参见 [包校验实现](src/agents/connectors/agent-connector-package-store.cjs) 和 [端到端示例测试](test/agents/connectors/agent-connector-sdk.test.cjs)。
这两个 Provider 当前不支持检查点或持久幂等,也不产生权限、来源、产物、证据、用量或等待输入事件;包含这些能力声明的包会被拒绝导入。HTTP 请求会携带 `operationId`,但这本身不构成服务端持久去重的保证。
## 生成本地示例包
在仓库根目录安装桌面依赖后,执行以下命令生成示例,不需要提供凭据:
```sh
node - <<'NODE'
const fs = require('node:fs')
const { SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_PACKAGE } = require('./desktop/src/agents/connectors/agent-connector-local.cjs')
const { serializeAgentConnectorPackage } = require('./desktop/src/agents/connectors/agent-connector-package-store.cjs')
fs.writeFileSync('/tmp/meldwork-local-echo.connector.json', serializeAgentConnectorPackage(SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_PACKAGE))
NODE
```
该命令输出到 macOS/Linux 的临时目录;Windows 用户可将目标改为自己可写的绝对路径。HTTP 包的完整构造示例见上述 `agent-connector-sdk.test.cjs` 中的 `httpPackage()`;应使用 `createAgentConnectorManifest` 和 `createAgentConnectorPackage` 生成哈希,而不是手工修改已有包的标识。
## 导入与生命周期
桌面主进程和预加载层提供 `localAgentConnector` 接口:`import`、`inspect`、`approve`、`install`、`configure`、`test`、`disable`、`revoke`、`upgrade`、`remove` 和 `audit`。导入通过系统文件选择器完成;批准前会展示发布者、来源、哈希、传输、权限、凭据槽和外联目标,并要求用户确认。如果包在确认期间发生变化,需要重新查看和批准。
已有实例时,同一声明发布者和 Provider 的新版本可以在凭据声明完全一致的情况下升级;实例 ID 和 CredentialRef 会保留。如果升级后的实例写入中断,Meldwork 会依据持久化的升级记录在后续刷新时恢复;旧包已撤销时不会自动迁移。凭据声明不兼容、有请求正在运行,或先前迁移尚未完成时,升级会被阻止。发布者 ID 是包内声明,并不构成身份认证。存在实例的包不能直接删除;被会话引用的实例也不能直接删除。
设置页面的“Connector 管理”提供导入、查看、批准、安装、测试和信任管理入口,不向网页提供任意命令执行能力。创建带凭据的实例时,填写本机 Shell 中已有的环境变量名;经本机确认后,主进程读取变量并使用系统安全存储保存其值,页面不会收到密钥。
预加载接口的 `configure` 仅接受 `credentials: null`;带凭据的实例使用 `configureFromEnvironment` 提交变量名,不能通过渲染进程提交密钥值。
`test` 执行一次只读提示,在独立的临时工作目录运行,并在结束后清理。它是连通性检查,不是对 Provider 所有功能和安全行为的完整认证。
## 能力与事件
清单声明的输入类型、权限、会话、取消、事件类型和用量字段在运行时受约束。对于 SDK 的 HTTP 传输,目标必须与声明的 HTTPS origin 匹配,且不会跟随重定向。委托给本地 Agent CLI 的 Connector 不提供进程级网络隔离:CLI 自行发出的网络请求不受 Connector 外联目标清单强制限制,不能把该清单视为这类 Connector 的完整网络边界。
`Usage` 只统计事件实际报告的字段;缺失的回复 token 用量由运行预算估算,返回值不得伪造未声明或与事件不一致的用量。
事件及其必填字段以 [Run Event 协议](src/runs/run-event-protocol.cjs) 和 [Manifest 校验](src/agents/connectors/agent-connector-manifest.cjs) 为准。不要把 API key 放入清单、请求正文或示例包。
## 验证
```sh
npm --prefix desktop run connector:conformance
```
该命令运行 Manifest、注册表、运行时、实例、安装包、SDK、实际 Connector 接线和主进程安全测试,包括非委托的本地回展示例、多账号凭据隔离以及渲染进程边界。它验证当前内置 Provider 的行为,不代表第三方服务已经通过独立认证。
+43 -18
View File
@@ -13,12 +13,18 @@ API Key 或任意 Shell 执行能力。
- Hermes
- OpenClaw
- WorkBuddy
- Zcode
- Pi Agent
- Kimi Code
- MiMo Code
- DeepSeek Harness
- Claude Code
- Gemini CLI
- OpenCode
- Qwen Code
- OpenCodeReview
Connector 包的当前支持范围、示例与验证方法见 [Agent Connector SDK](AGENT_CONNECTOR_SDK.md)。
Agent 管理面板提供经过白名单约束的安装入口。安装命令和下载地址由主进程固定,不会根据页面
输入执行任意命令。内置 Agent 通过各自支持的原生权限模式或 Meldwork 托管工具策略执行;无法
@@ -28,7 +34,7 @@ Agent 管理面板提供经过白名单约束的安装入口。安装命令和
## 首次启动与本地 Agent 检测
V1.0.3 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
V1.0.7 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
如果 macOS 首次启动时拦截候选应用,请打开
“系统设置 → 隐私与安全性”,找到 Meldwork 的拦截提示,点击“仍要打开 / Open Anyway”,
再确认启动。
@@ -66,34 +72,49 @@ Agent 原生支持的图片会按其数量限制直接传递。其他文件,
## Provider 配置
用户可以在客户端内填写一个 OpenAI-compatible Provider:
Agent 使用各自本机的原生鉴权、Provider 和默认模型配置。Meldwork 不要求统一 Provider,
也不会将一个 Agent 的密钥注入其他 Agent。WorkBuddy 桌面账号的加密登录不能直接供独立
CLI 解锁;可在 Meldwork 中选择桌面配置里已有且已配置鉴权的模型。
Meldwork 仅保存模型标识,运行时由主进程读取原生配置,不向渲染层返回密钥。
Zcode 沿用默认配置;若其默认 Provider 缺少 API Key,需要先修复本机原生配置。
```text
provider: Provider 显示名称
baseUrl: HTTPS 地址,或本机 loopback HTTP 地址
model: 模型名称
apiKey: API Key
```
Hermes 的原生配置目录设置会显示 `config.yaml` 中显式指定的 Provider(包括嵌套模型及旧版根级配置),或配置中没有指定时的
`HERMES_INFERENCE_PROVIDER`。显示 `auto` 表示交由 Hermes 自动选择,并不代表已经确认最终路由;
无法读取或没有明确配置时显示未指定。此摘要不返回凭据。
Codex 设置显示用户配置(含所选 profile)中的 `model_provider`;项目配置或运行参数仍可覆盖它,
因此该值不等同于每次调用的最终 Provider。
API Key 仅通过 Electron `safeStorage` 加密保存到本机;操作系统安全存储不可用时拒绝落盘。
Provider 不设品牌、服务地址或模型默认值,也不会从远端自动获取配置。配置后可供 Hermes、
OpenClaw、WorkBuddy、Qwen 和 OpenCodeReview 使用;其他 Agent 沿用各自的本机鉴权和 Provider 配置。
DeepSeek Harness 使用 `dsh` 的 ACP profile,未指定原生 ACP Provider 或模型时默认连接 ZGCI 的 `smart-router-v2`;
若 ACP profile 或 DSH home 的 `cordis.patch.yml` 已指定 Provider 或模型,则沿用原生配置及其声明的凭据环境变量。
可在 DeepSeek Harness
的原生凭据服务中配置 `ZGCI_API_KEY`,或在启动 Meldwork 的环境中提供该变量;
Meldwork 不保存该密钥。默认只读,授权工作区写入后才允许修改工作区;权限模式改变时会建立
新的原生会话并由 Meldwork 补充对话上下文。需要超出所选工作区权限的原生升级请求会被拒绝。
客户端「设置 → Agent → DeepSeek Harness → 模型与使用说明」列出默认模型、凭据配置和能力范围。
配置凭据后点击刷新,再打开私聊或将其加入群聊。私聊与群聊分别保留原生会话;群聊可选择并发回复、
顺序讨论或自动讨论。文件附件通过受控暂存路径提供,本地 Skill 可从输入框选择;当前没有原生图片输入。
图标取自官方 macOS 客户端的 `Contents/Resources/icon.png`,资源来源记录在
`frontend/public/agent-logos/deepseek-source.md`。
## 本地 CLI 支持矩阵
| Agent | 检测命令 | macOS 安装 | Windows 安装 | 用户 Provider |
| --- | --- | --- | --- | --- |
| Codex | `codex` | npm:`@openai/codex@latest` | npm:`@openai/codex@latest` | 不注入;沿用 Codex 本机配置 |
| Hermes | `hermes` | 官方 `install.sh` | 官方 `install.ps1` | 支持 |
| OpenClaw | `openclaw` | npm:`openclaw@latest` | npm:`openclaw@latest` | 支持,使用客户端隔离的托管配置 |
| WorkBuddy | `codebuddy` | npm:`@tencent-ai/[email protected]` | npm:`@tencent-ai/[email protected]` | 实验性支持 |
| Hermes | `hermes` | 官方 `install.sh` | 官方 `install.ps1` | 沿用本机配置 |
| OpenClaw | `openclaw` | npm:`openclaw@latest` | npm:`openclaw@latest` | 沿用本机配置 |
| WorkBuddy | `codebuddy` | npm:`@tencent-ai/[email protected]` | npm:`@tencent-ai/[email protected]` | 可选择桌面已配置模型 |
| Zcode | `zcode` | 官方 CLI 安装 | 官方 CLI 安装 | 沿用默认配置 |
| Pi Agent | `pi` | npm:`@mariozechner/pi-coding-agent` | npm:`@mariozechner/pi-coding-agent` | 沿用本机配置 |
| Kimi Code | `kimi` | 官方 `install.sh` | 官方 `install.ps1` | 不注入;沿用 Kimi 本机配置 |
| MiMo Code | `mimo` | npm:`@mimo-ai/cli@latest` | npm:`@mimo-ai/cli@latest` | 不注入;沿用 MiMo 本机配置,Meldwork 固定使用只读 `plan` Agent |
| MiMo Code | `mimo` | npm:`@mimo-ai/cli@latest` | npm:`@mimo-ai/cli@latest` | 不注入;沿用 MiMo 本机配置,按授权使用 `plan` / `build` 模式 |
| DeepSeek Harness | `dsh` | npm:`@deepseek-ai/[email protected]` | npm:`@deepseek-ai/[email protected]` | ZGCI `smart-router-v2`,使用原生凭据或环境变量 |
| Claude Code | `claude` | npm:`@anthropic-ai/claude-code@latest` | npm:`@anthropic-ai/claude-code@latest` | 不注入;沿用 Claude 本机配置 |
| Gemini CLI | `gemini` | npm:`@google/gemini-cli@latest` | npm:`@google/gemini-cli@latest` | 不注入;沿用 Gemini 本机配置 |
| OpenCode | `opencode` | npm:`opencode-ai@latest` | npm:`opencode-ai@latest` | 不注入;沿用 OpenCode 本机配置 |
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 支持 |
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 支持 |
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 沿用本机配置 |
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 沿用本机配置 |
macOS 会在当前 `PATH` 之外搜索 Volta、pnpm、fnm、asdf、mise、bun、
`~/.local/bin`、`~/.kimi-code/bin`、`~/.mimocode/bin`、Homebrew 等常见目录。Windows 会额外搜索用户 npm、
@@ -130,6 +151,10 @@ npm run dev
## 测试与打包
任务图的 `primary` 节点可以设置可选的 `claimKey`:相同键的结论会进行冲突检查,不同键适合独立交付物。未设置时沿用共享结论的检查方式;该字段是工作流输入的一部分,不会自动推断任务含义。
当前默认的 V4 群聊尚不支持任务图。旧版 V3 任务图的审查节点可能把指出问题的回复判为已通过;在此问题修复前,请人工检查审查内容,不要仅依据节点完成状态判断结果。
```bash
npm test
npm run pack
@@ -137,7 +162,7 @@ npm run dist
npm run dist:public
```
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.3 GitHub 预发布候选使用的 ad-hoc 签名 DMG 和 ZIP。这些产物不具备 Developer ID 信任链且未公证,不能声称通过 Gatekeeper。
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.7 GitHub 预发布候选使用的 ad-hoc 签名 DMG 和 ZIP。这些产物不具备 Developer ID 信任链且未公证,不能声称通过 Gatekeeper。
`dist:public` 是未来正式签名公开发行的专用命令:它要求
Developer ID 证书和完整公证凭据,强制代码签名,在签名后校验永久 Bundle ID、Team ID 与
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+15 -9
View File
@@ -1,16 +1,19 @@
{
"name": "meldwork-desktop",
"version": "0.1.3",
"version": "1.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meldwork-desktop",
"version": "0.1.3",
"license": "SEE LICENSE IN LICENSE",
"version": "1.0.9",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.23.0",
"@iarna/toml": "^2.2.5",
"@streamparser/json": "0.0.22",
"js-yaml": "^4.3.2",
"json5": "^2.2.3",
"zod": "4.4.3"
},
"devDependencies": {
@@ -459,6 +462,12 @@
"node": ">= 10.0.0"
}
},
"node_modules/@iarna/toml": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
"license": "ISC"
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -983,7 +992,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/asn1js": {
@@ -2623,10 +2631,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
@@ -2671,7 +2678,6 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
+24 -11
View File
@@ -1,10 +1,10 @@
{
"name": "meldwork-desktop",
"version": "0.1.3",
"version": "1.0.9",
"private": true,
"description": "A local-first desktop workspace for multi-Agent collaboration",
"author": "Ryder-MHumble",
"license": "SEE LICENSE IN LICENSE",
"license": "Apache-2.0",
"engines": {
"node": ">=22.12.0"
},
@@ -12,7 +12,7 @@
"scripts": {
"build:frontend": "npm --prefix ../frontend run build:desktop",
"dev": "npm run build:frontend && electron .",
"connector:conformance": "node --test test/agents/connectors/agent-connector-manifest.test.cjs test/agents/connectors/agent-connector-registry.test.cjs test/agents/connectors/agent-connector-runtime.test.cjs test/agents/connectors/agent-connector-instance-store.test.cjs test/agents/connectors/agent-connector-package-store.test.cjs test/agents/connectors/agent-connector-sdk.test.cjs",
"connector:conformance": "node --test test/agents/connectors/agent-connector-manifest.test.cjs test/agents/connectors/agent-connector-registry.test.cjs test/agents/connectors/agent-connector-runtime.test.cjs test/agents/connectors/agent-connector-instance-store.test.cjs test/agents/connectors/agent-connector-package-store.test.cjs test/agents/connectors/agent-connector-sdk.test.cjs test/agents/connectors/agent-connector-local.test.cjs test/security/main-security.test.cjs test/security/preload-security.test.cjs",
"eval:deterministic": "node scripts/run-evals.cjs --suite deterministic",
"eval:provider": "node scripts/run-evals.cjs --suite provider",
"test": "node scripts/run-tests.cjs",
@@ -26,7 +26,10 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "0.23.0",
"@iarna/toml": "^2.2.5",
"@streamparser/json": "0.0.22",
"js-yaml": "^4.3.2",
"json5": "^2.2.3",
"zod": "4.4.3"
},
"devDependencies": {
@@ -38,6 +41,10 @@
"productName": "Meldwork",
"artifactName": "Meldwork-${version}-${arch}.${ext}",
"afterPack": "scripts/after-pack.cjs",
"afterAllArtifactBuild": "scripts/after-all-artifact-build.cjs",
"asarUnpack": [
"src/agents/cli/deepseek-*.yml"
],
"files": [
"src/**/*",
"samples/**/*",
@@ -59,6 +66,10 @@
{
"from": "../COMMERCIAL_USE.md",
"to": "COMMERCIAL_USE.md"
},
{
"from": "build/dmg-background-ai-v4.tiff",
"to": "dmg-background.tiff"
}
],
"mac": {
@@ -70,21 +81,23 @@
]
},
"dmg": {
"background": "build/dmg-background-ai-v2.png",
"background": "build/dmg-background-ai-v4.png",
"icon": null,
"writeUpdateInfo": false,
"window": {
"width": 540,
"height": 380
"width": 640,
"height": 420
},
"iconSize": 128,
"iconSize": 112,
"contents": [
{
"x": 145,
"y": 210,
"x": 110,
"y": 205,
"type": "file"
},
{
"x": 395,
"y": 210,
"x": 520,
"y": 205,
"type": "link",
"path": "/Applications"
}
+1 -1
View File
@@ -1 +1 @@
{"manifest":{"connectorId":"external.local-echo-sample","connectorVersion":"1.0.0","credentials":{"mode":"none","slots":[]},"description":"Local SDK sample that returns text without delegating to a built-in Agent.","domains":["general"],"eventProtocolVersion":1,"eventTypes":["Completed","Failed","Cancelled"],"inputTypes":["text"],"invocation":{"recipeId":"sdk.local-echo.v1"},"kind":"agent","label":"Local Echo Connector Sample","license":"LicenseRef-Meldwork-NC","manifestId":"connector-manifest-6ce79bf9cbe282e9f537a1b45d742cc507e21ade5ea2859dff9e007bd4f850db","outboundDestinations":[],"permissionModes":["read-only"],"recordType":"agent-connector-manifest","schemaVersion":1,"session":{"cancel":true,"checkpoint":false,"resume":true,"supported":true},"transport":{"protocol":"json","type":"cli"},"upstream":{"id":"meldwork-sdk","maxVersion":"1.0.0","minVersion":"1.0.0"},"usage":{"costMicros":false,"elapsedMs":false,"inputTokens":false,"outboundBytes":false,"outputTokens":false,"toolCalls":false}},"packageId":"connector-package-f4908cee8fa7acf5d17a403b39bd58cb8d4a1dfec3fbfb43c62c5727ee793cd4","provider":{"config":{},"id":"sdk.local-echo.v1"},"publisher":{"id":"meldwork","name":"Meldwork"},"recordType":"agent-connector-package","schemaVersion":1}
{"manifest":{"connectorId":"external.local-echo-sample","connectorVersion":"1.0.0","credentials":{"mode":"none","slots":[]},"description":"Local SDK sample that returns text without delegating to a built-in Agent.","domains":["general"],"eventProtocolVersion":1,"eventTypes":["Completed","Failed","Cancelled"],"inputTypes":["text"],"invocation":{"recipeId":"sdk.local-echo.v1"},"kind":"agent","label":"Local Echo Connector Sample","license":"Apache-2.0","manifestId":"connector-manifest-6ce79bf9cbe282e9f537a1b45d742cc507e21ade5ea2859dff9e007bd4f850db","outboundDestinations":[],"permissionModes":["read-only"],"recordType":"agent-connector-manifest","schemaVersion":1,"session":{"cancel":true,"checkpoint":false,"resume":true,"supported":true},"transport":{"protocol":"json","type":"cli"},"upstream":{"id":"meldwork-sdk","maxVersion":"1.0.0","minVersion":"1.0.0"},"usage":{"costMicros":false,"elapsedMs":false,"inputTokens":false,"outboundBytes":false,"outputTokens":false,"toolCalls":false}},"packageId":"connector-package-f4908cee8fa7acf5d17a403b39bd58cb8d4a1dfec3fbfb43c62c5727ee793cd4","provider":{"config":{},"id":"sdk.local-echo.v1"},"publisher":{"id":"meldwork","name":"Meldwork"},"recordType":"agent-connector-package","schemaVersion":1}
@@ -0,0 +1,126 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const ROOT_HIDDEN_RESOURCES = ['.background.tiff', '.VolumeIcon.icns']
function selectDmgArtifacts(artifactPaths) {
return artifactPaths.filter(artifactPath => artifactPath.endsWith('.dmg'))
}
function findBundledPython(cacheRoot = process.env.ELECTRON_BUILDER_CACHE) {
const root = cacheRoot || path.join(os.homedir(), 'Library', 'Caches', 'electron-builder')
const pending = [root]
while (pending.length > 0) {
const current = pending.pop()
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const candidate = path.join(current, entry.name)
if (entry.isDirectory()) {
pending.push(candidate)
} else if (
entry.isFile()
&& /^python3(?:\.\d+)?$/.test(entry.name)
&& path.basename(current) === 'bin'
&& candidate.includes(`${path.sep}dmg-builder@`)
) {
return candidate
}
}
}
throw new Error(`Unable to locate dmg-builder's bundled Python under ${root}`)
}
function parseAttachedVolume(plist, workDir, run = execFileSync) {
const plistPath = path.join(workDir, 'attach.plist')
fs.writeFileSync(plistPath, plist)
const parsed = JSON.parse(run('plutil', ['-convert', 'json', '-o', '-', plistPath], {
encoding: 'utf8',
}))
const mounted = parsed['system-entities'].find(entity => entity['mount-point'])
if (!mounted) {
throw new Error('hdiutil did not return a mounted DMG volume')
}
return { device: mounted['dev-entry'], mountPath: mounted['mount-point'] }
}
function repointDmgBackground(dmgPath, options = {}) {
const run = options.execFile || execFileSync
const pythonPath = options.pythonPath || findBundledPython(options.cacheRoot)
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-dmg-'))
const readWriteDmg = path.join(workDir, 'writable.dmg')
const repackedDmg = `${dmgPath}.repacked-${process.pid}.dmg`
let device
try {
run('hdiutil', ['convert', dmgPath, '-format', 'UDRW', '-o', readWriteDmg], {
stdio: 'pipe',
})
const attached = parseAttachedVolume(run('hdiutil', [
'attach', readWriteDmg, '-nobrowse', '-readwrite', '-plist',
], { encoding: 'utf8' }), workDir, run)
device = attached.device
const backgroundPath = path.join(
attached.mountPath,
'Meldwork.app',
'Contents',
'Resources',
'dmg-background.tiff',
)
if (!fs.existsSync(backgroundPath)) {
throw new Error(`Packaged DMG background is missing: ${backgroundPath}`)
}
run(pythonPath, [
path.join(__dirname, 'repoint-dmg-background.py'),
path.join(attached.mountPath, '.DS_Store'),
backgroundPath,
], { stdio: 'pipe' })
for (const resource of ROOT_HIDDEN_RESOURCES) {
fs.rmSync(path.join(attached.mountPath, resource), { force: true })
}
run('/usr/bin/SetFile', ['-a', 'c', attached.mountPath], { stdio: 'pipe' })
run('/bin/sync', [], { stdio: 'pipe' })
run('hdiutil', ['detach', device], { stdio: 'pipe' })
device = null
run('hdiutil', [
'convert', readWriteDmg, '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-o', repackedDmg,
], { stdio: 'pipe' })
fs.renameSync(repackedDmg, dmgPath)
} finally {
if (device) {
try {
run('hdiutil', ['detach', '-force', device], { stdio: 'pipe' })
} catch {}
}
fs.rmSync(repackedDmg, { force: true })
fs.rmSync(workDir, { recursive: true, force: true })
}
}
async function afterAllArtifactBuild(buildResult) {
for (const dmgPath of selectDmgArtifacts(buildResult.artifactPaths)) {
repointDmgBackground(dmgPath)
}
return []
}
afterAllArtifactBuild.ROOT_HIDDEN_RESOURCES = ROOT_HIDDEN_RESOURCES
afterAllArtifactBuild.selectDmgArtifacts = selectDmgArtifacts
afterAllArtifactBuild.findBundledPython = findBundledPython
afterAllArtifactBuild.parseAttachedVolume = parseAttachedVolume
afterAllArtifactBuild.repointDmgBackground = repointDmgBackground
module.exports = afterAllArtifactBuild
@@ -0,0 +1,72 @@
#!/usr/bin/env swift
import AppKit
import Foundation
let arguments = CommandLine.arguments
guard arguments.count == 3 else {
fputs("Usage: render-dmg-background.swift <source.png> <output.png>\n", stderr)
exit(1)
}
let sourceURL = URL(fileURLWithPath: arguments[1])
let outputURL = URL(fileURLWithPath: arguments[2])
guard let source = NSImage(contentsOf: sourceURL) else {
fputs("Unable to read source image\n", stderr)
exit(1)
}
guard let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: 1280,
pixelsHigh: 840,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
) else {
fputs("Unable to create output bitmap\n", stderr)
exit(1)
}
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
source.draw(
in: NSRect(x: 0, y: 0, width: 1280, height: 840),
from: NSRect(x: 0, y: 4, width: 1280, height: 840),
operation: .copy,
fraction: 1
)
let centered = NSMutableParagraphStyle()
centered.alignment = .center
let title = "INSTALL MELDWORK" as NSString
title.draw(
in: NSRect(x: 0, y: 712, width: 1280, height: 52),
withAttributes: [
.font: NSFont(name: "DIN Alternate Bold", size: 38) ?? NSFont.boldSystemFont(ofSize: 38),
.foregroundColor: NSColor(white: 0.96, alpha: 0.94),
.kern: 4.5,
.paragraphStyle: centered,
]
)
let explanation = "DRAG MELDWORK INTO APPLICATIONS / YOUR WORKSPACE STAYS LOCAL" as NSString
explanation.draw(
in: NSRect(x: 0, y: 675, width: 1280, height: 30),
withAttributes: [
.font: NSFont(name: "Menlo-Regular", size: 14) ?? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular),
.foregroundColor: NSColor(white: 0.82, alpha: 0.74),
.kern: 1.5,
.paragraphStyle: centered,
]
)
NSGraphicsContext.restoreGraphicsState()
guard let png = bitmap.representation(using: .png, properties: [:]) else {
fputs("Unable to encode PNG\n", stderr)
exit(1)
}
try png.write(to: outputURL)
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import sys
from ds_store import DSStore
from mac_alias import Alias
def main():
ds_store_path, background_path = sys.argv[1:3]
with DSStore.open(ds_store_path, "r+") as store:
icon_view = store["."]["icvp"]
icon_view["backgroundImageAlias"] = Alias.for_file(background_path).to_bytes()
store["."]["icvp"] = icon_view
if __name__ == "__main__":
main()
+1 -1
View File
@@ -5,7 +5,7 @@ const TEST_SUITES = Object.freeze({
agents: ['agents'],
attachments: ['attachments'],
collaboration: ['collaboration'],
core: ['channels', 'knowledge', 'media', 'providers', 'skills'],
core: ['channels', 'knowledge', 'media', 'skills'],
runs: ['runs'],
security: ['security', 'shell'],
workspace: ['workspace', 'gates'],
+61 -12
View File
@@ -1,13 +1,15 @@
const VERSION_PATTERN = /\bv?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z][0-9A-Za-z.-]*))?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?\b/
const ANSI_PATTERN = /\x1b\[[0-?]*[ -\/]*[@-~]/g
const ANSI_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -\/]*[@-~]/g
const AGENT_VERSION_IDENTITIES = Object.freeze({
codex: [/\bcodex(?:-cli)?\b/i],
hermes: [/\bhermes(?:\s+agent)?\b/i],
openclaw: [/\bopenclaw\b/i],
workbuddy: [/\b(?:workbuddy|codebuddy)\b/i],
zcode: [/\bzcode\b(?![-_])/i, /\bzcode-runtime\b/i],
pi: [/\bpi(?:\s+agent)?\b/i],
kimi: [/\bkimi(?:\s+code)?\b/i],
mimo: [/\bmimo(?:\s+code)?\b/i, /\bmimocode\b/i],
deepseek: [/\b(?:deepseek|dsh)\b/i],
claude: [/\bclaude(?:\s+code)?\b/i],
gemini: [/\bgemini(?:\s+cli)?\b/i],
opencode: [/\bopencode\b/i, /\bopen\s+code\b/i],
@@ -19,9 +21,11 @@ const AGENT_EXECUTABLE_NAMES = Object.freeze({
hermes: ['hermes'],
openclaw: ['openclaw'],
workbuddy: ['codebuddy'],
zcode: ['zcode'],
pi: ['pi', 'pi-agent', 'piagent'],
kimi: ['kimi'],
mimo: ['mimo'],
deepseek: ['dsh'],
claude: ['claude'],
gemini: ['gemini'],
opencode: ['opencode'],
@@ -56,8 +60,18 @@ const AGENT_COMPATIBILITY = Object.freeze({
'json', 'plan', 'acceptEdits',
]),
]),
zcode: exactProfile('0.16.1', [
probe('zcode-prompt', ['--help'], ['--prompt', '--json', '--mode', '--resume', 'plan', 'edit']),
]),
pi: exactProfile('0.84.2', [
probe('pi-cli', ['--help'], []),
probe('pi-cli', ['--help'], [
'--mode', '--print', '--session', '--tools', '--approve', '--no-approve', 'json',
]),
probe('pi-json', [
'--mode', 'json', '--print', '--no-approve', '--tools', 'read,grep,find,ls',
'--no-session', '--no-extensions', '--no-skills', '--no-prompt-templates',
'--no-themes', '--no-context-files', '--offline',
], [], piSessionEvent),
]),
kimi: profile('0.19.2', '0.32.0', [
probe('kimi-stream', ['--help'], [
@@ -70,6 +84,9 @@ const AGENT_COMPATIBILITY = Object.freeze({
'--pure', '--agent', '--format', '--dir', '--session', 'json',
]),
]),
deepseek: profile('0.2.1', '0.2.1', [
probe('deepseek-acp', ['--profile', 'acp', '--help'], ['ACP', 'stdio']),
], ['0.2.1-rc.2']),
claude: profile('2.1.165', '2.1.221', [
probe('claude-print', ['--help'], [
'--print', '--output-format', '--include-partial-messages', '--verbose',
@@ -99,11 +116,23 @@ const AGENT_COMPATIBILITY = Object.freeze({
]),
})
function probe(id, args, requiredText) {
function piSessionEvent(output) {
return String(output || '').split(/\r?\n/).some(line => {
try {
const event = JSON.parse(line)
return event?.type === 'session' && Number.isInteger(event.version)
&& event.version > 0 && typeof event.id === 'string' && event.id.length > 0
} catch { return false }
})
}
function probe(id, args, requiredText, validateOutput = null, timeout = null) {
return Object.freeze({
id,
args: Object.freeze([...args]),
requiredText: Object.freeze([...requiredText]),
validateOutput,
timeout,
})
}
@@ -161,12 +190,19 @@ function identifiedVersionCandidate(kind, lines) {
const identities = AGENT_VERSION_IDENTITIES[kind] || []
const candidates = []
for (const line of lines) {
const identityMatches = identities.map(identity => identity.exec(line)).filter(Boolean)
const identityMatches = identities.flatMap(identity => (
[...line.matchAll(new RegExp(identity.source, `${identity.flags.replace(/g/g, '')}g`))]
))
const versions = versionMatches(line)
const weighted = []
for (const identity of identityMatches) {
const weighted = []
const identityEnd = identity.index + identity[0].length
for (const version of versions) {
const beforeIdentity = version.end <= identity.index
if (beforeIdentity && line.slice(0, version.index).trim()) continue
const gap = beforeIdentity ? line.slice(version.end, identity.index)
: version.index >= identityEnd ? line.slice(identityEnd, version.index) : ''
if (!/^[\s:()[\]@_-]*(?:(?:version|ver)\.?[\s:()[\]@_-]*)?$/i.test(gap)) continue
const distance = version.end <= identity.index
? identity.index - version.end
: version.index >= identityEnd
@@ -174,13 +210,22 @@ function identifiedVersionCandidate(kind, lines) {
: 0
weighted.push({ ...version, distance })
}
}
const closestDistance = Math.min(...weighted.map(candidate => candidate.distance))
const closestVersions = [...new Set(weighted
.filter(candidate => candidate.distance === closestDistance)
.map(candidate => candidate.resolvedVersion))]
if (closestVersions.length === 1) {
candidates.push({ line, resolvedVersion: closestVersions[0] })
const closestDistance = Math.min(...weighted.map(candidate => candidate.distance))
const closestVersions = [...new Set(weighted
.filter(candidate => candidate.distance === closestDistance)
.map(candidate => candidate.resolvedVersion))]
if (closestVersions.length > 1) return null
const closest = weighted.filter(candidate => candidate.distance === closestDistance)
for (let index = 1; index < versions.length; index += 1) {
const left = versions[index - 1]
const right = versions[index]
if (closest.some(candidate => candidate.index === left.index || candidate.index === right.index)
&& /^\s*(?:or|\/)\s*$/i.test(line.slice(left.end, right.index))
&& left.resolvedVersion !== right.resolvedVersion) return null
}
if (closestVersions.length === 1) {
candidates.push({ line, resolvedVersion: closestVersions[0] })
}
}
}
const versions = [...new Set(candidates.map(candidate => candidate.resolvedVersion))]
@@ -199,6 +244,9 @@ function identifyAgentVersion(kind, outputs, options = {}) {
const lines = normalizedVersionLines(outputs)
const identified = identifiedVersionCandidate(kind, lines)
if (identified) return identified
// A bare version must not resolve contradictory or ambiguous product lines.
if (lines.some(line => (AGENT_VERSION_IDENTITIES[kind] || []).some(identity => identity.test(line))
&& versionMatches(line).length)) return null
if (!executableIdentifiesAgent(kind, options.executable)) return null
return uniqueVersionCandidate(lines.filter(line => (
@@ -261,6 +309,7 @@ module.exports = {
AGENT_COMPATIBILITY,
assessAgentVersion,
capabilityProbes,
compareReleaseVersions,
extractAgentVersion,
identifyAgentVersion,
}
@@ -0,0 +1,157 @@
const { nativeSecretEnvKeys } = require('./cli/native-secret-environment.cjs')
const { PI_BUILTIN_CREDENTIAL_KEYS, PI_BUILTIN_CONFIG_KEYS } = require('./cli/pi-provider-environment.cjs')
const { deepseekNativeConfig } = require('./cli/deepseek-native-config.cjs')
const { ocrSelectedProvider } = require('./cli/ocr-provider-environment.cjs')
const VERTEX_MODEL_REGION_KEY = /^VERTEX_REGION_[A-Z0-9_]{1,120}$/
const CREDENTIAL_ENV_KEYS = Object.freeze({
codex: ['CODEX_API_KEY'],
hermes: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY'],
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
workbuddy: ['CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN'],
zcode: ['ANTHROPIC_API_KEY', 'ZCODE_API_KEY'],
pi: ['PI_API_KEY', ...PI_BUILTIN_CREDENTIAL_KEYS],
kimi: ['MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY', 'OPENAI_API_KEY'],
mimo: ['MIMO_API_KEY'],
deepseek: ['ZGCI_API_KEY'],
claude: ['ANTHROPIC_API_KEY'],
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY'],
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
opencode: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY'],
opencodereview: ['OCR_LLM_TOKEN', 'OPENAI_API_KEY'],
})
const GOOGLE_CLOUD_ENV_KEYS = [
'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION', 'GOOGLE_CLOUD_QUOTA_PROJECT', 'CLOUDSDK_CONFIG',
]
const AWS_SDK_ENV_KEYS = [
'AWS_SDK_LOAD_CONFIG', 'AWS_EC2_METADATA_DISABLED', 'AWS_STS_REGIONAL_ENDPOINTS',
'AWS_RETRY_MODE', 'AWS_MAX_ATTEMPTS', 'AWS_DEFAULT_PROFILE', 'AWS_CA_BUNDLE',
]
const RUNTIME_ENV_KEYS = Object.freeze({
codex: ['CODEX_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'CODEX_HOME'],
hermes: [
'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY',
'OPENAI_BASE_URL', 'OPENAI_MODEL', 'HERMES_INFERENCE_PROVIDER', 'HERMES_INFERENCE_MODEL',
'OPENROUTER_BASE_URL', 'CUSTOM_BASE_URL', 'OLLAMA_HOST', 'OLLAMA_API_KEY',
'HERMES_HOME', 'HERMES_DATA_DIR_SUFFIX', 'HERMES_INSTALL_DIR',
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', ...AWS_SDK_ENV_KEYS,
'VERTEX_CREDENTIALS_PATH', 'VERTEX_PROJECT_ID', 'VERTEX_REGION', ...GOOGLE_CLOUD_ENV_KEYS,
],
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'OPENCLAW_STATE_DIR', 'OPENCLAW_HOME'],
workbuddy: [
'CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL',
'CODEBUDDY_CONFIG_DIR',
],
zcode: ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ZCODE_API_KEY', 'ZCODE_BASE_URL',
'ZCODE_MODEL', 'ZCODE_HTTP_PROXY', 'ZCODE_NO_PROXY', 'ZCODE_AGENT_CA_CERT', 'HOME', 'USERPROFILE'],
pi: [
'PI_API_KEY', 'PI_BASE_URL', 'PI_MODEL', ...PI_BUILTIN_CREDENTIAL_KEYS, ...PI_BUILTIN_CONFIG_KEYS,
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
'OPENROUTER_API_KEY', 'GEMINI_API_KEY', 'PI_CODING_AGENT_DIR',
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN',
'AWS_BEARER_TOKEN_BEDROCK', 'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION',
'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_ROLE_ARN',
'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE',
'AWS_BEDROCK_FORCE_CACHE', 'AWS_BEDROCK_FORCE_HTTP', 'AWS_BEDROCK_SKIP_AUTH',
...AWS_SDK_ENV_KEYS,
'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_API_VERSION', 'AZURE_API_VERSION',
'AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_RESOURCE_NAME', 'AZURE_OPENAI_DEPLOYMENT_NAME_MAP',
'GOOGLE_CLOUD_API_KEY', ...GOOGLE_CLOUD_ENV_KEYS,
],
kimi: [
'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY',
'KIMI_MODEL_BASE_URL', 'KIMI_MODEL_NAME',
'KIMI_MODEL_PROVIDER_TYPE', 'KIMI_MODEL_CAPABILITIES',
'KIMI_MODEL_MAX_CONTEXT_SIZE', 'KIMI_MODEL_MAX_OUTPUT_SIZE',
'KIMI_CODE_HOME',
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
],
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL', 'MIMOCODE_CONFIG', 'MIMOCODE_CONFIG_DIR',
'MIMOCODE_CONFIG_CONTENT', 'MIMOCODE_DISABLE_PROJECT_CONFIG'],
deepseek: ['ZGCI_API_KEY', 'DSH_HOME', 'DSH_AGENTS_HOME'],
claude: [
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'CLAUDE_CONFIG_DIR',
'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_MANTLE',
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE',
...AWS_SDK_ENV_KEYS,
'ANTHROPIC_BEDROCK_BASE_URL', 'ANTHROPIC_BEDROCK_MANTLE_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
...GOOGLE_CLOUD_ENV_KEYS,
'CLOUD_ML_REGION', 'ANTHROPIC_VERTEX_PROJECT_ID', 'ANTHROPIC_VERTEX_BASE_URL',
'ANTHROPIC_FOUNDRY_RESOURCE', 'ANTHROPIC_FOUNDRY_BASE_URL',
'ANTHROPIC_FOUNDRY_API_KEY', 'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_SECRET', 'AZURE_AUTHORITY_HOST',
'AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_CLIENT_CERTIFICATE_PASSWORD', 'AZURE_FEDERATED_TOKEN_FILE',
'AZURE_CONFIG_DIR',
],
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL',
'GEMINI_API_KEY', 'GEMINI_MODEL', 'GOOGLE_API_KEY', 'GOOGLE_MODEL', 'QWEN_OAUTH', 'QWEN_HOME', 'HOME', 'USERPROFILE',
'QWEN_CODE_SYSTEM_SETTINGS_PATH', 'QWEN_CODE_SYSTEM_DEFAULTS_PATH', 'QWEN_DEFAULT_AUTH_TYPE',
'GEMINI_CLI_TRUSTED_FOLDERS_PATH'],
gemini: [
'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', 'GEMINI_CLI_HOME',
'GEMINI_DEFAULT_AUTH_TYPE', 'GEMINI_CLI_AUTH_OVERRIDE', 'GEMINI_CLI_USE_COMPUTE_ADC', 'CLOUD_SHELL',
'GEMINI_CLI_SYSTEM_SETTINGS_PATH', 'GEMINI_CLI_SYSTEM_DEFAULTS_PATH',
'GEMINI_CLI_TRUSTED_FOLDERS_PATH', 'GEMINI_CLI_TRUST_WORKSPACE', 'GEMINI_RESTRICTED_MODE',
'GOOGLE_GEMINI_BASE_URL', 'GOOGLE_VERTEX_BASE_URL', 'GOOGLE_GENAI_API_VERSION',
'GOOGLE_GENAI_USE_VERTEXAI', 'GOOGLE_GENAI_USE_GCA', 'GOOGLE_CLOUD_ACCESS_TOKEN',
'GOOGLE_CLOUD_PROJECT_ID', 'GOOGLE_CLOUD_UNIVERSE_DOMAIN', ...GOOGLE_CLOUD_ENV_KEYS,
],
opencode: [
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY',
'OPENAI_BASE_URL', 'OPENAI_MODEL',
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT', 'OPENCODE_DISABLE_PROJECT_CONFIG',
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', ...AWS_SDK_ENV_KEYS,
'AZURE_OPENAI_API_KEY', 'AZURE_RESOURCE_NAME', 'GOOGLE_API_KEY', 'GEMINI_API_KEY',
...GOOGLE_CLOUD_ENV_KEYS,
],
opencodereview: [
'HOME', 'USERPROFILE',
'OCR_LLM_TOKEN', 'OCR_LLM_URL', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC',
'OCR_LLM_PROTOCOL', 'OCR_LLM_AUTH_HEADER', 'OCR_LLM_TIMEOUT',
'OPENAI_API_KEY', 'OPENAI_RESPONSES_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY',
],
})
function nativeCredentialEnvironment(kind, env = process.env, declaredKeys = [], platform = process.platform) {
const result = {}
const sourceKeys = Object.keys(env)
let runtimeKeys = kind === 'deepseek' && deepseekNativeConfig({ env }).explicitModel
? RUNTIME_ENV_KEYS.deepseek.filter(key => key !== 'ZGCI_API_KEY') : (RUNTIME_ENV_KEYS[kind] || [])
if (kind === 'opencodereview' && ocrSelectedProvider({ env, platform })) {
const selected = new Set(nativeSecretEnvKeys(declaredKeys))
const credentials = new Set(['OCR_LLM_TOKEN', 'OPENAI_API_KEY', 'OPENAI_RESPONSES_API_KEY',
'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'])
runtimeKeys = runtimeKeys.filter(key => !credentials.has(key) || selected.has(key))
}
const keys = [...runtimeKeys, ...(kind === 'claude'
? sourceKeys.map(key => platform === 'win32' ? key.toUpperCase() : key)
.filter(key => VERTEX_MODEL_REGION_KEY.test(key)) : []),
...nativeSecretEnvKeys(declaredKeys)]
for (const key of keys) {
const sourceKey = Object.hasOwn(env, key) ? key
: platform === 'win32' ? sourceKeys.find(name => name.toUpperCase() === key.toUpperCase()) : undefined
if (sourceKey !== undefined && typeof env[sourceKey] === 'string') result[key] = env[sourceKey]
}
return result
}
module.exports = { CREDENTIAL_ENV_KEYS, RUNTIME_ENV_KEYS, VERTEX_MODEL_REGION_KEY, nativeCredentialEnvironment }
+25 -1
View File
@@ -426,7 +426,7 @@ function importAgentOutputs(input, attachmentStore) {
&& !Array.isArray(input.baseline.files)
? input.baseline.files
: {}
const candidates = outputFiles(safeOutputDirectory(workdirRealPath))
const controlledCandidates = outputFiles(safeOutputDirectory(workdirRealPath))
.filter(file => !sameFileState(baselineFiles[file.name], file))
.filter(file => Math.max(file.mtimeMs, file.ctimeMs) >= startedAt - 2000)
.sort((left, right) => (
@@ -434,6 +434,30 @@ function importAgentOutputs(input, attachmentStore) {
|| left.name.localeCompare(right.name)
))
const reportedCandidates = []
const reportedPaths = Array.isArray(input.reportedPaths) ? input.reportedPaths : []
for (const reportedPath of reportedPaths.slice(0, MAX_SCANNED_ENTRIES)) {
if (typeof reportedPath !== 'string' || !path.isAbsolute(reportedPath)) continue
try {
const realPath = fs.realpathSync(reportedPath)
const stat = fs.statSync(realPath)
const extension = path.extname(realPath).toLowerCase()
// A path the Agent merely mentioned (a file it read, the user's input,
// a staged attachment) is not an output. Only files written during this
// run outside the input staging folder are imported.
if (!stat.isFile() || !SUPPORTED_EXTENSIONS.has(extension)
|| stat.size > 64 * 1024 * 1024 || !isInside(workdirRealPath, realPath)
|| realPath.split(path.sep).includes('.meldwork-input')
|| stat.mtimeMs < startedAt - 2000) continue
reportedCandidates.push({
name: path.basename(realPath), path: realPath, size: stat.size,
mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs,
})
} catch { /* reported paths are best effort */ }
}
const candidates = [...controlledCandidates, ...reportedCandidates]
.filter((file, index, all) => all.findIndex(candidate => candidate.path === file.path) === index)
const imported = []
if (input.signal?.aborted) return imported
for (const file of candidates) {
@@ -0,0 +1,85 @@
const { createHash } = require('node:crypto')
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
function sha256(value) {
return createHash('sha256').update(String(value || '')).digest('hex')
}
// Tool kinds an Agent may use without approval during a read-only turn.
const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
function terminalPlanRejection(options = []) {
// Kimi's first reject option is "Revise", which loops without feedback.
return options.find(option => option?.optionId === 'plan_reject_and_exit'
&& ['reject_once', 'reject_always'].includes(option.kind))
}
// Automatic answer to an Agent's permission request when the run is not
// waiting for a person. Returns the response to send, `null` when the request
// is refused but the Agent offered nothing to refuse with, or `undefined`
// when no automatic answer exists and the caller must ask the user.
//
// A read-only turn stays read-only even when the Agent asks: only
// side-effect-free tools are approved, while edits, deletes, moves, commands
// and mode switches are refused.
function automaticPermissionResponse(request, { allowWrite }) {
const options = Array.isArray(request?.options) ? request.options : []
if (!allowWrite && !READ_ONLY_SAFE_TOOL_KINDS.has(request?.toolCall?.kind)) {
const rejection = terminalPlanRejection(options)
|| options.find(candidate => candidate?.kind === 'reject_once')
|| options.find(candidate => candidate?.kind === 'reject_always')
return rejection?.optionId ? { status: 'rejected', optionId: rejection.optionId } : null
}
const option = options.find(candidate => candidate?.kind === 'allow_once')
|| options.find(candidate => candidate?.kind === 'allow_always')
|| options[0]
return option?.optionId ? { status: 'approved', optionId: option.optionId } : undefined
}
// A person's decision applies only to the exact request it was made for.
function matchesResumedPermission(request, resumedGate) {
if (!request || !resumedGate?.request) return false
try {
if (canonicalJson(request) !== canonicalJson(resumedGate.request)) return false
} catch {
return false
}
const selected = request.options?.find(option => option.optionId === resumedGate.optionId)
if (!selected) return false
return resumedGate.status === 'approved'
? selected.kind.startsWith('allow_')
: selected.kind.startsWith('reject_')
}
function permissionContinuationRequestId(requestHash, binding) {
return sha256(canonicalJson({
requestHash,
sessionRefHash: binding.sessionRefHash,
sessionProvenanceHash: binding.sessionProvenanceHash,
}))
}
// A resumed permission decision must belong to the native session the
// request came from; anything else is refused before the Agent runs.
function assertResumedPermissionBinding(resumed, currentBinding, sessionRef) {
const requestHash = sha256(canonicalJson(resumed.request))
const persistedBinding = {
sessionRefHash: resumed.sessionRefHash,
sessionProvenanceHash: resumed.sessionProvenanceHash,
}
if (resumed.requestId !== permissionContinuationRequestId(requestHash, persistedBinding)
|| resumed.requestHash !== requestHash
|| resumed.sessionProvenanceHash !== currentBinding.sessionProvenanceHash
|| (sessionRef && resumed.sessionRefHash !== currentBinding.sessionRefHash)) {
throw new Error('LOCAL_RUN_PERMISSION_REQUEST_MISMATCH')
}
}
module.exports = {
terminalPlanRejection,
READ_ONLY_SAFE_TOOL_KINDS,
assertResumedPermissionBinding,
automaticPermissionResponse,
matchesResumedPermission,
permissionContinuationRequestId,
}
+53 -11
View File
@@ -23,7 +23,7 @@ const LIMIT_BANDS = new Set(['low', 'medium', 'high', 'any'])
const BAND_ORDER = new Map([['low', 0], ['medium', 1], ['high', 2]])
const EXCLUSION_REASONS = new Set([
'unavailable', 'domain', 'input-type', 'output-type', 'tool-class',
'permission', 'latency', 'cost', 'context', 'resumability',
'permission', 'latency', 'cost', 'context', 'resumability', 'unverified',
])
const RATIONALES = new Set([
'explicit-user-selection', 'smallest-suitable-team', 'evidence-ranked-team',
@@ -80,7 +80,7 @@ function normalizeRoutingRequirements(value, defaults = {}) {
const minContextChars = input.minContextChars ?? defaults.minContextChars ?? 1
const resumableRequired = input.resumableRequired ?? false
if (!AGENT_PERMISSION_MODES.includes(permissionMode)
|| (defaults.permissionMode && permissionMode !== defaults.permissionMode)
|| (defaults.permissionMode === 'read-only' && permissionMode !== 'read-only')
|| !COLLABORATION_MODES.has(collaboration)
|| !LIMIT_BANDS.has(maxLatencyBand)
|| !LIMIT_BANDS.has(maxCostBand)
@@ -184,7 +184,8 @@ function evaluateCandidate(agent, requirements, options = {}) {
attachmentSupport: options.attachmentSupport?.(agent.kind) || {},
})
const exclusions = []
if (!(agent.invocable ?? agent.available)) exclusions.push('unavailable')
if (agent.invocable !== true) exclusions.push('unavailable')
if (options.requireVerified && !options.isRecentlyVerified(agent)) exclusions.push('unverified')
if (requirements.domains.some(domain => !capabilities.domains.includes(domain))) {
exclusions.push('domain')
}
@@ -197,7 +198,9 @@ function evaluateCandidate(agent, requirements, options = {}) {
if (requirements.toolClasses.some(type => !capabilities.toolClasses.includes(type))) {
exclusions.push('tool-class')
}
if (!capabilities.permissionModes.includes(requirements.permissionMode)) {
if (!capabilities.permissionModes.includes(requirements.permissionMode)
&& !(options.allowReadOnlyPeer && requirements.permissionMode === 'workspace-write'
&& capabilities.permissionModes.includes('read-only'))) {
exclusions.push('permission')
}
if (exceedsBand(capabilities.latencyBand, requirements.maxLatencyBand)) {
@@ -285,11 +288,14 @@ function normalizeRoutingDecision(value) {
'candidates', 'evidenceVersion', 'mode', 'rationale', 'requirements',
'selectedKinds', 'version',
])
if (Object.hasOwn(input || {}, 'permissionCap')) fields.add('permissionCap')
if (!exactKeys(input, fields) || input.version !== ROUTING_DECISION_VERSION
|| !ROUTING_MODES.has(input.mode) || !RATIONALES.has(input.rationale)
|| (Object.hasOwn(input, 'permissionCap') && input.permissionCap !== 'read-only')
|| (input.evidenceVersion && !PUBLIC_ID.test(input.evidenceVersion))) return null
let requirements
try { requirements = normalizeRoutingRequirements(input.requirements) } catch { return null }
if (input.permissionCap && requirements.permissionMode !== 'read-only') return null
let selectedKinds
try { selectedKinds = targetList(input.selectedKinds) } catch { return null }
const candidates = Array.isArray(input.candidates) && input.candidates.length <= MAX_ROUTING_CANDIDATES
@@ -309,6 +315,7 @@ function normalizeRoutingDecision(value) {
selectedKinds,
rationale: input.rationale,
evidenceVersion: input.evidenceVersion,
...(input.permissionCap ? { permissionCap: input.permissionCap } : {}),
}
}
@@ -316,6 +323,7 @@ class AgentRouter {
constructor(options = {}) {
this.attachmentSupport = options.attachmentSupport || (() => ({}))
this.fitMatrix = normalizeFitMatrix(options.fitMatrix)
this.isRecentlyVerified = options.isRecentlyVerified || (agent => agent.recentlyVerified === true)
}
route({ agents, group, input, mode, inputTypes, minContextChars }) {
@@ -341,35 +349,56 @@ class AgentRouter {
&& explicitTargets.every(kind => isCodeReviewAgentKind(kind))
const detected = new Map((Array.isArray(agents) ? agents : []).map(agent => [agent.kind, agent]))
const permissionTargets = effectiveMode === 'explicit' ? explicitTargets : approvedKinds
const targetWriteSupport = permissionTargets.map((kind) => (
agentRuntimeCapabilities(kind, {
const targetWriteSupport = permissionTargets.map((kind) => {
const candidate = detected.get(kind)
return (effectiveMode === 'explicit' || (candidate
&& candidate.invocable === true && this.isRecentlyVerified(candidate, group.workdir)))
&& agentRuntimeCapabilities(kind, {
agent: detected.get(kind),
attachmentSupport: this.attachmentSupport(kind),
}).permissionModes.includes('workspace-write')
))
})
const writePermissionCompatible = effectiveMode === 'explicit'
? targetWriteSupport.every(Boolean)
: targetWriteSupport.some(Boolean)
const permissionMode = explicitReadOnlyTargets || !writePermissionCompatible
? 'read-only'
: group.allowWrite === true ? 'workspace-write' : 'read-only'
const requirements = normalizeRoutingRequirements(input.routingRequirements, {
const defaults = {
domains: effectiveMode === 'automatic' ? ['general'] : [],
inputTypes,
outputTypes: ['text'],
permissionMode,
minContextChars,
collaboration: mode === 'auto' ? 'required' : 'none',
})
}
let requirements = normalizeRoutingRequirements(input.routingRequirements, defaults)
if (explicitTargets.some(kind => !approvedKinds.includes(kind))) {
const kind = explicitTargets.find(target => !approvedKinds.includes(target))
throw routingError('LOCAL_MESSAGE_TARGET_OUT_OF_GROUP', { kind })
}
const candidates = approvedKinds.map((kind) => evaluateCandidate(
let allowReadOnlyPeer = effectiveMode === 'automatic'
&& requirements.collaboration === 'required'
&& requirements.permissionMode === 'workspace-write'
&& input.routingRequirements?.permissionMode == null
const canWrite = kind => agentRuntimeCapabilities(kind, {
agent: detected.get(kind), attachmentSupport: this.attachmentSupport(kind),
}).permissionModes.includes('workspace-write')
const evaluateCandidates = () => approvedKinds.map((kind) => evaluateCandidate(
detected.get(kind) || { kind, available: false },
requirements,
{ attachmentSupport: this.attachmentSupport, fitMatrix: this.fitMatrix },
{ attachmentSupport: this.attachmentSupport, fitMatrix: this.fitMatrix,
requireVerified: effectiveMode === 'automatic', isRecentlyVerified: agent => this.isRecentlyVerified(agent, group.workdir),
allowReadOnlyPeer },
))
let candidates = evaluateCandidates()
if (effectiveMode === 'automatic' && requirements.permissionMode === 'workspace-write'
&& input.routingRequirements?.permissionMode == null
&& !candidates.some(candidate => candidate.eligible && canWrite(candidate.kind))) {
requirements = normalizeRoutingRequirements(input.routingRequirements, { ...defaults, permissionMode: 'read-only' })
allowReadOnlyPeer = false
candidates = evaluateCandidates()
}
let selectedKinds
if (effectiveMode === 'explicit') {
selectedKinds = explicitTargets
@@ -385,12 +414,24 @@ class AgentRouter {
|| approvedKinds.indexOf(left.kind) - approvedKinds.indexOf(right.kind))
const requiredCount = requirements.collaboration === 'required' ? 2 : 1
if (eligible.length < requiredCount) {
const unverified = candidates.filter(candidate => candidate.exclusions.length === 1
&& candidate.exclusions[0] === 'unverified').length
if (eligible.length + unverified >= requiredCount) {
throw routingError('LOCAL_AGENT_ROUTING_UNVERIFIED', { eligibleCount: eligible.length, requiredCount })
}
throw routingError(
requiredCount > 1 ? 'LOCAL_AUTO_AGENT_COUNT' : 'LOCAL_AGENT_ROUTING_NO_MATCH',
{ eligibleCount: eligible.length, requiredCount },
)
}
selectedKinds = eligible.slice(0, requiredCount).map(candidate => candidate.kind)
if (allowReadOnlyPeer) {
if (!selectedKinds.some(canWrite)) {
const writer = eligible.find(candidate => canWrite(candidate.kind))
if (!writer) throw routingError('LOCAL_AGENT_ROUTING_NO_MATCH', { eligibleCount: eligible.length, requiredCount })
selectedKinds[selectedKinds.length - 1] = writer.kind
}
}
}
const selectedHasEvidence = selectedKinds.some(kind => (
candidates.find(candidate => candidate.kind === kind)?.evidence
@@ -405,6 +446,7 @@ class AgentRouter {
? 'explicit-user-selection'
: (selectedHasEvidence ? 'evidence-ranked-team' : 'smallest-suitable-team'),
evidenceVersion: selectedHasEvidence ? this.fitMatrix.version : '',
...(input.routingRequirements?.permissionMode === 'read-only' ? { permissionCap: 'read-only' } : {}),
})
if (!decision) throw routingError('LOCAL_AGENT_ROUTING_DECISION_INVALID')
return Object.freeze(decision)
+56 -2
View File
@@ -1,3 +1,4 @@
const { authFailureCertainty } = require('../runs/failure-signals.cjs')
const AGENT_OUTCOMES = Object.freeze([
'accepted',
'running',
@@ -79,6 +80,10 @@ const AGENT_RUNTIME_CAPABILITIES = Object.freeze({
domains: ['general', 'software-development', 'document-production'],
toolClasses: ['filesystem', 'shell'],
}),
zcode: generalProfile({
domains: ['general', 'software-development', 'research'],
toolClasses: ['filesystem', 'shell'],
}),
pi: generalProfile({
domains: ['general', 'software-development'],
toolClasses: ['filesystem', 'shell'],
@@ -91,6 +96,10 @@ const AGENT_RUNTIME_CAPABILITIES = Object.freeze({
domains: ['general', 'software-development'],
toolClasses: ['filesystem', 'shell'],
}),
deepseek: generalProfile({
domains: ['general', 'software-development', 'research', 'tool-use'],
toolClasses: ['filesystem', 'shell'],
}),
claude: generalProfile({
domains: ['general', 'software-development', 'research', 'document-production'],
toolClasses: ['filesystem', 'shell'],
@@ -210,10 +219,35 @@ function normalizeExternalRunRef(value) {
return EXTERNAL_RUN_REF.test(ref) ? ref : ''
}
function terminalAuthenticationDiagnostic(value) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (!text || text.length > 2000) return ''
return /^HTTP\s+(?:401|403)\s*:\s*(?:unauthorized|forbidden|(?:invalid|expired|missing)\s+(?:access\s+token|token|credentials?|api[ _-]?key|authentication))(?:\s*\(request[ _-]?id:\s*[^()]{1,160}\))?\.?$/i
.test(text)
? text
: ''
}
// True only for explicit sign-in or API-key statements. Ambiguous refusals
// (HTTP 403, proxy or region blocks, "provider rejected") are classified as
// LOCAL_AGENT_ACCESS_DENIED so they are retried instead of signing the Agent out.
function authConfigurationFailure(detail) {
return authFailureCertainty(detail) === 'definitive'
}
function authFailureCode(detail) {
const certainty = authFailureCertainty(detail)
if (certainty === 'definitive') return 'LOCAL_AGENT_AUTH_REQUIRED'
if (certainty === 'ambiguous') return 'LOCAL_AGENT_ACCESS_DENIED'
return ''
}
function failureCategory(code) {
const value = String(code || '')
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
if (value === 'LOCAL_AGENT_AUTH_REQUIRED') return 'authentication'
if (value === 'LOCAL_AGENT_AUTH_REQUIRED' || value === 'LOCAL_WORKBUDDY_PROVIDER_AUTH') return 'authentication'
if (value === 'LOCAL_AGENT_ACCESS_DENIED') return 'provider'
if (value === 'LOCAL_AGENT_PROVIDER_QUOTA_EXHAUSTED') return 'provider'
if (value === 'LOCAL_AGENT_TIMEOUT') return 'timeout'
if (value === 'LOCAL_AGENT_EXECUTION_STOPPED') return 'cancellation'
if (value === 'LOCAL_AGENT_REFUSED') return 'refusal'
@@ -290,15 +324,32 @@ function requireTerminalAgentResult(result) {
throw agentRuntimeError('LOCAL_AGENT_EXECUTION_STOPPED')
}
if (normalized.outcome === 'failed') {
const failure = normalized.failure?.code
let failure = normalized.failure?.code
? normalized.failure
: agentRuntimeFailure('LOCAL_AGENT_PROCESS_FAILED')
const diagnostic = String(normalized.diagnostic || normalized.text || '')
// A structured failure whose diagnostic names an authentication problem is
// an authentication failure even when the CLI reported a generic process
// error, so the catalog can mark the Agent as needing a fresh login.
if (failure.category !== 'authentication' && authConfigurationFailure(diagnostic)) {
failure = agentRuntimeFailure('LOCAL_AGENT_AUTH_REQUIRED')
} else if (failure.code === 'LOCAL_AGENT_PROCESS_FAILED'
&& authFailureCertainty(normalized.diagnostic) === 'ambiguous') {
failure = agentRuntimeFailure('LOCAL_AGENT_ACCESS_DENIED')
}
throw agentRuntimeError(
failure.code,
normalized.diagnostic || normalized.text,
failure,
)
}
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
if (authenticationDiagnostic) {
throw agentRuntimeError(
authFailureCode(authenticationDiagnostic) || 'LOCAL_AGENT_ACCESS_DENIED',
authenticationDiagnostic,
)
}
return normalized
}
@@ -325,6 +376,8 @@ module.exports = {
agentRuntimeCapabilities,
agentRuntimeError,
agentRuntimeFailure,
authConfigurationFailure,
authFailureCode,
isCodeReviewAgentKind,
isReviewOnlyAgentKind,
normalizeAgentResult,
@@ -332,4 +385,5 @@ module.exports = {
normalizeOutcome,
outcomeForAcpStopReason,
requireTerminalAgentResult,
terminalAuthenticationDiagnostic,
}
@@ -0,0 +1,66 @@
async function abortableHttpOperation(operation, signal) {
const pending = Promise.resolve(operation)
if (!signal) return pending
if (signal.aborted) {
pending.catch(() => {})
throw signal.reason
}
let onAbort
const aborted = new Promise((_resolve, reject) => {
onAbort = () => reject(signal.reason)
signal.addEventListener('abort', onAbort, { once: true })
})
try { return await Promise.race([pending, aborted]) }
finally { signal.removeEventListener('abort', onAbort) }
}
function discardHttpResponse(response) {
if (typeof response?.body?.cancel === 'function') {
try { Promise.resolve(response.body.cancel()).catch(() => {}) } catch { /* already closed */ }
}
}
function discardLateHttpResponse(operation, signal) {
Promise.resolve(operation).then(response => {
if (signal?.aborted) discardHttpResponse(response)
}, () => {})
}
async function boundedHttpResponseText(response, limit, errorCode, signal) {
const invalid = () => { throw Object.assign(new Error(errorCode), { code: errorCode }) }
if (!response.body?.getReader) {
const text = await response.text()
if (typeof text !== 'string' || Buffer.byteLength(text) > limit) invalid()
return text
}
const reader = response.body.getReader()
let cancelled = false
const cancelReader = () => {
if (cancelled) return
cancelled = true
try { Promise.resolve(reader.cancel()).catch(() => {}) } catch { /* already closed */ }
}
signal?.addEventListener('abort', cancelReader, { once: true })
if (signal?.aborted) cancelReader()
const chunks = []
let total = 0
try {
while (true) {
const { done, value } = await abortableHttpOperation(reader.read(), signal)
if (done) break
const chunk = Buffer.from(value)
total += chunk.length
if (total > limit) invalid()
chunks.push(chunk)
}
} catch (error) {
cancelReader()
throw error
} finally {
signal?.removeEventListener('abort', cancelReader)
reader.releaseLock()
}
return Buffer.concat(chunks, total).toString('utf8')
}
module.exports = { abortableHttpOperation, boundedHttpResponseText, discardHttpResponse, discardLateHttpResponse }
+278 -51
View File
@@ -1,6 +1,11 @@
const { spawn } = require('node:child_process')
const { createHash } = require('node:crypto')
const { createHash, randomBytes } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { PassThrough, Readable, Writable } = require('node:stream')
const { SECRET_ENV_KEYS, nativeSecretEnvKeys } = require('./native-secret-environment.cjs')
const { mimoConfigurationFingerprint } = require('./mimo-native-config.cjs')
const { deepseekConfigurationFingerprint } = require('./deepseek-native-config.cjs')
const { AGENT_PROFILES, prepareCommand } = require('./cli-discovery.cjs')
const {
outcomeForAcpStopReason,
@@ -18,13 +23,35 @@ const {
agentExecutionError,
childEnvironment,
failedAgentProcessError,
terminateWindowsProcessTree,
} = require('./cli-process-support.cjs')
const { createAcpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
const { redactSecrets } = require('../../security/secret-redaction.cjs')
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
const { createTurnCompletion } = require('./cli-turn-completion.cjs')
const { terminalPlanRejection } = require('../agent-permission-policy.cjs')
const ACP_CANCEL_GRACE_MS = 250
const ACP_MAX_LINE_BYTES = 1024 * 1024
// After a stop or timeout the Agent gets this long to answer the cancelled
// prompt. A clean cancellation keeps the long-lived process and its native
// session; only an Agent that ignores cancel is torn down.
const ACP_CANCEL_SETTLE_MS = 3000
// MiMo can flush the last delta of a message just after its prompt response.
const MIMO_TERMINAL_DRAIN_MS = 50
const ACP_INITIALIZE_TIMEOUT_MS = 60 * 1000
const ACP_STDERR_TAIL_BYTES = 64 * 1024
// Process identity must not change when only volatile shell values change.
// Version managers such as fnm put a per-shell directory on PATH, so a fresh
// login-shell capture used to restart the ACP process and drop its session.
const ACP_VOLATILE_ENV_KEYS = new Set([
'PATH', 'Path', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TERM_SESSION_ID',
'SHLVL', 'PWD', 'OLDPWD', '_', 'TMPDIR', 'COLUMNS', 'LINES', 'SHELL',
'FNM_MULTISHELL_PATH', 'FNM_DIR', 'FNM_VERSION_FILE_STRATEGY', 'FNM_LOGLEVEL',
'FNM_NODE_DIST_MIRROR', 'FNM_ARCH', 'FNM_COREPACK_ENABLED', 'FNM_RESOLVE_ENGINES',
])
const ACP_MAX_LINE_BYTES = 4 * 1024 * 1024
const ACP_MAX_INPUT_BYTES = 16 * 1024 * 1024
const ACP_UNKNOWN_METHOD_NAME_LIMIT = 120
const ACP_MAX_REPLY_BYTES = 10 * 1024 * 1024
const ACP_PERSISTENT_RUNTIME_LIMIT = 16
const ACP_PERMISSION_OPTION_LIMIT = 16
@@ -88,6 +115,10 @@ function acpTransportError(diagnostic) {
return agentExecutionError('LOCAL_AGENT_PROCESS_FAILED', diagnostic)
}
// Returns the message to deliver, or null for traffic that is safe to ignore.
// A newer Agent can send updates and notifications this client does not know;
// failing the whole turn over them would break Meldwork on every Agent upgrade,
// so only messages that decide what is executed or approved stay strict.
function validateAcpInboundMessage(message, validators, replyState, protocolLabel) {
if (!message || typeof message !== 'object' || Array.isArray(message)
|| message.jsonrpc !== '2.0') {
@@ -99,7 +130,8 @@ function validateAcpInboundMessage(message, validators, replyState, protocolLabe
if (message.method === 'session/update' && !hasId) {
const parsed = validators.zSessionNotification.safeParse(message.params)
if (!parsed.success) {
throw acpTransportError(`${protocolLabel} returned invalid session update parameters.`)
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return null
}
const update = parsed.data.update
if (replyState.collecting !== false
@@ -119,7 +151,23 @@ function validateAcpInboundMessage(message, validators, replyState, protocolLabe
}
return message
}
throw acpTransportError(`${protocolLabel} requested an unsupported client method.`)
// An unknown request still gets the connection's "method not found"
// answer, so the Agent is not left waiting. The SDK logs every request it
// rejects together with its parameters, so only the method name is
// forwarded. An unknown notification needs no answer and is dropped
// before it can reach the SDK's logging.
const validId = typeof message.id === 'string'
|| (Number.isInteger(message.id) && message.id >= 0)
if (hasId && validId) {
return {
jsonrpc: '2.0',
id: message.id,
method: message.method.slice(0, ACP_UNKNOWN_METHOD_NAME_LIMIT),
params: {},
}
}
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return null
}
if (!Number.isInteger(message.id) || message.id < 0) {
@@ -215,6 +263,12 @@ function boundedAcpStream(
const parseLine = (line) => {
const text = line.toString('utf8').trim()
if (!text) return
// Some CLIs print banners or warnings on stdout next to the protocol.
// Anything that is not an object is not protocol traffic.
if (!text.startsWith('{')) {
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return
}
let message
try {
message = JSON.parse(text)
@@ -224,6 +278,7 @@ function boundedAcpStream(
const validated = validateAcpInboundMessage(
message, validators, replyState, protocolLabel,
)
if (!validated) return
if (message.method === 'session/request_permission') {
replyState.pendingPermissionRequestIds.add(message.id)
} else if (message.method === 'session/update' && message.params?.update) {
@@ -309,7 +364,7 @@ function boundedAcpStream(
}
function permissionRejection(options) {
return (options || []).find(option => (
return terminalPlanRejection(options || []) || (options || []).find(option => (
['reject_once', 'reject_always'].includes(option.kind)
|| /reject|deny/i.test(`${option.optionId || ''} ${option.name || ''}`)
))
@@ -423,6 +478,7 @@ function destroyChildPipes(child) {
async function terminateChild(child, platform, spawnFn) {
if (child.exitCode != null || child.signalCode != null) return
if (platform === 'win32') return terminateWindowsProcessTree(child, spawnFn)
await new Promise((resolve) => {
let settled = false
let forceKillTimeout
@@ -438,15 +494,6 @@ async function terminateChild(child, platform, spawnFn) {
}
child.once('close', finish)
child.once('error', finish)
if (platform === 'win32' && child.pid) {
const killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore', windowsHide: true,
})
killer.on('error', () => {})
killer.unref()
forceSettleTimeout = setTimeout(finish, TERMINATE_GRACE_MS + KILL_SETTLE_MS)
return
}
const signalTree = (signal) => {
if (child.pid) {
try {
@@ -482,13 +529,14 @@ async function waitForChildExit(child, timeoutMs) {
let timeout
const finish = (exited) => {
clearTimeout(timeout)
child.removeListener('close', onExit)
child.removeListener('error', onExit)
child.removeListener('close', onClose)
child.removeListener('error', onError)
resolve(exited)
}
const onExit = () => finish(true)
child.once('close', onExit)
child.once('error', onExit)
const onClose = () => finish(true)
const onError = () => finish(false)
child.once('close', onClose)
child.once('error', onError)
timeout = setTimeout(() => finish(false), timeoutMs)
timeout.unref?.()
})
@@ -500,13 +548,16 @@ async function closeAcpChild(child, platform, spawnFn) {
try { child.stdin.end() } catch { /* the process has already closed its input */ }
}
if (await waitForChildExit(child, TERMINATE_GRACE_MS)) return
await terminateChild(child, platform, spawnFn)
destroyChildPipes(child)
try { await terminateChild(child, platform, spawnFn) } finally { destroyChildPipes(child) }
}
function acpProtocolError(error, childEnv, sessionRef = '') {
function acpProtocolError(error, childEnv, sessionRef = '', kind = '') {
if (/^LOCAL_AGENT_[A-Z_]+$/.test(String(error?.message || ''))) return error
const detail = redactChildSecrets(error?.diagnostic || error?.message || error, childEnv).trim()
if (kind === 'deepseek'
&& /^Internal error: turn failed: llm-pi-ai: no credential for provider route "zgci"/i.test(detail)) {
return agentExecutionError('LOCAL_AGENT_AUTH_REQUIRED', 'Missing API key for the configured ZGCI provider.')
}
return detail
? failedAgentProcessError(detail, { sessionRef })
: agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
@@ -529,6 +580,32 @@ function persistenceKey(agent, options) {
return `${agent.kind}:${value}`
}
const ACP_IMAGE_MIME_TYPES = new Map([
['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'],
['.gif', 'image/gif'], ['.webp', 'image/webp'],
])
const ACP_MAX_IMAGE_BYTES = 10 * 1024 * 1024
// Images travel as ACP content blocks only when the Agent advertises image
// prompts during initialize. Otherwise the turn fails before prompting so the
// adapter can fall back to a transport that accepts image arguments.
function acpImageBlocks(runtime, imageAttachments) {
const images = Array.isArray(imageAttachments) ? imageAttachments : []
if (!images.length) return []
if (runtime.agentCapabilities?.promptCapabilities?.image !== true) {
throw allowAcpSetupFallback(agentExecutionError('LOCAL_AGENT_IMAGE_UNSUPPORTED'))
}
return images.map((filename) => {
const mimeType = ACP_IMAGE_MIME_TYPES.get(path.extname(filename).toLowerCase())
if (!mimeType) throw agentExecutionError('LOCAL_AGENT_IMAGE_UNSUPPORTED')
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > ACP_MAX_IMAGE_BYTES) {
throw agentExecutionError('LOCAL_AGENT_IMAGE_LIMIT')
}
return { type: 'image', mimeType, data: fs.readFileSync(filename).toString('base64') }
})
}
function withAcpRuntimeLock(key, callback) {
const previous = acpRuntimeLocks.get(key) || Promise.resolve()
const current = previous.catch(() => {}).then(callback)
@@ -540,16 +617,28 @@ function withAcpRuntimeLock(key, callback) {
}
function runtimeSignature(agent, workdir, spec, profile, childEnv) {
const environment = Object.keys(childEnv).sort().map(key => [key, childEnv[key]])
const environment = Object.keys(childEnv).sort()
.filter(key => !ACP_VOLATILE_ENV_KEYS.has(key))
.map(key => [key, childEnv[key]])
return createHash('sha256').update(JSON.stringify({
kind: agent.kind,
executable: agent.executable,
workdir,
command: spec.command,
args: spec.args,
acpMode: spec.acpMode || '',
// The native session mode is applied on every turn
// with session/set_mode, so it does not identify a process. Anything that
// really needs a separate process (a permission policy in the environment)
// is already part of the environment below.
profileId: profile.profileId,
nativeConfiguration: agent.kind === 'mimo'
? mimoConfigurationFingerprint({ env: childEnv, home: childEnv.HOME, workdir }) || randomBytes(32).toString('hex')
: agent.kind === 'deepseek'
? deepseekConfigurationFingerprint({ env: childEnv, home: childEnv.HOME, workdir }) || randomBytes(32).toString('hex')
: '',
environment,
secretEnvironmentKeys: nativeSecretEnvKeys(childEnv[SECRET_ENV_KEYS]),
redactionSecrets: childEnv[Symbol.for('meldwork.project-secret-values')] || [],
})).digest('hex')
}
@@ -653,8 +742,14 @@ async function createAcpRuntime(input) {
})
child.stderr.on('data', (chunk) => {
try { runtime.activeTurn?.noteActivity?.() } catch { /* activity is best-effort */ }
// Keep only recent stderr. A long-lived process used to keep the first
// megabyte of its whole lifetime, so a new failure was classified from
// stale early lines (for example an old auth warning).
runtime.stderr.push(chunk)
runtime.stderrBytes += chunk.length
if (runtime.stderrBytes <= 1024 * 1024) runtime.stderr.push(chunk)
while (runtime.stderrBytes > ACP_STDERR_TAIL_BYTES && runtime.stderr.length > 1) {
runtime.stderrBytes -= runtime.stderr.shift().length
}
})
runtime.observedStdout = createObservedAcpInput(
child.stdout, runtime.replyState, protocolLabel,
@@ -663,6 +758,16 @@ async function createAcpRuntime(input) {
const client = {
async requestPermission(params) {
const turn = runtime.activeTurn
if (turn?.completion?.completed) return { outcome: { outcome: 'cancelled' } }
// DeepSeek escalation can escape its sandbox. Meldwork's selected
// workspace mode is the entire grant for this process.
if (runtime.agent.kind === 'deepseek') {
return selectedPermissionOutcome(permissionRejection(params?.options))
}
if (runtime.agent.kind === 'mimo' && runtime.replyState.promptTerminalSequence != null) {
runtime.replyState.permissionPendingAtPromptTerminal = true
return { outcome: { outcome: 'cancelled' } }
}
const pending = permissionOutcome(params, turn?.options || {}, childEnv)
turn?.pendingPermissionCallbacks.add(pending)
pending.then(
@@ -676,11 +781,40 @@ async function createAcpRuntime(input) {
try { turn?.noteActivity?.() } catch { /* activity is best-effort */ }
const sequence = runtime.replyState.pendingUpdateSequences.shift()
if (!turn || !runtime.replyState.collecting) return
if (params.sessionId !== runtime.protocolSessionRef) return
if (runtime.replyState.promptTerminalSequence != null
&& Number.isInteger(sequence)
&& sequence > runtime.replyState.promptTerminalSequence) return
turn.structuredOutput.ingest?.(params)
for (const event of turn.profile.mapEvent(params.update, turn.runtimeState)) {
&& sequence > runtime.replyState.promptTerminalSequence
&& !(runtime.agent.kind === 'mimo'
&& params.update?.sessionUpdate === 'agent_message_chunk'
&& turn.lastMessageId
&& params.update.messageId === turn.lastMessageId)) return
if (params.update?.sessionUpdate === 'agent_message_chunk' && params.update.messageId) {
turn.lastMessageId = params.update.messageId
}
let update = params.update
if (['tool_call', 'tool_call_update'].includes(update?.sessionUpdate)) {
if (['completed', 'failed'].includes(update.status)) turn.openTools.delete(update.toolCallId)
else if (update.status || update.sessionUpdate === 'tool_call') turn.openTools.add(update.toolCallId)
}
if (turn.completion && update?.sessionUpdate === 'agent_message_chunk'
&& update.content?.type === 'text') {
const next = turn.completion.push(update.content.text,
turn.openTools.size === 0 && turn.pendingPermissionCallbacks.size === 0)
update = { ...update, content: { ...update.content, text: next.delta } }
if (next.completed) turn.finishContribution()
}
if (turn.repetitionGuard?.(update)) {
turn.stopRepeatedOutput()
return
}
if (update?.sessionUpdate === 'agent_thought_chunk'
&& update.content?.type === 'text' && typeof update.content.text === 'string'
&& update.content.text.length > 0) {
try { turn.options.onSubstantiveActivity?.() } catch { /* activity is best-effort */ }
}
turn.structuredOutput.ingest?.({ ...params, update })
for (const event of turn.profile.mapEvent(update, turn.runtimeState)) {
turn.runtimeEvents.emit(event)
}
},
@@ -713,15 +847,42 @@ async function createAcpRuntime(input) {
)
runtime.connection = new ClientSideConnection(() => client, stream)
acpInitializingRuntimes.add(runtime)
const abortSignal = input.options?.signal
const initializeTimeoutMs = Number.isFinite(input.options?.acpInitializeTimeoutMs)
&& input.options.acpInitializeTimeoutMs > 0
? input.options.acpInitializeTimeoutMs
: ACP_INITIALIZE_TIMEOUT_MS
let initializeTimer = null
let abortListener = null
try {
await Promise.race([
// initialize used to wait forever while holding the per-conversation
// lock, leaving the Agent unusable in that conversation until restart.
const initialized = await Promise.race([
runtime.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }),
runtime.failurePromise,
new Promise((_, reject) => {
initializeTimer = setTimeout(() => reject(agentExecutionError(
'LOCAL_AGENT_PROTOCOL_UNAVAILABLE',
`${protocolLabel} did not answer the ACP initialize request.`,
)), initializeTimeoutMs)
}),
new Promise((_, reject) => {
abortListener = () => reject(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
if (abortSignal?.aborted) abortListener()
else abortSignal?.addEventListener('abort', abortListener, { once: true })
}),
])
runtime.agentCapabilities = initialized?.agentCapabilities
&& typeof initialized.agentCapabilities === 'object'
? initialized.agentCapabilities
: {}
} catch (error) {
await closeAcpRuntime(runtime)
if (abortSignal?.aborted) throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
throw allowAcpSetupFallback(acpProtocolError(error, childEnv))
} finally {
clearTimeout(initializeTimer)
if (abortListener) abortSignal?.removeEventListener('abort', abortListener)
acpInitializingRuntimes.delete(runtime)
}
return runtime
@@ -745,7 +906,7 @@ function publicSessionRef(value, childEnv) {
return safe.includes('[redacted]') ? '' : safe
}
async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
async function runAcpTurn(runtime, prompt, options, spec, profile) {
if (runtime.closed || runtime.failureError) {
throw runtime.failureError || agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
}
@@ -753,6 +914,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
try { options.onActivity?.() } catch { /* activity is best-effort */ }
}
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, runtime.childEnv)
const completion = options.turnCompletion === true ? createTurnCompletion() : null
if (completion) prompt = `${prompt}\n\n${completion.instruction}`
const capabilityWarning = connectorLimitedRuntimeEvent(runtime.agent.kind, profile)
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
const turn = {
@@ -767,8 +930,13 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
noteActivity,
outboundPayloadFailed: false,
pendingPermissionCallbacks: new Set(),
completion,
openTools: new Set(),
repetitionGuard: ['opencode', 'mimo', 'hermes'].includes(runtime.agent.kind) ? createOutputRepetitionGuard() : null,
}
runtime.activeTurn = turn
runtime.stderr = []
runtime.stderrBytes = 0
runtime.replyState.bytes = 0
runtime.replyState.inputBytes = 0
runtime.replyState.observedInputBytes = 0
@@ -785,12 +953,16 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
runtime.publicSessionRef = visibleSessionRef
}
let promptStarted = false
let promptRequest = null
let caughtError = null
let abortRequested = false
let timeoutRequested = false
let cancelPromise = Promise.resolve()
let rejectAbort
let resolveContribution
const contribution = new Promise(resolve => { resolveContribution = resolve })
const abortPromise = new Promise((_, reject) => { rejectAbort = reject })
const abort = () => {
const abort = (error = agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')) => {
if (abortRequested) return
abortRequested = true
if (protocolSessionRef) {
@@ -800,7 +972,16 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
})).catch(() => {})
} catch { /* the ACP stream has already closed */ }
}
rejectAbort(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
rejectAbort(error instanceof Error ? error : agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
}
turn.stopRepeatedOutput = () => {
runtime.replyState.collecting = false
abort(agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT',
'The Agent repeated the same response without tool progress; the native turn was cancelled.'))
}
turn.finishContribution = () => {
runtime.replyState.collecting = false
resolveContribution({ stopReason: 'end_turn' })
}
const operation = promise => Promise.race([promise, abortPromise, runtime.failurePromise])
const timeout = setTimeout(() => {
@@ -812,26 +993,26 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
try {
if (!protocolSessionRef) {
if (persistent && options.sessionRef) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (spec.acpSessionStrategy !== 'new' && options.sessionRef) {
protocolSessionRef = String(options.sessionRef)
protocolSessionRef = runtime.agent.kind === 'deepseek'
? String(options.sessionRef).slice(`dsh-${spec.deepSeekMode}:`.length)
: String(options.sessionRef)
await operation(runtime.connection.resumeSession({
sessionId: protocolSessionRef,
cwd: runtime.workdir,
mcpServers: [],
}))
visibleSessionRef = protocolSessionRef
visibleSessionRef = runtime.agent.kind === 'deepseek'
? String(options.sessionRef) : protocolSessionRef
} else {
const session = await operation(runtime.connection.newSession({
cwd: runtime.workdir,
mcpServers: [],
}))
protocolSessionRef = String(session.sessionId || '')
visibleSessionRef = String(
spec.publicSessionRef || options.sessionRef || protocolSessionRef,
)
visibleSessionRef = runtime.agent.kind === 'deepseek'
? `dsh-${spec.deepSeekMode}:${protocolSessionRef}`
: String(spec.publicSessionRef || options.sessionRef || protocolSessionRef)
}
if (!protocolSessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
runtime.protocolSessionRef = protocolSessionRef
@@ -847,14 +1028,28 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
if (visibleSessionRef && typeof options.onSessionRef === 'function') {
await operation(options.onSessionRef(visibleSessionRef, { transport: 'acp' }))
}
const imageBlocks = acpImageBlocks(runtime, spec.imageAttachments)
runtime.replyState.collecting = true
let promptResult
try {
promptStarted = true
promptResult = await operation(runtime.connection.prompt({
promptRequest = Promise.resolve(runtime.connection.prompt({
sessionId: protocolSessionRef,
prompt: [{ type: 'text', text: prompt }],
prompt: [{ type: 'text', text: prompt }, ...imageBlocks],
}))
promptRequest.catch(() => {})
promptResult = await operation(Promise.race([promptRequest, contribution]))
if (completion?.completed) {
await operation(settleWithin(Promise.resolve(runtime.connection.cancel({ sessionId: protocolSessionRef }))
.catch(() => {}), ACP_CANCEL_GRACE_MS))
let settled = false
await operation(settleWithin(promptRequest.then(() => { settled = true }, () => { settled = true }),
ACP_CANCEL_SETTLE_MS))
if (!settled) runtime.contributionNeedsClose = true
}
if (runtime.agent.kind === 'mimo' && turn.lastMessageId) {
await operation(new Promise(resolve => setTimeout(resolve, MIMO_TERMINAL_DRAIN_MS)))
}
} finally {
runtime.replyState.collecting = false
}
@@ -883,15 +1078,21 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
runtimeEvents.finalize({ status: 'failed' })
throw error
}
const normalized = acpProtocolError(error, runtime.childEnv, visibleSessionRef)
const normalized = acpProtocolError(error, runtime.childEnv, visibleSessionRef, runtime.agent.kind)
runtimeEvents.finalize({
status: timeoutRequested
? 'timeout'
: normalized.failure?.category === 'cancellation' ? 'stopped' : 'failed',
})
if (promptStarted && options.sessionRef
&& runtime.replyState.bytes === 0
&& normalized.message === 'LOCAL_AGENT_REFUSED') {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!promptStarted && normalized.message !== 'LOCAL_AGENT_SESSION_INVALID') {
allowAcpSetupFallback(normalized)
}
caughtError = normalized
throw normalized
} finally {
clearTimeout(timeout)
@@ -904,7 +1105,26 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
turn.pendingPermissionCallbacks.clear()
runtime.activeTurn = null
runtime.lastUsedAt = Date.now()
if (abortRequested) await settleWithin(cancelPromise, ACP_CANCEL_GRACE_MS)
if (abortRequested) {
await settleWithin(cancelPromise, ACP_CANCEL_GRACE_MS)
// A user stop or watchdog timeout used to destroy the process and its
// native session. Keep both when the Agent honours the cancellation.
let promptSettled = false
// Disposable runtimes are always closed after the turn; only a
// persistent runtime is worth waiting for.
if (promptRequest && !acpDisposableRuntimes.has(runtime)) {
await settleWithin(
promptRequest.then(() => { promptSettled = true }, () => { promptSettled = true }),
ACP_CANCEL_SETTLE_MS,
)
}
if (caughtError && caughtError.code !== 'LOCAL_AGENT_REPEATED_OUTPUT'
&& promptSettled && !runtime.closed && !runtime.failureError) {
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
value: true, enumerable: false, configurable: true,
})
}
}
}
}
@@ -955,9 +1175,6 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
runtime = null
if (options.sessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!runtime && options.sessionRef) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!runtime) {
if (shutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
@@ -969,13 +1186,19 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
}
runtime.active = true
try {
return await runAcpTurn(runtime, prompt, options, spec, profile, true)
return await runAcpTurn(runtime, prompt, options, spec, profile)
} catch (error) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
if (error?.acpRuntimeReusable !== true) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
}
throw error
} finally {
runtime.active = false
if (runtime.contributionNeedsClose) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
}
runtime.lastUsedAt = Date.now()
await evictIdleAcpRuntimes()
}
@@ -1003,7 +1226,7 @@ async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, prof
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
acpDisposableRuntimes.add(runtime)
return await runAcpTurn(runtime, prompt, options, spec, profile, false)
return await runAcpTurn(runtime, prompt, options, spec, profile)
} finally {
if (runtime) {
acpDisposableRuntimes.delete(runtime)
@@ -1013,6 +1236,10 @@ async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, prof
}
async function runAcpAgent(agent, prompt, workdir, options, spec, profile) {
if (agent.kind === 'deepseek' && options.sessionRef
&& !new RegExp(`^dsh-${spec.deepSeekMode}:[A-Za-z0-9][A-Za-z0-9._-]{0,200}$`).test(options.sessionRef)) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (options.signal?.aborted
|| options.acpShutdownGeneration != null
&& options.acpShutdownGeneration !== acpShutdownGeneration) {
+89 -31
View File
@@ -15,6 +15,7 @@ const {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -29,6 +30,7 @@ const {
shutdownAcpSessionRuntime,
} = require('./cli-acp-runner.cjs')
const { withOpenClawGateway } = require('./cli-openclaw-gateway.cjs')
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
const { imageAttachmentLimit, invocation } = require('./cli-invocations.cjs')
const {
connectorLimitedRuntimeEvent,
@@ -44,14 +46,27 @@ const {
agentExecutionError,
childEnvironment,
failedAgentProcessError,
terminateWindowsProcessTree,
} = require('./cli-process-support.cjs')
const { createLegacyOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
const { authFailureCertainty } = require('../../runs/failure-signals.cjs')
const MAX_PROGRESS_STEPS = 8
const MAX_STDOUT_CAPTURE_BYTES = 10 * 1024 * 1024
const MAX_STDERR_CAPTURE_BYTES = 1024 * 1024
const OUTPUT_TRUNCATION_MARKER = Buffer.from('\n[output truncated]\n')
const BROKEN_INPUT_GRACE_MS = 5000
// A step that ends because the model asked for tools is followed by more
// steps (the tools run, then the model continues); only the last step's finish
// ends the run. Ending the process after the first one cut every tool-using
// MiMo or OpenCode turn short.
function stepFinishContinues(event) {
const reason = String(event?.part?.reason || event?.reason || '').toLowerCase()
return /^tool[-_ ]?(calls?|use)$/.test(reason)
}
function createBoundedOutputCapture(maxBytes) {
const headLimit = Math.floor(maxBytes / 2)
const tailLimit = maxBytes - headLimit
@@ -122,13 +137,16 @@ async function runAgent(agent, prompt, workdir, options = {}) {
? options.hermesAcpAvailable
: agent.acpAvailable
const spec = invocation(agent.kind, agent.executable, workdir, sessionRef, {
platform,
...(agent.kind === 'deepseek' ? { env: options.env } : {}),
sandbox: options.sandbox,
provider: options.provider,
attachments: options.attachments,
hermesAcpAvailable,
hermesStreamJsonAvailable: agent.streamJsonAvailable,
invocationTransport: options.invocationTransport,
sessionTransport: options.sessionTransport,
maxTurns: options.maxTurns,
model: agent.kind === 'workbuddy' ? options.env?.CODEBUDDY_MODEL : undefined,
})
const profile = resolveConnectorEventProfile(agent.kind, {
transport: spec.eventTransport,
@@ -212,10 +230,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
}))
}
const childEnv = childEnvironment(agent, workdir, options, platform)
const repetitionGuard = ['opencode', 'mimo', 'hermes'].includes(agent.kind)
? createOutputRepetitionGuard() : null
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, childEnv)
const capabilityWarning = connectorLimitedRuntimeEvent(agent.kind, profile)
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
const profileRunContext = profile.createRunContext(options)
const profileRunContext = profile.createRunContext({ ...options, childEnv, workdir, platform })
return await new Promise((resolve, reject) => {
const rejectTerminal = (error, status = 'failed') => {
runtimeEvents.finalize({ status })
@@ -244,11 +264,13 @@ async function runAgent(agent, prompt, workdir, options = {}) {
let stopRequested = false
let timeoutRequested = false
let inputError = null
let brokenInputTimer = null
let timeout
let forceKillTimeout
let forceSettleTimeout
let terminalKillTimer
let terminalForceKillTimer
let windowsTermination = null
const progress = []
const progressIndexes = new Map()
let anonymousProgressId = 0
@@ -281,6 +303,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
const progressEvent = profile.mapProgress(event)
if (progressEvent) emitProgress(progressEvent)
for (const runtimeEvent of profile.mapEvent(event, runtimeState)) {
if (stopRequested || settled) return
if (repetitionGuard?.(runtimeEvent)) {
inputError = agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT')
abort()
return
}
runtimeEvents.emit(runtimeEvent)
}
})
@@ -293,6 +321,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
clearTimeout(forceSettleTimeout)
clearTimeout(terminalKillTimer)
clearTimeout(terminalForceKillTimer)
clearTimeout(brokenInputTimer)
options.signal?.removeEventListener('abort', abort)
callback()
}
@@ -312,9 +341,13 @@ async function runAgent(agent, prompt, workdir, options = {}) {
if (terminalObserved || !event || typeof event !== 'object') return
const terminal = (
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|| (agent.kind === 'pi' && ['turn_end', 'agent_end'].includes(event.type))
|| (agent.kind === 'hermes' && spec.eventTransport === 'stream-json' && event.type === 'result')
|| (agent.kind === 'pi' && (event.type === 'agent_settled'
|| (event.type === 'agent_end'
&& !Object.hasOwn(event, 'willRetry') && !Object.hasOwn(event, 'willContinue'))))
|| (agent.kind === 'codex' && event.type === 'turn.completed')
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish')
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish'
&& !stepFinishContinues(event))
|| (agent.kind === 'gemini' && event.type === 'result')
|| (agent.kind === 'kimi' && event.type === 'session.resume_hint')
)
@@ -332,21 +365,17 @@ async function runAgent(agent, prompt, workdir, options = {}) {
if (settled || stopRequested) return
stopRequested = true
const terminalError = inputError || new Error('LOCAL_AGENT_EXECUTION_STOPPED')
if (platform === 'win32' && child.pid) {
const killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore', windowsHide: true,
})
killer.on('error', () => {})
killer.unref()
forceSettleTimeout = setTimeout(() => finish(() => {
if (platform === 'win32') {
windowsTermination = terminateWindowsProcessTree(child, spawnFn)
windowsTermination.then(() => finish(() => rejectTerminal(
terminalError,
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
)), error => finish(() => {
child.stdin.destroy()
child.stdout.destroy()
child.stderr.destroy()
rejectTerminal(
terminalError,
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
)
}), KILL_SETTLE_MS)
rejectTerminal(error, 'failed')
}))
return
}
signalProcessTree('SIGTERM')
@@ -383,22 +412,40 @@ async function runAgent(agent, prompt, workdir, options = {}) {
})
child.stdin.on('error', (error) => {
if (settled || inputError) return
inputError = agentExecutionError(
const sendFailure = () => agentExecutionError(
'LOCAL_AGENT_SEND_FAILED',
redactChildSecrets(error?.message || error, childEnv),
)
// A closed pipe usually means the CLI exited before it read the whole
// prompt (expired sign-in, rejected flag). Its own output says why, so
// let the process finish and report that. Only a process that stays
// alive without reading its input is a send failure.
if (['EPIPE', 'ERR_STREAM_DESTROYED', 'ECONNRESET'].includes(error?.code)) {
brokenInputTimer ||= setTimeout(() => {
if (settled || inputError) return
inputError = sendFailure()
abort()
}, BROKEN_INPUT_GRACE_MS)
return
}
inputError = sendFailure()
abort()
})
child.on('error', error => finish(() => rejectTerminal(
inputError || (stopRequested
? agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
: agentExecutionError(
'LOCAL_AGENT_SPAWN_FAILED',
redactChildSecrets(error?.message || error, childEnv),
)),
stopRequested ? (inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped')) : 'failed',
)))
child.on('close', (code, signal) => finish(() => {
child.on('error', error => {
if (stopRequested && windowsTermination) return
finish(() => rejectTerminal(
inputError || (stopRequested
? agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
: agentExecutionError(
'LOCAL_AGENT_SPAWN_FAILED',
redactChildSecrets(error?.message || error, childEnv),
)),
stopRequested ? (inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped')) : 'failed',
))
})
child.on('close', (code, signal) => {
if (stopRequested && windowsTermination) return
finish(() => {
void (async () => {
runtimeStreamParser?.end()
if (stopRequested || options.signal?.aborted) {
@@ -416,9 +463,16 @@ async function runAgent(agent, prompt, workdir, options = {}) {
})
let result = structuredOutput.end({
sessionRef: nextSessionRef,
prompt,
})
const rawStdout = stdout.text()
const structuredDetail = structuredCliError(rawStdout)
if (!String(result?.text || '').trim() && authFailureCertainty(rawStderr) === 'definitive') {
rejectTerminal(failedAgentProcessError(redactChildSecrets(rawStderr, childEnv), {
sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL,
}))
return
}
const usableNonZeroResult = code !== 0
&& result
&& typeof result.text === 'string'
@@ -426,16 +480,18 @@ async function runAgent(agent, prompt, workdir, options = {}) {
&& ['completed', 'partial'].includes(result.outcome)
if (code !== 0 && !usableNonZeroResult) {
const detail = redactChildSecrets(
[rawStderr, structuredDetail].filter(Boolean).join('\n'),
[result?.error, rawStderr, structuredDetail].filter(Boolean).join('\n'),
childEnv,
)
rejectTerminal(failedAgentProcessError(detail, { sessionRef: failureSessionRef }))
rejectTerminal(failedAgentProcessError(detail, {
sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL,
}))
return
}
if (result.error) {
rejectTerminal(failedAgentProcessError(
redactChildSecrets(result.error, childEnv),
{ sessionRef: failureSessionRef },
{ sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL },
))
return
}
@@ -482,7 +538,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
runtimeEvents.finalize({ text: redactedText, status: output.outcome })
resolve(output)
})().catch(error => rejectTerminal(error))
}))
})
})
if (spec.stdin) child.stdin.end(stdin)
else child.stdin.end()
})
@@ -499,6 +556,7 @@ module.exports = {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
prepareCommand,
readHermesFinalResponse,
@@ -0,0 +1,12 @@
const CONFIG_DIRECTORY_ENV_KEYS = Object.freeze([
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME',
'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR',
])
function configDirectoryEnvironment(source = {}) {
return Object.fromEntries(CONFIG_DIRECTORY_ENV_KEYS
.filter(key => typeof source[key] === 'string')
.map(key => [key, source[key]]))
}
module.exports = { CONFIG_DIRECTORY_ENV_KEYS, configDirectoryEnvironment }
+286 -85
View File
@@ -1,11 +1,20 @@
const { execFile } = require('node:child_process')
const { createHmac, randomBytes } = require('node:crypto')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { promisify } = require('node:util')
const { networkEnvironment } = require('./cli-network-environment.cjs')
const { SECRET_ENV_KEYS, nativeSecretEnvKeys } = require('./native-secret-environment.cjs')
const { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_AUTH_IDENTITY_FILES,
nativeIdentityDirectory, nativeAuthIdentityFiles, nativeNetworkIdentityFiles } = require('../native-config-directory.cjs')
const { nativeCredentialEnvironment } = require('../agent-native-environment.cjs')
const { redactChildSecrets } = require('./cli-runtime-event-sanitizer.cjs')
const { authFailureCertainty } = require('../../runs/failure-signals.cjs')
const {
assessAgentVersion,
capabilityProbes,
extractAgentVersion,
identifyAgentVersion,
} = require('../agent-compatibility.cjs')
@@ -15,9 +24,11 @@ const AGENT_PROFILES = {
hermes: { label: 'Hermes', commands: ['hermes'] },
openclaw: { label: 'OpenClaw', commands: ['openclaw'] },
workbuddy: { label: 'WorkBuddy', commands: ['codebuddy'] },
zcode: { label: 'Zcode', commands: ['zcode'] },
pi: { label: 'Pi Agent', commands: ['pi', 'pi-agent', 'piagent'] },
kimi: { label: 'Kimi', commands: ['kimi'] },
mimo: { label: 'MiMo', commands: ['mimo'] },
deepseek: { label: 'DeepSeek Harness', commands: ['dsh'] },
claude: { label: 'Claude', commands: ['claude'] },
gemini: { label: 'Gemini', commands: ['gemini'] },
opencode: { label: 'OpenCode', commands: ['opencode'] },
@@ -26,6 +37,10 @@ const AGENT_PROFILES = {
}
const ALLOWED_KINDS = Object.keys(AGENT_PROFILES)
const DEFAULT_WINDOWS_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD']
const CAPABILITY_CACHE_TTL_MS = 5 * 60 * 1000
const capabilityCache = new Map()
const activeCapabilityProbes = new Map()
const capabilityEnvironmentKey = randomBytes(32)
const SYSTEM_CHILD_ENV_KEYS = Object.freeze([
'HOME', 'USER', 'LOGNAME', 'SHELL',
'TMPDIR', 'TMP', 'TEMP',
@@ -38,18 +53,24 @@ const SYSTEM_CHILD_ENV_KEYS = Object.freeze([
'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432',
'OS', 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'NUMBER_OF_PROCESSORS',
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
])
function envValue(env, name) {
const match = Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase())
function envValue(env, name, platform = process.platform) {
const match = Object.hasOwn(env, name) ? name
: platform === 'win32' ? Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) : undefined
return match ? env[match] : ''
}
function nativeConfigDirectoryEnvironment(kind, env, platform = process.platform) {
const key = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, kind) ? NATIVE_CONFIG_DIRECTORIES[kind].key : ''
const keys = kind === 'deepseek' ? [key, 'DSH_AGENTS_HOME'] : [key]
return Object.fromEntries(keys.filter(Boolean).map(name => [name, envValue(env, name, platform)])
.filter(([, value]) => typeof value === 'string' && value))
}
function systemChildEnvironment(sourceEnv = process.env, platform = process.platform) {
const env = {}
const env = networkEnvironment(sourceEnv, platform)
for (const name of SYSTEM_CHILD_ENV_KEYS) {
const value = envValue(sourceEnv, name)
const value = envValue(sourceEnv, name, platform)
if (typeof value === 'string' && value) env[name] = value
}
if (platform === 'win32') env.USERPROFILE ||= os.homedir()
@@ -99,12 +120,16 @@ function versionManagerBins(root, suffix, options = {}) {
.map(name => pathApi.join(root, name, ...suffix))
}
const DETECTION_SEARCH_DIRECTORIES = Symbol('detectionSearchDirectories')
function searchDirectories(options = {}) {
if (options[DETECTION_SEARCH_DIRECTORIES]) return options[DETECTION_SEARCH_DIRECTORIES]
const { platform, env, home, pathApi } = runtimeOptions(options)
const configured = String(envValue(env, 'PATH') || '').split(pathApi.delimiter)
const pnpmHome = envValue(env, 'PNPM_HOME')
const readEnv = name => envValue(env, name, platform)
const configured = String(readEnv('PATH') || '').split(pathApi.delimiter)
const pnpmHome = readEnv('PNPM_HOME')
if (platform !== 'win32') {
const fnmMultishellPath = envValue(env, 'FNM_MULTISHELL_PATH')
const fnmMultishellPath = readEnv('FNM_MULTISHELL_PATH')
const fnmDataRoots = [
pathApi.join(home, '.local', 'share', 'fnm', 'node-versions'),
platform === 'darwin'
@@ -114,11 +139,11 @@ function searchDirectories(options = {}) {
return uniquePaths([
...configured,
pnpmHome,
envValue(env, 'NVM_BIN'),
envValue(env, 'BUN_INSTALL') && pathApi.join(envValue(env, 'BUN_INSTALL'), 'bin'),
envValue(env, 'ASDF_DATA_DIR') && pathApi.join(envValue(env, 'ASDF_DATA_DIR'), 'shims'),
envValue(env, 'MISE_DATA_DIR') && pathApi.join(envValue(env, 'MISE_DATA_DIR'), 'shims'),
envValue(env, 'NODENV_ROOT') && pathApi.join(envValue(env, 'NODENV_ROOT'), 'shims'),
readEnv('NVM_BIN'),
readEnv('BUN_INSTALL') && pathApi.join(readEnv('BUN_INSTALL'), 'bin'),
readEnv('ASDF_DATA_DIR') && pathApi.join(readEnv('ASDF_DATA_DIR'), 'shims'),
readEnv('MISE_DATA_DIR') && pathApi.join(readEnv('MISE_DATA_DIR'), 'shims'),
readEnv('NODENV_ROOT') && pathApi.join(readEnv('NODENV_ROOT'), 'shims'),
fnmMultishellPath && pathApi.join(fnmMultishellPath, 'bin'),
pathApi.join(home, '.volta', 'bin'),
platform === 'darwin' && pathApi.join(home, 'Library', 'pnpm'),
@@ -143,15 +168,15 @@ function searchDirectories(options = {}) {
], platform)
}
const appData = envValue(env, 'APPDATA')
const localAppData = envValue(env, 'LOCALAPPDATA')
const programFiles = envValue(env, 'ProgramFiles')
const programFilesX86 = envValue(env, 'ProgramFiles(x86)')
const programData = envValue(env, 'ProgramData')
const chocolatey = envValue(env, 'ChocolateyInstall')
const scoop = envValue(env, 'SCOOP')
const nvmHome = envValue(env, 'NVM_HOME')
const nvmSymlink = envValue(env, 'NVM_SYMLINK')
const appData = readEnv('APPDATA')
const localAppData = readEnv('LOCALAPPDATA')
const programFiles = readEnv('ProgramFiles')
const programFilesX86 = readEnv('ProgramFiles(x86)')
const programData = readEnv('ProgramData')
const chocolatey = readEnv('ChocolateyInstall')
const scoop = readEnv('SCOOP')
const nvmHome = readEnv('NVM_HOME')
const nvmSymlink = readEnv('NVM_SYMLINK')
const workBuddyCli = base => base && pathApi.join(
base, 'WorkBuddy', 'resources', 'app.asar.unpacked', 'cli', 'bin',
)
@@ -188,8 +213,33 @@ function searchPath(options = {}) {
}
function agentCommands(kind, options = {}) {
const { platform, home, pathApi } = runtimeOptions(options)
const { platform, home, pathApi, env } = runtimeOptions(options)
const commands = [...AGENT_PROFILES[kind].commands]
if (kind === 'hermes' && platform !== 'win32') {
const configuredHome = String(envValue(env, 'HERMES_HOME', platform) || '').replace(/^~(?=$|[\\/])/, home)
const installDir = envValue(env, 'HERMES_INSTALL_DIR', platform)
const suffix = envValue(env, 'HERMES_DATA_DIR_SUFFIX', platform) || ''
const homes = [configuredHome, pathApi.join(home, `.hermes${suffix}`)]
.filter(value => typeof value === 'string' && pathApi.isAbsolute(value))
const roots = [installDir, ...homes.map(value => pathApi.join(value, 'hermes-agent')), ...homes]
.filter(value => typeof value === 'string' && pathApi.isAbsolute(value))
for (const root of roots) {
commands.push(pathApi.join(root, '.hermes', 'bin', 'hermes'))
commands.push(pathApi.join(root, 'venv', 'bin', 'hermes'))
}
}
if (kind === 'hermes' && platform === 'win32') {
const configuredHome = String(envValue(env, 'HERMES_HOME', platform) || '').replace(/^~(?=$|[\\/])/, home)
const localAppData = envValue(env, 'LOCALAPPDATA', platform) || pathApi.join(home, 'AppData', 'Local')
const suffix = envValue(env, 'HERMES_DATA_DIR_SUFFIX', platform) || ''
const homes = [configuredHome, pathApi.join(localAppData, `hermes${suffix}`)]
.filter(value => typeof value === 'string' && pathApi.isAbsolute(value))
for (const root of homes) {
commands.push(pathApi.join(root, 'bin', 'hermes'))
commands.push(pathApi.join(root, 'hermes-agent', 'venv', 'Scripts', 'hermes'))
commands.push(pathApi.join(root, 'venv', 'Scripts', 'hermes'))
}
}
if (kind === 'codex' && platform === 'darwin') {
commands.push('/Applications/ChatGPT.app/Contents/Resources/codex')
commands.push(pathApi.join(
@@ -206,10 +256,27 @@ function agentCommands(kind, options = {}) {
return commands
}
// The WorkBuddy app ships its CLI with only the lite bundle, which the
// launcher loads solely when this variable is set. Without it every headless
// call, `--help` included, exits with MODULE_NOT_FOUND. Installs that carry a
// regular bundle launch exactly as before.
function launchEnvironment(kind, executable, options = {}) {
if (kind !== 'workbuddy' || !executable) return {}
const { pathApi } = runtimeOptions(options)
try {
const dist = pathApi.join(pathApi.dirname(fs.realpathSync(executable)), '..', 'dist')
const shipped = name => fs.existsSync(pathApi.join(dist, name))
if (shipped('codebuddy-lite-wb.mjs') && !shipped('codebuddy-headless.js') && !shipped('codebuddy.js')) {
return { CODEBUDDY_FORCE_LITE_WB_BUNDLE: '1' }
}
} catch { /* an unreadable layout keeps the default launch */ }
return {}
}
function executableExtensions(options = {}) {
const { platform, env } = runtimeOptions(options)
if (platform !== 'win32') return ['']
const configured = String(envValue(env, 'PATHEXT') || '')
const configured = String(envValue(env, 'PATHEXT', platform) || '')
.split(';')
.map(extension => extension.trim())
.filter(Boolean)
@@ -243,6 +310,15 @@ async function* resolvedExecutableCandidates(kind, options = {}) {
if (seen.has(key)) continue
seen.add(key)
try {
if (kind === 'hermes' && platform !== 'win32' && pathApi.isAbsolute(command)
&& candidate.endsWith('/venv/bin/hermes')) {
const installRoot = pathApi.dirname(pathApi.dirname(pathApi.dirname(candidate)))
try {
if ((await (options.statFn || fs.promises.stat)(pathApi.join(installRoot, 'pm'))).isDirectory()) continue
} catch (error) {
if (error?.code !== 'ENOENT') continue
}
}
await accessFn(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK)
yield candidate
} catch { /* keep searching */ }
@@ -330,52 +406,152 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
const runtime = runtimeOptions(options)
const { env, execFileFn, platform } = runtime
const prepareCommandFn = options.prepareCommandFn || prepareCommand
const childEnv = options.childEnv || {
...systemChildEnvironment(env, platform),
PATH: searchPath(options),
const childEnv = {
...(options.childEnv || {
...systemChildEnvironment(env, platform),
...nativeConfigDirectoryEnvironment(kind, env, platform),
...(kind === 'openclaw' ? {} : nativeCredentialEnvironment(kind, env, options.nativeCredentialKeys, platform)),
PATH: searchPath(options),
}),
...launchEnvironment(kind, executable, options),
}
const results = await Promise.all(capabilityProbes(kind).map(async (probe) => {
try {
const command = prepareCommandFn(executable, probe.args, options)
const result = await execFileFn(command.command, command.args, {
timeout: 8000,
windowsHide: true,
env: childEnv,
})
return {
probe,
output: `${result.stdout || ''}\n${result.stderr || ''}`,
}
} catch {
return {
probe,
error: true,
}
let identity = ''
try {
const stat = (options.statFn || fs.statSync)(executable)
let shimTargetIdentity = null
if (platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable)) {
const target = npmShimTarget(executable, options)
if (!target) throw new Error('LOCAL_CLI_WRAPPER_UNSUPPORTED')
const targetStat = (options.statFn || fs.statSync)(target)
shimTargetIdentity = [target, targetStat.dev, targetStat.ino, targetStat.size,
targetStat.mtimeMs, targetStat.ctimeMs]
}
}))
for (const result of results) {
const { probe } = result
if (result.error) {
return {
compatibilityState: 'incompatible',
incompatibilityReason: probe.id.endsWith('-acp')
? 'LOCAL_AGENT_PROTOCOL_UNAVAILABLE'
: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
incompatibilityProbe: probe.id,
const environment = Object.keys(childEnv).sort().map(key => [key, childEnv[key]])
const environmentFingerprint = createHmac('sha256', capabilityEnvironmentKey)
.update(JSON.stringify(environment)).digest('hex')
const nativeRoot = nativeIdentityDirectory(kind, childEnv, runtime.home, platform)
if (!nativeRoot && platform === process.platform) throw new Error('native configuration unavailable')
// Loaded lazily because verification identities also use the executable shim parser in this module.
const { agentVerificationIdentity } = require('../../workspace/agent-verification-identity.cjs')
const nativeAgent = { kind, [NATIVE_CONFIG_IDENTITY_DIRECTORY]: nativeRoot,
[NATIVE_AUTH_IDENTITY_FILES]: [...nativeAuthIdentityFiles(kind, childEnv, runtime.home, platform),
...nativeNetworkIdentityFiles(childEnv, platform)] }
const nativeConfiguration = agentVerificationIdentity(nativeAgent, {})
identity = JSON.stringify([
kind, executable, options.version || '', stat.dev, stat.ino,
stat.size, stat.mtimeMs, stat.ctimeMs, shimTargetIdentity, environmentFingerprint, nativeConfiguration,
])
} catch { /* caching requires a verifiable executable identity */ }
const cacheKey = `${kind}:${executable}`
let resolveCompletion
const record = { identity, completion: new Promise(resolve => { resolveCompletion = resolve }) }
const group = activeCapabilityProbes.get(cacheKey) || { latest: null, active: 0 }
group.latest = record
group.active += 1
activeCapabilityProbes.set(cacheKey, group)
try {
const outcome = await (async () => {
const prior = capabilityCache.get(cacheKey)
const now = Date.now()
const recentCompatible = identity && prior?.identity === identity
&& now >= prior.checkedAt && now - prior.checkedAt < CAPABILITY_CACHE_TTL_MS
const results = await Promise.all(capabilityProbes(kind).map(async (probe) => {
for (const timeout of probe.timeout ? [probe.timeout] : [8000, 16000]) {
try {
const command = prepareCommandFn(executable, probe.args, options)
const execution = execFileFn(command.command, command.args, {
timeout,
windowsHide: true,
env: childEnv,
})
if (probe.id === 'pi-json') execution.child?.stdin?.end()
const result = await execution
const output = `${result.stdout || ''}\n${result.stderr || ''}`
const missingCapabilities = probe.requiredText.some(value => !output.includes(value))
|| (probe.validateOutput && !probe.validateOutput(output))
return { probe, output, authRequired: missingCapabilities && authFailureCertainty(output) === 'definitive' }
} catch (error) {
const timedOut = error?.killed === true || error?.code === 'ETIMEDOUT'
if (timedOut && timeout === 8000) continue
const diagnostic = `${error?.stdout || ''}\n${error?.stderr || ''}\n${error?.message || ''}`
return { probe, error: true, timedOut, systemError: typeof error?.code === 'string' && !timedOut,
authRequired: authFailureCertainty(diagnostic) === 'definitive' }
}
}
}))
while (group.latest !== record) {
const latest = group.latest
const newer = await latest.completion
if (group.latest !== latest) continue
return identity && identity === latest.identity
? newer : { compatibilityState: 'unknown', incompatibilityReason: '', incompatibilityProbe: '' }
}
}
if (probe.requiredText.some(value => !result.output.includes(value))) {
return {
compatibilityState: 'incompatible',
incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
incompatibilityProbe: probe.id,
const failed = results.find(result => !result.timedOut && !result.systemError && !result.authRequired && (result.error
|| result.probe.requiredText.some(value => !result.output.includes(value))
|| (result.probe.validateOutput && !result.probe.validateOutput(result.output))))
if (group.latest === record
&& (failed || results.some(result => result.systemError || result.authRequired))) capabilityCache.delete(cacheKey)
for (const result of results) {
const { probe } = result
if (result.authRequired && !failed) {
return {
compatibilityState: 'unknown',
incompatibilityReason: 'LOCAL_AGENT_AUTH_REQUIRED',
incompatibilityProbe: probe.id,
}
}
if (result.authRequired) continue
if (result.timedOut && !failed) {
if (recentCompatible) continue
return {
compatibilityState: 'unknown',
incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
incompatibilityProbe: probe.id,
}
}
if (result.timedOut) continue
if (result.systemError && !failed) {
return { compatibilityState: 'unknown', incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_FAILED',
incompatibilityProbe: probe.id }
}
if (result.systemError) continue
if (result.error) {
return {
compatibilityState: 'incompatible',
incompatibilityReason: probe.id.endsWith('-acp')
? 'LOCAL_AGENT_PROTOCOL_UNAVAILABLE'
: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
incompatibilityProbe: probe.id,
}
}
if (probe.requiredText.some(value => !result.output.includes(value))
|| (probe.validateOutput && !probe.validateOutput(result.output))) {
return {
compatibilityState: 'incompatible',
incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
incompatibilityProbe: probe.id,
}
}
}
}
}
return {
compatibilityState: 'compatible',
incompatibilityReason: '',
incompatibilityProbe: '',
if (group.latest === record && identity && !results.some(result => result.timedOut)) {
capabilityCache.set(cacheKey, { identity, checkedAt: Date.now() })
}
return {
compatibilityState: 'compatible',
incompatibilityReason: '',
incompatibilityProbe: '',
...(kind === 'hermes' && results.some(result => result.output?.includes('--format')
&& result.output.includes('stream-json')) ? { streamJsonAvailable: true } : {}),
}
})()
resolveCompletion(outcome)
return outcome
} catch (error) {
resolveCompletion({ compatibilityState: 'unknown', incompatibilityReason: '', incompatibilityProbe: '' })
throw error
} finally {
group.active -= 1
if (!group.active && activeCapabilityProbes.get(cacheKey) === group) activeCapabilityProbes.delete(cacheKey)
}
}
@@ -388,6 +564,12 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
incompatibilityProbe: '',
}
const identifiedVersion = (stdout, stderr) => {
const identified = identifyAgentVersion(kind, [stdout, stderr], { executable })
const version = identified && extractAgentVersion(identified.line) !== identified.resolvedVersion
? identified.resolvedVersion : identified?.line || ''
return { succeeded: true, version: redactChildSecrets(version, childEnv), versionIdentified: Boolean(identified) }
}
const versionTask = (async () => {
try {
const versionCommand = prepareCommandFn(executable, ['--version'], options)
@@ -396,24 +578,23 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
windowsHide: true,
env: childEnv,
})
const identified = identifyAgentVersion(kind, [result.stdout, result.stderr], { executable })
return {
succeeded: true,
version: identified?.line || '',
versionIdentified: Boolean(identified),
return identifiedVersion(result.stdout, result.stderr)
} catch (error) {
if (Number.isInteger(error?.code) && !error.killed && !error.signal) {
return identifiedVersion(error.stdout, error.stderr)
}
} catch { /* a broken shim is not a usable CLI */ }
/* Capability probes can still establish whether this installation works. */
}
return { succeeded: false, version: '', versionIdentified: false }
})()
const { succeeded: versionCommandSucceeded, version } = await versionTask
if (!versionCommandSucceeded) return null
const { version, versionIdentified } = await versionTask
const versionCompatibility = assessAgentVersion(kind, version)
const capabilityTask = Promise.resolve().then(async () => {
const result = await (options.probeAgentCapabilitiesFn || probeAgentCapabilities)(
kind,
executable,
{ ...options, childEnv },
{ ...options, childEnv, version },
)
return result && typeof result === 'object' ? result : incompatibleCapabilities
}).catch(() => incompatibleCapabilities)
@@ -433,8 +614,10 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
})()
: Promise.resolve(undefined)
const [capabilityCompatibility, acpAvailable] = await Promise.all([capabilityTask, acpTask])
const compatibility = capabilityCompatibility.compatibilityState === 'incompatible'
const compatibility = capabilityCompatibility.compatibilityState !== 'compatible'
? { ...versionCompatibility, ...capabilityCompatibility }
: kind === 'pi' && !version
? { ...versionCompatibility, compatibilityState: 'unknown', incompatibilityProbe: '' }
: {
...versionCompatibility,
compatibilityState: 'compatible',
@@ -446,8 +629,12 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
name: `${AGENT_PROFILES[kind].label} CLI`,
executable,
version,
...(!versionIdentified ? { versionIdentified: false } : {}),
...compatibility,
...(kind === 'hermes' ? { acpAvailable } : {}),
...(kind === 'hermes' ? {
acpAvailable,
...(capabilityCompatibility.streamJsonAvailable ? { streamJsonAvailable: true } : {}),
} : {}),
}
}
@@ -457,27 +644,39 @@ async function detectAgentKind(kind, options, childEnv) {
: null
const candidates = typeof options.resolveExecutableFn === 'function'
? (resolved ? [resolved] : [])
: resolvedExecutableCandidates(kind, options)
: resolvedExecutableCandidates(kind, kind === 'hermes'
? { ...options, env: { ...options.env, HERMES_HOME: childEnv.HERMES_HOME,
HERMES_INSTALL_DIR: childEnv.HERMES_INSTALL_DIR } } : options)
let firstIncompatible = null
for await (const executable of candidates) {
const candidate = await inspectAgentCandidate(kind, executable, options, childEnv)
if (!candidate) continue
if (candidate.compatibilityState === 'compatible') return candidate
if (candidate.compatibilityState === 'compatible' && candidate.versionIdentified !== false) return candidate
firstIncompatible ||= candidate
}
return firstIncompatible
}
async function detectAgents(options = {}) {
const runtime = runtimeOptions(options)
const detectionOptions = { ...options, [DETECTION_SEARCH_DIRECTORIES]: searchDirectories(options) }
const runtime = runtimeOptions(detectionOptions)
const { env, platform } = runtime
const childEnv = {
...systemChildEnvironment(env, platform),
PATH: searchPath(options),
PATH: searchPath(detectionOptions),
}
const detected = await Promise.all(ALLOWED_KINDS.map(kind => (
detectAgentKind(kind, options, childEnv).catch(() => null)
)))
const detected = await Promise.all(ALLOWED_KINDS.map(kind => {
const secretKeys = typeof options.nativeCredentialKeysForKind === 'function'
? nativeSecretEnvKeys(options.nativeCredentialKeysForKind(kind)) : []
const nativeEnv = typeof options.nativeEnvironmentForKind === 'function'
? options.nativeEnvironmentForKind(kind) : {
...nativeConfigDirectoryEnvironment(kind, env, platform),
...(kind === 'openclaw' ? {} : nativeCredentialEnvironment(kind, env, secretKeys, platform)),
}
const kindEnv = { ...childEnv, ...nativeEnv }
Object.defineProperty(kindEnv, SECRET_ENV_KEYS, { value: secretKeys })
return detectAgentKind(kind, detectionOptions, kindEnv).catch(() => null)
}))
return detected.filter(Boolean)
}
@@ -485,6 +684,8 @@ module.exports = {
AGENT_PROFILES,
ALLOWED_KINDS,
detectAgents,
launchEnvironment,
npmShimTarget,
prepareCommand,
probeAgentCapabilities,
resolveExecutable,
+128 -12
View File
@@ -1,5 +1,8 @@
const { StringDecoder } = require('node:string_decoder')
const { createHash } = require('node:crypto')
const os = require('node:os')
const path = require('node:path')
const { agentRuntimeFailure } = require('../agent-runtime-contract.cjs')
const {
classifyCliOutcome,
codexProgressEvent,
@@ -182,19 +185,34 @@ function createHermesLegacyFinalOutputAccumulator(sessionRef = '') {
function createHermesLegacyRunContext(options = {}) {
const watermarkFn = options.hermesMessageWatermarkFn || readHermesMessageWatermark
const env = options.childEnv || {}
const home = options.home || env.HOME || os.homedir()
let hermesHome = String(env.HERMES_HOME || '').trim()
if (hermesHome) {
hermesHome = hermesHome.replace(/\$(\w+)|\$\{([^}]+)\}/g, (match, plain, braced) => env[plain || braced] ?? match)
if (hermesHome === '~' || hermesHome.startsWith('~/')) hermesHome = path.join(home, hermesHome.slice(2))
hermesHome = path.resolve(options.workdir || process.cwd(), hermesHome)
} else {
const suffix = env.HERMES_DATA_DIR_SUFFIX || ''
hermesHome = options.platform === 'win32'
? path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), `hermes${suffix}`)
: path.join(home, `.hermes${suffix}`)
}
try {
const messageWatermark = watermarkFn({
home: options.home,
hermesHome,
existsFn: options.hermesStateExistsFn,
queryFn: options.hermesStateQueryFn,
})
return {
hermesHome,
messageWatermark: Number.isSafeInteger(messageWatermark) && messageWatermark >= 0
? messageWatermark
: null,
}
} catch {
return { messageWatermark: null }
return { hermesHome, messageWatermark: null }
}
}
@@ -207,21 +225,30 @@ async function finalizeHermesLegacyResult(input = {}) {
if (sessionRef && typeof options.onSessionRef === 'function') {
await options.onSessionRef(sessionRef, { transport: 'legacy' })
}
if (!sessionRef || !Number.isSafeInteger(runContext?.messageWatermark)) return result
const finalResponseFn = options.hermesFinalResponseFn || readHermesFinalResponse
let finalResponse = ''
try {
finalResponse = finalResponseFn(sessionRef, {
home: options.home,
afterMessageId: runContext.messageWatermark,
existsFn: options.hermesStateExistsFn,
queryFn: options.hermesStateQueryFn,
})
} catch { /* fall back to the official --quiet stdout */ }
if (sessionRef && Number.isSafeInteger(runContext?.messageWatermark)) {
try {
finalResponse = finalResponseFn(sessionRef, {
home: options.home,
hermesHome: runContext.hermesHome,
afterMessageId: runContext.messageWatermark,
existsFn: options.hermesStateExistsFn,
queryFn: options.hermesStateQueryFn,
})
} catch { /* fall back to the official --quiet stdout */ }
}
const text = typeof finalResponse === 'string'
? finalResponse.trim().slice(0, MAX_FINAL_TEXT_CHARS)
: ''
return text ? { ...result, text } : result
const output = text ? { ...result, text } : result
const notice = /^(?:Your request was not processed\. Send it again if you still want me to carry it out\.|This turn did not complete\. Some actions may already have run; verify their effects before resending\.)$/m
const failureText = [result.text, text].map(value => String(value || '').replace(/\r/g, '').trim())
.find(candidate => notice.test(candidate)
&& (!candidate.includes('\n') || /^Provider said:\s*\S.*$/m.test(candidate)))
return failureText
? { ...output, outcome: 'failed', failure: agentRuntimeFailure('LOCAL_AGENT_PROCESS_FAILED'), diagnostic: failureText }
: output
}
function profile(input) {
@@ -450,14 +477,103 @@ const HERMES_FINAL_TEXT_PROFILE = profile({
createFinalOutputAccumulator: createHermesLegacyFinalOutputAccumulator,
})
const HERMES_STREAM_JSON_PROFILE = profile({
profileId: 'hermes-stream-json-v1',
protocol: 'hermes-stream-json',
framing: 'jsonl',
source: 'stdout',
capabilities: {
answerMode: 'delta', tools: START_RESULT_TOOL_LIFECYCLE,
plan: false, reasoning: false, session: true, terminal: true,
},
createDecoder: createJsonLineParser,
createState: createStatelessRuntimeState,
mapEvent(event) {
if (event.type === 'text' && typeof event.text === 'string') {
return [{ type: 'answer_delta', status: 'running', delta: event.text }]
}
if (['tool_use', 'tool_result'].includes(event.type)) {
return [{
id: event.tool_call_id || event.name,
type: event.type === 'tool_use' ? 'tool_start' : 'tool_result_summary',
title: event.name || 'tool',
status: event.type === 'tool_use' ? 'running' : event.is_error ? 'failed' : 'completed',
}]
}
return []
},
async finalizeResult({ result, options }) {
// Both native chat formats use the same resumable conversation IDs.
if (result.sessionRef) await options.onSessionRef?.(result.sessionRef, { transport: 'legacy' })
return result
},
createFinalOutputAccumulator(sessionRef = '') {
const state = { text: '', sessionRef, outcome: 'partial' }
const ingest = event => {
if (typeof event.session_id === 'string' && event.session_id) state.sessionRef = event.session_id
if (event.type === 'text' && typeof event.text === 'string') appendFinalText(state, event.text)
if (event.type === 'result') {
if (typeof event.text === 'string') state.text = event.text.slice(0, MAX_FINAL_TEXT_CHARS)
state.outcome = event.exit_code === 0 && !event.error ? 'completed' : 'failed'
if (event.error) state.error = String(event.error)
}
}
const parser = createJsonLineParser(ingest)
return {
ingest,
write: chunk => parser.write(chunk),
end() {
parser.end()
return { ...state }
},
}
},
})
const ZCODE_JSON_PROFILE = profile({
profileId: 'zcode-prompt-json-v1',
protocol: 'zcode-prompt-json',
framing: 'document',
source: 'stdout',
capabilities: {
answerMode: 'final', tools: NO_TOOL_LIFECYCLE,
plan: false, reasoning: false, session: true, terminal: true,
},
createState: createStatelessRuntimeState,
createDecoder: null,
mapEvent: finalOnlyRuntimeEvents,
createFinalOutputAccumulator() {
const decoder = new StringDecoder('utf8')
const raw = { text: '' }
return {
capture(chunk) { appendFinalText(raw, decoder.write(chunk)) },
end() {
appendFinalText(raw, decoder.end())
let value
try { value = JSON.parse(raw.text) } catch { return { text: '', outcome: 'failed' } }
if (typeof value.response !== 'string' || typeof value.sessionId !== 'string') {
return { text: '', outcome: 'failed' }
}
return {
text: value.response,
sessionRef: value.sessionId,
outcome: ['idle', 'completed'].includes(value.projection?.status) ? 'completed' : 'partial',
}
},
}
},
})
const CONNECTOR_EVENT_PROFILES = Object.freeze({
codex: CODEX_APP_SERVER_PROFILE,
hermes: ACP_JSONRPC_PROFILE,
openclaw: ACP_JSONRPC_PROFILE,
workbuddy: WORKBUDDY_TERMINAL_RESULT_PROFILE,
zcode: ZCODE_JSON_PROFILE,
pi: PI_JSON_EVENTS_PROFILE,
kimi: ACP_JSONRPC_PROFILE,
mimo: MIMO_JSON_EVENTS_PROFILE,
deepseek: ACP_JSONRPC_PROFILE,
claude: ANTHROPIC_STREAM_JSON_PROFILE,
gemini: GEMINI_STREAM_JSON_PROFILE,
opencode: OPENCODE_JSON_EVENTS_PROFILE,
@@ -466,7 +582,7 @@ const CONNECTOR_EVENT_PROFILES = Object.freeze({
})
const TRANSPORT_EVENT_PROFILES = Object.freeze({
hermes: Object.freeze({ legacy: HERMES_FINAL_TEXT_PROFILE }),
hermes: Object.freeze({ legacy: HERMES_FINAL_TEXT_PROFILE, 'stream-json': HERMES_STREAM_JSON_PROFILE }),
openclaw: Object.freeze({ legacy: OPENCLAW_TERMINAL_DOCUMENT_PROFILE }),
kimi: Object.freeze({ 'stream-json': KIMI_STREAM_JSON_PROFILE }),
mimo: Object.freeze({ acp: ACP_JSONRPC_PROFILE }),
+62 -29
View File
@@ -1,4 +1,5 @@
const path = require('node:path')
const { deepseekNativeConfig } = require('./deepseek-native-config.cjs')
const CODEX_SANDBOXES = new Set(['read-only', 'workspace-write'])
const IMAGE_ATTACHMENT_LIMITS = Object.freeze({
@@ -7,12 +8,13 @@ const IMAGE_ATTACHMENT_LIMITS = Object.freeze({
opencode: 4,
})
const DEFAULT_INTERNAL_TURN_LIMITS = Object.freeze({
// Meldwork already owns the outer discussion rounds. Keep WorkBuddy's
// nested tool loop bounded so one participant cannot dominate a round.
// Preserve the default for independent replies. Automatic discussions pass
// an explicit zero to let the native tool loop finish its work.
workbuddy: 8,
})
function internalTurnLimit(kind, requested) {
if (requested === 0) return 0
const value = Number(requested)
if (Number.isInteger(value) && value >= 1 && value <= 50) return value
return DEFAULT_INTERNAL_TURN_LIMITS[kind]
@@ -73,9 +75,24 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
}
}
if (kind === 'hermes') {
const legacySessionRef = options.sessionTransport === 'acp' ? '' : sessionRef
if (options.hermesStreamJsonAvailable === true
&& (options.hermesAcpAvailable === false || options.invocationTransport === 'stream-json'
|| (sessionRef && options.sessionTransport !== 'acp'))
&& (!sessionRef || options.sessionTransport !== 'acp')) {
return {
command: executable,
args: ['chat', '--format', 'stream-json',
...(options.sandbox === 'workspace-write' ? ['--yolo'] : []),
...(sessionRef ? ['--resume', sessionRef] : []),
...(attachments[0] ? ['--image', attachments[0]] : []), '--query'],
promptArg: true,
eventTransport: 'stream-json',
}
}
// Hermes uses the same native conversation identifier across its ACP and
// legacy transports. Keep it when an attachment requires the legacy path.
const legacySessionRef = sessionRef
const useAcp = options.invocationTransport !== 'legacy'
&& !attachments.length
&& options.hermesAcpAvailable !== false
&& (!sessionRef || options.sessionTransport === 'acp')
if (useAcp) {
@@ -86,6 +103,9 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
eventTransport: 'acp',
fallbackTransport: 'legacy',
fallbackSessionPolicy: 'invalidate',
// Hermes ACP advertises image prompts; send images as content blocks
// instead of silently dropping them from a text-only prompt.
...(attachments.length ? { imageAttachments: attachments } : {}),
}
}
return {
@@ -94,8 +114,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
'chat',
'--quiet',
...(options.sandbox === 'workspace-write' ? ['--yolo'] : []),
...(options.provider?.id ? ['--provider', options.provider.id] : []),
...(options.provider?.model ? ['--model', options.provider.model] : []),
...(legacySessionRef ? ['--resume', legacySessionRef] : []),
...(attachments[0] ? ['--image', attachments[0]] : []),
'--query',
@@ -143,20 +161,35 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
'--print',
'--output-format', 'stream-json',
'--include-partial-messages',
// Workspace write access means accepting file edits in the working
// directory. Skipping every permission check would also let the CLI
// run any command anywhere on the machine without confirmation.
'--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan',
...(maxTurns ? ['--max-turns', String(maxTurns)] : []),
...(options.model ? ['--model', options.model] : []),
...(sessionRef ? ['--resume', sessionRef] : []),
],
promptArg: true,
eventTransport: 'stream-json',
}
}
if (kind === 'zcode') {
return {
command: executable,
args: ['--json', '--no-color', '--verbose', '--cwd', workdir,
'--mode', options.sandbox === 'workspace-write' ? 'edit' : 'plan',
...(sessionRef ? ['--resume', sessionRef] : []), '--prompt'],
promptArg: true,
}
}
if (kind === 'pi') {
return {
command: executable,
args: [
'--mode', 'json', '--print',
...(options.sandbox === 'workspace-write' ? ['--approve'] : ['--no-approve']),
...(options.sandbox === 'workspace-write'
? ['--approve']
: ['--no-approve', '--tools', 'read,grep,find,ls']),
...(sessionRef ? ['--session', sessionRef] : []),
],
promptArg: true,
@@ -164,31 +197,20 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
}
}
if (kind === 'kimi') {
if (options.sandbox !== 'workspace-write') {
return {
command: executable,
args: ['acp'],
acpMode: 'plan',
eventTransport: 'acp',
}
}
// Kimi rejects --auto together with --prompt. Use the permission-aware
// ACP transport for both modes instead of bypassing approval with --yolo.
return {
command: executable,
args: [
'--output-format', 'stream-json',
'--auto',
...(sessionRef ? ['--session', sessionRef] : []),
'--prompt',
],
promptArg: true,
eventTransport: 'stream-json',
args: ['acp'],
acpMode: 'default',
eventTransport: 'acp',
}
}
if (kind === 'mimo') {
if (options.invocationTransport !== 'json') {
if (!attachments.length && options.invocationTransport !== 'json') {
return {
command: executable,
args: ['acp', '--pure', '--cwd', workdir],
args: ['acp', '--cwd', workdir],
acpMode: options.sandbox === 'workspace-write' ? 'build' : 'plan',
eventTransport: 'acp',
fallbackTransport: 'json',
@@ -198,13 +220,26 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
return {
command: executable,
args: [
'run', '--pure', '--agent', options.sandbox === 'workspace-write' ? 'build' : 'plan',
'run', '--agent', options.sandbox === 'workspace-write' ? 'build' : 'plan',
'--format', 'json', '--dir', workdir,
...(sessionRef ? ['--session', sessionRef] : []),
],
promptArg: true,
}
}
if (kind === 'deepseek') {
const mode = options.sandbox === 'workspace-write' ? 'write' : 'read'
// The external dsh process cannot read files inside Electron's ASAR.
const policyDirectory = __dirname.replace(/app\.asar(?=[\\/])/, 'app.asar.unpacked')
return {
command: executable,
args: ['--profile', 'acp', ...(deepseekNativeConfig({ env: options.env }).explicitModel
? [] : ['--patch', path.join(policyDirectory, 'deepseek-zgci.yml')]),
'--patch', path.join(policyDirectory, `deepseek-${mode}.yml`)],
eventTransport: 'acp',
deepSeekMode: mode,
}
}
if (kind === 'claude') {
return {
command: executable,
@@ -226,8 +261,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
'--output-format', 'stream-json',
'--include-partial-messages',
'--approval-mode', options.sandbox === 'workspace-write' ? 'auto-edit' : 'plan',
...(options.provider?.id === 'openai' ? ['--auth-type', 'openai'] : []),
...(options.provider?.model ? ['--model', options.provider.model] : []),
...(sessionRef ? ['--resume', sessionRef] : []),
],
promptArg: true,
@@ -249,7 +282,7 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
if (!attachments.length && options.invocationTransport !== 'json') {
return {
command: executable,
args: ['acp', '--pure', '--cwd', workdir],
args: ['acp', '--cwd', workdir],
acpMode: options.sandbox === 'workspace-write' ? 'build' : 'plan',
eventTransport: 'acp',
fallbackTransport: 'json',
@@ -0,0 +1,19 @@
const NETWORK_ENV_KEYS = Object.freeze([
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'NODE_USE_ENV_PROXY',
])
function networkEnvironment(source = {}, platform = process.platform) {
const result = {}
for (const [key, value] of Object.entries(source)) {
const allowed = platform === 'win32'
? NETWORK_ENV_KEYS.find(name => name.toLowerCase() === key.toLowerCase())
: NETWORK_ENV_KEYS.find(name => name === key)
if (allowed && typeof value === 'string') result[allowed] = value
}
return result
}
module.exports = { NETWORK_ENV_KEYS, networkEnvironment }
+23 -15
View File
@@ -4,10 +4,14 @@ const path = require('node:path')
const {
KILL_SETTLE_MS,
terminateWindowsProcessTree,
TERMINATE_GRACE_MS,
childEnvironment,
} = require('./cli-process-support.cjs')
const { configureOpenClawGatewayRuntime } = require('./openclaw-runtime.cjs')
const {
configureOpenClawGatewayRuntime,
refreshOpenClawRuntimeAfterStartup,
} = require('./openclaw-runtime.cjs')
const GATEWAY_READY_MARKER = '[gateway] ready'
const GATEWAY_READY_TIMEOUT_MS = 15_000
@@ -60,6 +64,14 @@ async function availableLoopbackPort() {
async function terminateGateway(child, platform = process.platform) {
if (!child || child.exitCode != null || child.signalCode != null) return
if (platform === 'win32') {
try { await terminateWindowsProcessTree(child, spawn) } finally {
for (const stream of [child.stdin, child.stdout, child.stderr]) {
if (stream && !stream.destroyed) stream.destroy()
}
}
return
}
await new Promise((resolve) => {
let settled = false
let forceKillTimeout
@@ -75,15 +87,6 @@ async function terminateGateway(child, platform = process.platform) {
}
child.once('close', finish)
child.once('error', finish)
if (platform === 'win32' && child.pid) {
const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore', windowsHide: true,
})
killer.on('error', () => {})
killer.unref()
forceSettleTimeout = setTimeout(finish, TERMINATE_GRACE_MS + KILL_SETTLE_MS)
return
}
const signalTree = (signal) => {
if (child.pid) {
try {
@@ -160,6 +163,8 @@ async function verifyGatewayHealth({
gatewayPort,
signal,
workdir,
spawnHealth = spawn,
terminateHealth = terminateGateway,
}) {
if (!gatewayChildAlive(gatewayChild)) throw gatewayStartError()
const args = [
@@ -170,7 +175,7 @@ async function verifyGatewayHealth({
]
let healthChild
try {
healthChild = spawn(executable, args, {
healthChild = spawnHealth(executable, args, {
cwd: path.resolve(workdir),
detached: process.platform !== 'win32',
env,
@@ -203,12 +208,12 @@ async function verifyGatewayHealth({
settled = true
cleanup()
const stopHealth = error && gatewayChildAlive(healthChild)
? terminateGateway(healthChild)
? terminateHealth(healthChild)
: Promise.resolve()
stopHealth.catch(() => {}).then(() => {
stopHealth.then(() => {
if (error) reject(error)
else resolve()
})
}, reject)
}
const onData = (chunk) => {
stdoutBytes += chunk.length
@@ -286,8 +291,11 @@ async function withOpenClawGateway(options, callback) {
signal: options.signal,
workdir: options.workdir,
})
runtimeOptions = refreshOpenClawRuntimeAfterStartup(runtimeOptions)
} catch (error) {
await terminateGateway(child)
// Startup can migrate the config before a port conflict or health failure.
runtimeOptions = refreshOpenClawRuntimeAfterStartup(runtimeOptions)
if (error?.retryable && attempt + 1 < GATEWAY_SETUP_ATTEMPTS) continue
throw attachRuntimeOptions(error, runtimeOptions)
}
@@ -308,4 +316,4 @@ async function withOpenClawGateway(options, callback) {
throw gatewayStartError()
}
module.exports = { withOpenClawGateway }
module.exports = { withOpenClawGateway, verifyGatewayHealth }
+103 -23
View File
@@ -91,10 +91,49 @@ function appendStructuredText(state, value, separator = '\n') {
}
function stripPiInternalPromptEcho(value) {
const text = String(value || '').trim()
let text = String(value || '').trim()
if (!text) return ''
const preamble = /^You are participating in a local multi-agent discussion\.[\s\S]*?^Answer the user naturally(?: in Markdown)?\.?\s*/im
return text.replace(preamble, '').trim()
text = text.replace(preamble, '').trim()
// Pi may resume from a compact continuation prompt and echo only the
// protocol instructions or the selected Harness records. Remove those
// internal lines while preserving any user-facing text on the same line.
text = text.replace(/^Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template\.\s*/im, '')
text = text.replace(/^Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission\.\s*/im, '')
const lines = text.split(/\r?\n/)
const cleaned = []
let inRecords = false
for (const line of lines) {
const valueLine = line.trim()
if (/^Selected records from completed phases:\s*$/i.test(valueLine)) {
inRecords = true
continue
}
if (inRecords) {
if (/^MELDWORK_V4_COLLABORATION_PACKAGE_V1$/i.test(valueLine)
|| /^\[index\]\s+\S+/i.test(valueLine)
|| /^Artifact:\s+\S+/i.test(valueLine)
|| /^Evidence:\s+\S+/i.test(valueLine)) continue
const phasePrefix = /^(?:\[(?:challenge|proposal)\]\s+agent=\S+\s+Completed the (?:challenge|proposal) phase\.\s*)/i
const remainder = line.replace(phasePrefix, '')
if (remainder !== line) {
if (remainder.trim()) cleaned.push(remainder)
continue
}
inRecords = false
}
cleaned.push(line)
}
return cleaned.join('\n').replace(/\n{3,}/g, '\n\n').trim()
}
function stripPiPromptEcho(value, prompt) {
const text = String(value || '').trim()
const expected = String(prompt || '').trim()
if (expected && text.startsWith(expected)) {
return text.slice(expected.length).trim()
}
return stripPiInternalPromptEcho(text)
}
function stripExactMeldworkPromptEcho(value, prompt) {
@@ -133,17 +172,38 @@ function stepFinishOutcome(reason) {
return 'partial'
}
function structuredResultDiagnostic(event) {
const parts = []
if (typeof event?.error === 'string' && event.error.trim()) parts.push(event.error.trim())
else if (event?.error?.message) parts.push(String(event.error.message))
if (typeof event?.result === 'string' && event.result.trim()) parts.push(event.result.trim())
if (Array.isArray(event?.errors)) {
parts.push(...event.errors.map(value => String(value || '').trim()).filter(Boolean))
}
return parts.join('\n')
}
function ingestStructuredEvent(state, event) {
if (!event || typeof event !== 'object' || Array.isArray(event)) return
state.seen = true
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)
&& event.type === 'result' && typeof event.result === 'string') {
&& event.type === 'result') {
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
state.failure = null
state.diagnostic = ''
state.text = ''
appendStructuredText(state, event.result, '')
state.outcome = event.is_error === true ? 'partial' : 'completed'
if (typeof event.result === 'string') {
state.failure = null
state.diagnostic = ''
state.text = ''
appendStructuredText(state, event.result, '')
state.outcome = event.is_error === true ? 'partial' : 'completed'
return
}
// An error result without a usable result string still carries the CLI's
// error list; keep it as the failure diagnostic so authentication and
// session problems are classified instead of surfacing as a bare
// LOCAL_AGENT_PROCESS_FAILED.
if (event.is_error === true || String(event.subtype || '').startsWith('error')) {
markStructuredFailure(state, 'LOCAL_AGENT_PROCESS_FAILED', structuredResultDiagnostic(event))
}
return
}
if (structuredEventFailed(event)) {
@@ -177,13 +237,14 @@ function ingestStructuredEvent(state, event) {
}
if (state.kind === 'pi') {
if (typeof event.id === 'string') state.sessionRef = event.id
if (event.type === 'session' && typeof event.id === 'string') state.sessionRef = event.id
const update = event.assistantMessageEvent
if (event.type === 'message_update' && update?.type === 'text_delta'
&& typeof update.delta === 'string') {
appendStructuredText(state, update.delta, '')
}
if (event.type === 'message_end' && Array.isArray(event.message?.content)) {
if (event.type === 'message_end' && event.message?.role === 'assistant'
&& Array.isArray(event.message?.content)) {
for (const block of event.message.content) {
if (block?.type === 'text' && typeof block.text === 'string') {
if (!state.text) appendStructuredText(state, block.text, '')
@@ -195,9 +256,18 @@ function ingestStructuredEvent(state, event) {
if (event.type === 'turn_end') {
state.outcome = stepFinishOutcome(event.message?.stopReason || event.stopReason || 'stop')
}
if (event.type === 'agent_end') state.outcome = 'completed'
if (event.type === 'agent_end') {
state.piContinuing = event.willRetry === true || event.willContinue === true
const lastAssistant = event.messages?.findLast(message => message?.role === 'assistant')
if (lastAssistant?.stopReason) state.outcome = stepFinishOutcome(lastAssistant.stopReason)
else if (!state.outcome) state.outcome = 'completed'
if (state.outcome === 'completed' && !state.piContinuing) {
state.failure = null
state.diagnostic = ''
}
}
if (event.type === 'agent_settled') state.piContinuing = false
if (state.outcome === 'failed') markStructuredFailure(state)
state.text = stripPiInternalPromptEcho(state.text)
return
}
@@ -320,7 +390,7 @@ function openCodeReviewTerminal(status, manifest, manifestPresent = manifest !=
}
}
function finalizeStructuredState(state) {
function finalizeStructuredState(state, context = {}) {
if (state.parseError) {
return {
text: '',
@@ -360,9 +430,12 @@ function finalizeStructuredState(state) {
}
if (!state.seen && !state.text && !state.outcome) return null
const outcome = state.outcome || (state.text ? 'partial' : 'failed')
const outcome = state.kind === 'pi' && state.piContinuing && state.outcome === 'completed'
? 'partial' : state.outcome || (state.text ? 'partial' : 'failed')
const result = {
text: state.kind === 'pi' ? stripPiInternalPromptEcho(state.text) : state.text.trim(),
text: state.kind === 'pi'
? stripPiPromptEcho(state.text, context.prompt)
: state.text.trim(),
sessionRef: state.sessionRef,
outcome,
}
@@ -395,7 +468,7 @@ function createStructuredOutputAccumulator(kind, sessionRef = '') {
return {
format: 'jsonl',
ingest: event => ingestStructuredEvent(state, event),
end: () => finalizeStructuredState(state),
end: context => finalizeStructuredState(state, context),
}
}
@@ -669,7 +742,8 @@ function parsePiOutput(stdout) {
&& typeof event.assistantMessageEvent.delta === 'string') {
text += event.assistantMessageEvent.delta
}
if (!text && event.type === 'message_end' && Array.isArray(event.message?.content)) {
if (!text && event.type === 'message_end' && event.message?.role === 'assistant'
&& Array.isArray(event.message?.content)) {
text = event.message.content
.filter(block => block?.type === 'text' && typeof block.text === 'string')
.map(block => block.text)
@@ -678,7 +752,7 @@ function parsePiOutput(stdout) {
}
const parsedText = text.trim()
const answer = stripPiInternalPromptEcho(parsedText)
return { text: parsedText ? answer : String(stdout || '').trim(), sessionRef }
return { text: parsedText ? answer : events.length ? '' : String(stdout || '').trim(), sessionRef }
}
function openCodeReviewText(output) {
@@ -793,8 +867,13 @@ function classifyCliOutcome(kind, stdout) {
}
if (kind === 'pi') {
const terminal = events.findLast(event => event?.type === 'turn_end' || event?.type === 'agent_end')
const reason = terminal?.message?.stopReason || terminal?.stopReason || 'stop'
return { outcome: stepFinishOutcome(reason) }
if ((terminal?.willRetry === true || terminal?.willContinue === true)
&& events.at(-1)?.type !== 'agent_settled') return { outcome: 'partial' }
const lastAssistant = terminal?.messages?.findLast(message => message?.role === 'assistant')
const previousTurn = events.findLast(event => event?.type === 'turn_end')
const reason = terminal?.message?.stopReason || terminal?.stopReason || lastAssistant?.stopReason
|| previousTurn?.message?.stopReason || previousTurn?.stopReason
return { outcome: reason ? stepFinishOutcome(reason) : terminal ? 'completed' : 'partial' }
}
if (['mimo', 'opencode'].includes(kind)) {
const finish = events.findLast(event => (
@@ -832,7 +911,8 @@ function classifyCliOutcome(kind, stdout) {
function structuredCliError(stdout) {
const failed = parseJsonOutputEvents(stdout)
.findLast(event => event?.is_error || event?.subtype?.startsWith('error'))
return String(failed?.error?.message || failed?.result || '').trim()
if (!failed) return ''
return structuredResultDiagnostic(failed)
}
function hermesSessionRef(stderr) {
@@ -859,7 +939,7 @@ function queryHermesState(databasePath, sql, params, options = {}) {
}
function readHermesMessageWatermark(options = {}) {
const databasePath = path.join(options.home || os.homedir(), '.hermes', 'state.db')
const databasePath = path.join(options.hermesHome || path.join(options.home || os.homedir(), '.hermes'), 'state.db')
const existsFn = options.existsFn || fs.existsSync
if (!existsFn(databasePath)) return 0
try {
@@ -877,7 +957,7 @@ function readHermesMessageWatermark(options = {}) {
function readHermesFinalResponse(sessionRef, options = {}) {
const afterMessageId = Number(options.afterMessageId)
if (!sessionRef || !Number.isSafeInteger(afterMessageId) || afterMessageId < 0) return ''
const databasePath = path.join(options.home || os.homedir(), '.hermes', 'state.db')
const databasePath = path.join(options.hermesHome || path.join(options.home || os.homedir(), '.hermes'), 'state.db')
const existsFn = options.existsFn || fs.existsSync
if (!existsFn(databasePath)) return ''
try {
@@ -0,0 +1,45 @@
// Detect a runaway native turn, rather than deduplicating its visible answer.
// Exact repetitions and sustained paraphrased closing acknowledgements count;
// real tool activity resets the window.
const CLOSING_ACKNOWLEDGEMENT = /^(?:(?:确认(?:收到)?|收到|本轮).{0,100}(?:收尾|结束|完成|定稿|后续轮次|按.{0,16}分工).{0,30}|确认|收到|确认收到|(?:confirmed|acknowledged|done)(?:[,:;].{0,100})?)$/iu
function createOutputRepetitionGuard() {
const windows = { answer: { text: '', unchecked: 0 }, thought: { text: '', unchecked: 0 } }
return update => {
const type = update?.sessionUpdate || update?.type
if (['tool_call', 'tool_call_update', 'tool_start', 'tool_result_summary'].includes(type)) {
for (const window of Object.values(windows)) { window.text = ''; window.unchecked = 0 }
return false
}
const window = type === 'agent_thought_chunk' ? windows.thought : windows.answer
const value = type === 'answer_delta' ? update.delta
: ['agent_message_chunk', 'agent_thought_chunk'].includes(type)
&& update.content?.type === 'text' ? update.content.text : ''
const chunk = String(value || '').replace(/\s/g, '')
if (!chunk) return false
window.text = (window.text + chunk).slice(-8192)
window.unchecked += chunk.length
const text = window.text
if (window.unchecked < 32 || text.length < 64) return false
window.unchecked = 0
const clauses = text.split(/[。!?.!?]+/u).filter(Boolean).slice(-12)
if (window === windows.answer
&& clauses.filter(clause => CLOSING_ACKNOWLEDGEMENT.test(clause)).length >= 8) return true
if (new Set(text).size < 6) return false
const anchor = text.slice(-8)
let previous = text.lastIndexOf(anchor, text.length - 16)
while (previous >= 0) {
const period = text.length - 8 - previous
if (period > 2048) break
const copies = window === windows.thought ? (period < 80 ? 16 : 8) : (period < 80 ? 8 : 4)
if (period * copies <= text.length) {
const block = text.slice(-period)
if (new Set(block).size >= 6 && text.endsWith(block.repeat(copies))) return true
}
previous = previous > 0 ? text.lastIndexOf(anchor, previous - 1) : -1
}
return false
}
}
module.exports = { createOutputRepetitionGuard }
+186 -17
View File
@@ -1,7 +1,22 @@
const path = require('node:path')
const { searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
const fs = require('node:fs')
const JSON5 = require('json5')
const { launchEnvironment, searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
const { networkEnvironment } = require('./cli-network-environment.cjs')
const { CONFIG_DIRECTORY_ENV_KEYS } = require('./cli-config-directories.cjs')
const { SECRET_ENV_KEYS, nativeSecretEnvKeys, configSecrets } = require('./native-secret-environment.cjs')
const { nativeCredentialEnvironment } = require('../agent-native-environment.cjs')
const { mimoConfigSecrets } = require('./mimo-native-config.cjs')
const { opencodeConfigLayers } = require('./opencode-native-config.cjs')
const { qwenConfigSecrets } = require('./qwen-native-config.cjs')
const { hermesConfigSecrets } = require('./hermes-config-secrets.cjs')
const { deepseekNativeSecrets } = require('./deepseek-native-config.cjs')
const { redactChildSecrets } = require('./cli-runtime-events.cjs')
const { agentRuntimeError } = require('../agent-runtime-contract.cjs')
const {
agentRuntimeError,
authFailureCode,
} = require('../agent-runtime-contract.cjs')
const { authFailureCertainty, isContextOverflow } = require('../../runs/failure-signals.cjs')
const { validateOpenClawRuntimeGuard } = require('./openclaw-runtime.cjs')
const TERMINATE_GRACE_MS = 500
@@ -21,36 +36,108 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
'OPENCLAW_CONFIG_PATH',
'OPENCLAW_WORKSPACE_DIR',
])
const TEST_RUNTIME_ENV = /^(?:CONNECTOR_TEST_|MELDWORK_TEST_)/
const BLOCKED_RUNTIME_ENV = /^(?:NODE_OPTIONS|NODE_PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_|ELECTRON_RUN_AS_NODE)$/
function agentExecutionError(code, diagnostic = '') {
return agentRuntimeError(code, diagnostic)
}
function authConfigurationFailure(detail) {
return /api[ _-]?key|access[ _-]?token|refresh[ _-]?token|auth[ _-]?token|credential|auth(?:entication|orization)?|log(?:ged)?[ -]?in|sign(?:ed)?[ -]?in|unauthorized|forbidden|\b(?:401|403)\b|select an auth type|(?:provider|model).{0,80}(?:reject|configur|missing|invalid)|(?:reject|configur|missing|invalid).{0,80}(?:provider|model)|令牌|凭据|登录|认证|鉴权|(?:提供商|供应商|模型).{0,40}(?:配置|拒绝|缺失|无效)|(?:配置|拒绝|缺失|无效).{0,40}(?:提供商|供应商|模型)/i
.test(String(detail || ''))
function terminateWindowsProcessTree(child, spawnFn, timeoutMs = TERMINATE_GRACE_MS + KILL_SETTLE_MS) {
if (child.exitCode != null || child.signalCode != null) return Promise.resolve()
return new Promise((resolve, reject) => {
let killer
let childClosed = false
let killerSucceeded = false
let settled = false
let timer
const finish = (error) => {
if (settled) return
settled = true
clearTimeout(timer)
child.removeListener('close', onChildClose)
killer?.removeListener('close', onKillerClose)
if (error) {
const failure = agentExecutionError('LOCAL_AGENT_TERMINATION_FAILED')
if (child.pid && child.exitCode == null && child.signalCode == null) {
const pending = new Promise(resolveExit => {
child.once('exit', resolveExit)
if (child.exitCode != null || child.signalCode != null) {
child.removeListener('exit', resolveExit)
resolveExit()
}
})
Object.defineProperty(failure, Symbol.for('meldwork.pending-process-exit'), { value: pending })
}
reject(failure)
} else resolve()
}
const onFailure = () => finish(true)
const onChildClose = () => { childClosed = true; if (killerSucceeded) finish(false) }
const onKillerClose = code => {
if (code !== 0) return finish(true)
killerSucceeded = true
if (childClosed || child.exitCode != null || child.signalCode != null) finish(false)
}
child.once('close', onChildClose)
child.once('error', onFailure)
timer = setTimeout(onFailure, timeoutMs)
try {
if (!child.pid) return onFailure()
killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore', windowsHide: true,
})
killer.once('close', onKillerClose)
killer.once('error', onFailure)
killer.unref?.()
} catch { onFailure() }
})
}
function invalidSessionFailure(detail) {
const text = String(detail || '').trim()
if (!text || authConfigurationFailure(text)
if (!text || authFailureCertainty(text)
|| /\b(?:api|access|refresh|auth|session)[ _-]?token\b/i.test(text)) return false
// A resumed native session that no longer fits the model context cannot be
// continued; rebuilding it with compact continuation context is the fix.
if (isContextOverflow(text)) return true
if (/^(?:Error: )?Resume retains gateway session policy; creation overrides are unsupported on resume\./m.test(text)
&& /--yolo differs from this session's launch/.test(text)) return true
// Runtime crashes ("thread 'main' panicked ... invalid ...") and parser
// errors mention threads or invalid input without saying anything about the
// stored session. Discarding a healthy session for them loses context.
if (/\bpanicked\b|\bSyntaxError\b|\binvalid JSON\b|\bUnexpected token\b|\bstack backtrace\b/i.test(text)) {
return false
}
return /\b(?:no|unknown|invalid|expired|missing|stale)\s+(?:saved\s+)?(?:session|conversation|thread)\b|\b(?:session|conversation|thread)(?:\s+(?:id|key|reference))?\b[^\n]{0,100}\b(?:not found|does not exist|no longer exists|invalid|unknown|expired|has expired|was deleted|cannot be resumed)\b/i
.test(text)
}
function failedAgentProcessError(detail, options = {}) {
if (!detail) return agentExecutionError('LOCAL_AGENT_EXITED')
if (options.kind === 'zcode' && /\b1113\b|insufficient balance|no resource package/i.test(detail)) {
return agentExecutionError('LOCAL_AGENT_PROVIDER_QUOTA_EXHAUSTED', 'The configured provider has insufficient balance or no active resource package.')
}
if (options.kind === 'zcode' && /\bModel provider is missing an API key:/i.test(detail)) {
return agentExecutionError('LOCAL_AGENT_AUTH_REQUIRED', 'Missing API key for the configured Zcode provider.')
}
if (options.kind === 'workbuddy' && options.model && authFailureCode(detail)) {
return agentExecutionError('LOCAL_WORKBUDDY_PROVIDER_AUTH', detail)
}
return agentExecutionError(
options.sessionRef && invalidSessionFailure(detail)
? 'LOCAL_AGENT_SESSION_INVALID'
: authConfigurationFailure(detail)
? 'LOCAL_AGENT_AUTH_REQUIRED'
: 'LOCAL_AGENT_PROCESS_FAILED',
: authFailureCode(detail) || 'LOCAL_AGENT_PROCESS_FAILED',
detail,
)
}
function inheritedEnvironment(options, platform) {
const env = systemChildEnvironment(process.env, platform)
for (const key of options.nativeUnsetKeys || []) delete env[key]
return env
}
function openClawChildEnvironment(workdir, options, platform) {
const source = options.env || {}
const guard = options.openClawRuntimeGuard
@@ -60,7 +147,10 @@ function openClawChildEnvironment(workdir, options, platform) {
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
}
const env = systemChildEnvironment(process.env, platform)
const env = {
...inheritedEnvironment(options, platform),
...networkEnvironment(source, platform),
}
for (const key of [
'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME',
@@ -98,18 +188,96 @@ function childEnvironment(agent, workdir, options, platform) {
? { HERMES_EXEC_ASK: '', HERMES_YOLO_MODE: '1' }
: { HERMES_EXEC_ASK: '1', HERMES_YOLO_MODE: '' }
: {}
const openCodeSafetyEnv = agent.kind === 'opencode'
&& options.sandbox !== 'workspace-write'
? { OPENCODE_PERMISSION: OPENCODE_READ_ONLY_PERMISSION }
: {}
const openCodeSafetyEnv = {}
// ACP has no terminal UI for question/plan transitions. Match the native
// headless runner: return the answer to Meldwork instead of waiting forever.
const headlessPermissions = { question: 'deny', plan_enter: 'deny', plan_exit: 'deny' }
if (agent.kind === 'opencode') {
openCodeSafetyEnv.OPENCODE_PERMISSION = JSON.stringify({
...(options.sandbox !== 'workspace-write' ? JSON.parse(OPENCODE_READ_ONLY_PERMISSION) : {}),
...headlessPermissions,
})
}
if (agent.kind === 'mimo') {
// MiMo applies read/write permissions with ACP session/set_mode. Keep
// process-wide permissions stable when the discussion changes phase.
openCodeSafetyEnv.MIMOCODE_PERMISSION = JSON.stringify(headlessPermissions)
}
const source = { ...process.env, ...options.env }
return {
...systemChildEnvironment(process.env, platform),
...options.env,
for (const key of options.nativeUnsetKeys || []) {
if (!Object.hasOwn(options.env || {}, key)) delete source[key]
}
const nativeKeys = nativeSecretEnvKeys(options.nativeCredentialKeys)
const nativeEnvironment = nativeCredentialEnvironment(agent.kind, options.env || {}, nativeKeys, platform)
const controlled = {
...Object.fromEntries(Object.entries(nativeEnvironment).filter(([key]) => !BLOCKED_RUNTIME_ENV.test(key))),
...networkEnvironment(options.env || {}, platform),
...Object.fromEntries(Object.entries(options.env || {}).filter(([key, value]) => (
typeof value === 'string' && (TEST_RUNTIME_ENV.test(key)
|| CONFIG_DIRECTORY_ENV_KEYS.includes(key)) && !BLOCKED_RUNTIME_ENV.test(key)
))),
}
const childEnv = {
...inheritedEnvironment(options, platform),
...controlled,
...hermesSafetyEnv,
...openCodeSafetyEnv,
...launchEnvironment(agent.kind, agent.executable, { platform }),
PATH: searchPath({ platform, env: source }),
}
Object.defineProperty(childEnv, SECRET_ENV_KEYS, { value: nativeKeys })
if (agent.kind === 'hermes') {
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
const secrets = hermesConfigSecrets({ env: childEnv, home, platform, declaredKeys: nativeKeys })
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: secrets })
}
if (agent.kind === 'deepseek') {
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
const secrets = deepseekNativeSecrets({ env: childEnv, home, workdir })
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: secrets })
}
if (agent.kind === 'qwen') {
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
const secrets = qwenConfigSecrets({ home, env: childEnv, workdir, platform })
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: secrets })
}
if (agent.kind === 'mimo') {
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
const secrets = mimoConfigSecrets({ home, env: childEnv, workdir })
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: secrets })
}
if (agent.kind === 'opencode') {
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
const { configs } = opencodeConfigLayers({ home, env: childEnv, workdir })
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: configSecrets(configs) })
}
if (agent.kind === 'claude' && typeof workdir === 'string' && path.isAbsolute(workdir)) {
const secrets = new Set()
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
const userRoot = childEnv.CLAUDE_CONFIG_DIR || (home && path.join(home, '.claude'))
const files = [
...(typeof userRoot === 'string' && path.isAbsolute(userRoot) ? [path.join(userRoot, 'settings.json')] : []),
...['settings.json', 'settings.local.json'].map(name => path.join(workdir, '.claude', name)),
]
for (const file of files) {
try {
const stat = fs.statSync(file)
if (!stat.isFile() || stat.size > 1024 * 1024) continue
const env = JSON5.parse(fs.readFileSync(file, 'utf8'))?.env
if (!env || typeof env !== 'object' || Array.isArray(env)) continue
for (const [key, value] of Object.entries(env)) {
if (/(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(key)
&& typeof value === 'string' && value && value.length <= 8192) {
secrets.add(value)
const bearer = value.match(/^Bearer\s+(\S+)$/i)
if (bearer) secrets.add(bearer[1])
}
}
} catch { /* The CLI reports invalid or unreadable native settings. */ }
}
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: [...secrets] })
}
return childEnv
}
module.exports = {
@@ -119,4 +287,5 @@ module.exports = {
childEnvironment,
failedAgentProcessError,
invalidSessionFailure,
terminateWindowsProcessTree,
}
@@ -425,13 +425,16 @@ function acpPlanSummary(update) {
}
function createAcpRuntimeState() {
return { tools: new Map() }
return { tools: new Map(), toolKinds: new Map(), reasoning: new Map() }
}
const ACP_TOOL_KINDS = new Set([
'read', 'edit', 'delete', 'move', 'search',
'execute', 'think', 'fetch', 'switch_mode', 'other',
])
const ACP_TOOL_TITLES = Object.freeze({ read: 'read_file', edit: 'write_file', delete: 'delete',
move: 'move', search: 'search', execute: 'Bash', think: 'reasoning', fetch: 'fetch',
switch_mode: 'switch_mode', other: 'tool' })
function acpToolOperation(update) {
const name = ACP_TOOL_KINDS.has(update.kind) ? update.kind : 'tool'
@@ -447,15 +450,29 @@ function acpToolOperation(update) {
function acpRuntimeEvents(update, state) {
if (!update || typeof update !== 'object') return []
if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
return [{ type: 'answer_delta', status: 'running', delta: update.content.text }]
// Close an open reasoning phase once the visible answer starts.
const closed = state?.reasoning?.get('reasoning:acp')?.startsWith('running:')
? reasoningLifecycleEvent(state, 'reasoning:acp', 'completed')
: null
return [
...(closed ? [closed] : []),
{ type: 'answer_delta', status: 'running', delta: update.content.text },
]
}
if (update.sessionUpdate === 'agent_thought_chunk' && update.content?.type === 'text') {
return []
// Thinking is real progress: report a text-free reasoning status so the
// watchdog and the trace know the Agent is working. The private thought
// text itself never leaves the main process.
if (!state?.reasoning) return []
const event = reasoningLifecycleEvent(state, 'reasoning:acp', 'running')
return event ? [event] : []
}
if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
const status = runtimeEventStatus(update.status)
const completed = ['completed', 'failed', 'stopped', 'timeout'].includes(status)
const id = String(update.toolCallId || '')
if (id && ACP_TOOL_KINDS.has(update.kind)) state?.toolKinds?.set(id, update.kind)
const kind = ACP_TOOL_KINDS.has(update.kind) ? update.kind : state?.toolKinds?.get(id)
const operation = acpToolOperation(update)
if (id && operation && update.sessionUpdate === 'tool_call') state?.tools.set(id, operation)
const summary = state?.tools.get(id) || operation
@@ -465,7 +482,7 @@ function acpRuntimeEvents(update, state) {
type: completed
? 'tool_result_summary'
: update.sessionUpdate === 'tool_call' ? 'tool_start' : 'tool_update',
title: runtimeToolTitle(update),
title: ACP_TOOL_TITLES[kind] || 'tool',
status: status || (update.sessionUpdate === 'tool_call' ? 'running' : 'waiting'),
...(summary ? { summary } : {}),
...(detail ? { detail } : {}),
@@ -1,4 +1,39 @@
const { stripAnsi } = require('./cli-output-parsers.cjs')
const { SECRET_ENV_KEYS } = require('./native-secret-environment.cjs')
const JSON5 = require('json5')
const inlineSecretCache = new WeakMap()
const INLINE_SECRET_FIELD = /(?:api[_-]?key|access[_-]?key|token|secret|password|authorization|private[_-]?key)$/i
function inlineConfigSecrets(env) {
if (!env || typeof env !== 'object') return []
const values = [env.MIMOCODE_CONFIG_CONTENT, env.OPENCODE_CONFIG_CONTENT]
const cached = inlineSecretCache.get(env)
if (cached && values.every((value, index) => value === cached.values[index])) return cached.secrets
const secrets = new Set()
function collect(value) {
const pending = [value]
while (pending.length) {
const current = pending.pop()
if (!current || typeof current !== 'object') continue
for (const [key, item] of Object.entries(current)) {
if (INLINE_SECRET_FIELD.test(key) && typeof item === 'string' && item
&& item.length <= 8192 && !/^\{(?:env|file):[^}]+\}$/.test(item)) {
secrets.add(item)
const bearer = item.match(/^Bearer\s+(\S+)$/i)
if (bearer) secrets.add(bearer[1])
} else if (item && typeof item === 'object') pending.push(item)
}
}
}
for (const value of values) {
if (typeof value !== 'string' || !value || Buffer.byteLength(value) > 256 * 1024) continue
try { collect(JSON5.parse(value)) } catch { /* native CLI reports invalid configuration */ }
}
const result = [...secrets]
inlineSecretCache.set(env, { values, secrets: result })
return result
}
const RUNTIME_EVENT_TYPES = new Set([
'status',
@@ -28,21 +63,44 @@ function utf8ByteLength(value) {
function redactChildSecrets(value, env) {
let result = String(value || '')
for (const [name, secret] of Object.entries(env)) {
if (!/(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|| typeof secret !== 'string' || utf8ByteLength(secret) < 8) continue
for (const secret of childSecrets(env)) {
result = result.split(secret).join('[redacted]')
}
return result
}
function childSecrets(env) {
return Object.entries(env || {})
.filter(([name, secret]) => (
/(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
&& typeof secret === 'string' && utf8ByteLength(secret) >= 8
))
.map(([, secret]) => secret)
const secrets = new Set(inlineConfigSecrets(env))
for (const value of env?.[Symbol.for('meldwork.project-secret-values')] || []) {
if (typeof value === 'string' && value) secrets.add(value)
}
for (const [name, value] of Object.entries(env || {})) {
if (typeof value !== 'string' || !value) continue
if (env?.[SECRET_ENV_KEYS]?.includes(name)
|| /(?:^|_)(?:API_KEY|ACCESS_KEY_ID|SECRET_ACCESS_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)$/i.test(name)
|| /^AWS_BEARER_TOKEN_BEDROCK$/i.test(name)
|| (/(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
&& utf8ByteLength(value) >= 8)) secrets.add(value)
if (!/^(?:https?|all)_proxy$/i.test(name)) continue
if (value.includes('@')) secrets.add(value)
try {
const url = new URL(value)
if (!url.username && !url.password) continue
secrets.add(value)
secrets.add(url.href)
const userinfo = `${url.username}:${url.password}`
secrets.add(userinfo)
const decoded = decodeURIComponent(userinfo)
secrets.add(decoded)
secrets.add(Buffer.from(decoded).toString('base64'))
for (const part of [url.username, url.password]) {
if (utf8ByteLength(part) >= 8) secrets.add(part)
const literal = decodeURIComponent(part)
if (utf8ByteLength(literal) >= 8) secrets.add(literal)
}
} catch { /* malformed proxy URLs are diagnosed by the native runtime */ }
}
return [...secrets].sort((left, right) => right.length - left.length)
}
function assignmentNameVariants(parts) {
@@ -82,7 +140,7 @@ function pathSuffixLength(value) {
const matches = [
value.match(/file:\/\/\/[^\s"'`<>|,;)}\]]+$/i),
value.match(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+$/),
value.match(/(?<![A-Za-z0-9:/])\/+[^\s"'`<>|,;)}\]]+$/),
value.match(/(?<![\p{L}\p{N}:/])\/+[^\s"'`<>|,;)}\]]+$/u),
]
return Math.max(...matches.map(match => match?.[0]?.length || 0))
}
@@ -90,6 +148,9 @@ function pathSuffixLength(value) {
function boundarySensitiveSuffixLength(value, secrets) {
let retained = 0
const retain = (length) => { retained = Math.max(retained, length || 0) }
// Keep a word boundary across chunks: a slash in the next chunk may be
// prose (云/端), not the start of an absolute filesystem path.
retain(value.match(/(?=[^\x00-\x7f])[\p{L}\p{N}]$/u)?.[0]?.length)
for (const secret of secrets) {
const limit = Math.min(value.length, secret.length - 1)
for (let length = limit; length > retained; length -= 1) {
@@ -106,7 +167,7 @@ function boundarySensitiveSuffixLength(value, secrets) {
}
const pathLength = pathSuffixLength(value)
if (pathLength) retain(pathLength)
retain(value.match(/(?<![A-Za-z0-9:/])\/+$/)?.[0]?.length)
retain(value.match(/(?<![\p{L}\p{N}:/])\/+$/u)?.[0]?.length)
const matches = [
value.match(/\bAKIA[0-9A-Z]{0,15}$/),
@@ -189,7 +250,7 @@ function sanitizeRuntimeEventText(value, childEnv, limit, singleLine = false, tr
.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/gi, '[redacted private key]')
.replace(/file:\/\/\/[^\s"'`<>|,;)}\]]+/gi, '[path]')
.replace(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+/g, '[path]')
.replace(/(?<![A-Za-z0-9:/])\/+[^\s"'`<>|,;)}\]]+/g, '[path]')
.replace(/(?<![\p{L}\p{N}:/])\/+[^\s"'`<>|,;)}\]]+/gu, '[path]')
.replace(/(["']?)(?:[A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|secret[_-]?access[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|database[_-]?url|connection[_-]?string|dsn|token|secret|password|credential|authorization|private[_-]?key)[A-Za-z0-9_.-]*)\1\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|\[[^\]\r\n]*\]|[^\s,;}\]]+)/gi, 'credential=[redacted]')
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]')
.replace(/\bbearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer [redacted]')
@@ -13,6 +13,7 @@ const {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -54,6 +55,7 @@ module.exports = {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -0,0 +1,41 @@
const { randomUUID } = require('node:crypto')
// A contribution ends only on an explicit, invocation-specific signal from
// the assistant. Tool output and acknowledgements cannot finish a turn.
function createTurnCompletion() {
const marker = `[[MELDWORK_TURN_DONE:${randomUUID()}]]`
let text = ''
let delivered = 0
let completed = false
return {
instruction: `This is one scheduled contribution. Other participants cannot run until you return. Complete only the contribution requested for this invocation; do not poll or wait for their approvals. After your answer and any required collaboration receipt, output this exact signal on its own final line: ${marker}\nThe signal ends only your invocation, never the group task. Do not quote or explain it, and do not output it before all your tools have finished.`,
push(delta, toolsFinished = true) {
if (completed) return { delta: '', completed: true }
text += delta
const index = text.lastIndexOf(marker)
if (toolsFinished && index > 0 && text[index - 1] === '\n'
&& /^(?:[ \t]*\r?\n|[ \t]*$)/.test(text.slice(index + marker.length))) {
const before = text.slice(0, index)
const fences = before.match(/^\s*(?:```|~~~)/gm) || []
if (fences.length % 2 === 0 && before.trim()) {
completed = true
const visible = before.slice(delivered)
text = before
delivered = text.length
return { delta: visible, completed: true }
}
}
let held = 0
for (let length = Math.min(marker.length, text.length); length > 0; length--) {
if (text.endsWith(marker.slice(0, length))) { held = length; break }
}
const end = text.length - held
const visible = text.slice(delivered, end)
delivered = end
return { delta: visible, completed: false }
},
get completed() { return completed },
}
}
module.exports = { createTurnCompletion }
@@ -0,0 +1,167 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { parseEnv } = require('node:util')
const { createHash } = require('node:crypto')
const yaml = require('js-yaml')
const { nativeSecretEnvKeys, configSecrets } = require('./native-secret-environment.cjs')
// DSH supports JavaScript-tagged scalar expressions; inspect them as inert text.
const patchSchema = yaml.DEFAULT_SCHEMA.extend([new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar', construct: value => value,
})])
function deepseekConfigurationFingerprint(options = {}) {
const env = options.env || process.env
const home = options.home || os.homedir()
const root = typeof env.DSH_HOME === 'string' && env.DSH_HOME ? env.DSH_HOME.replace(/^~(?=$|[\\/])/, home) : path.join(home, '.dsh')
if (!path.isAbsolute(root)) return null
const files = ['cordis.patch.yml', '.credentials.yaml', 'profiles/acp/cordis.patch.yml',
'profiles/acp/.credentials.yaml', '.env'].map(file => [file, path.join(root, file)])
if (typeof options.workdir === 'string' && path.isAbsolute(options.workdir)) {
files.push(['project/.env', path.join(options.workdir, '.env')])
}
const entries = []
for (const [file, filename] of files) {
try {
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > 1024 * 1024) return null
entries.push([file, createHash('sha256').update(fs.readFileSync(filename)).digest('hex')])
} catch (error) {
if (error.code !== 'ENOENT') return null
entries.push([file, null])
}
}
return createHash('sha256').update(JSON.stringify([root, entries])).digest('hex')
}
function deepseekNativeSecrets(options = {}) {
const env = options.env || process.env
const home = options.home || os.homedir()
const root = typeof env.DSH_HOME === 'string' && env.DSH_HOME
? env.DSH_HOME.replace(/^~(?=$|[\\/])/, home) : path.join(home, '.dsh')
if (!path.isAbsolute(root)) return []
const secrets = new Set()
const add = value => {
if (typeof value === 'string' && value && value.length <= 8192) secrets.add(value)
}
for (const file of ['.credentials.yaml', 'profiles/acp/.credentials.yaml',
'cordis.patch.yml', 'profiles/acp/cordis.patch.yml']) {
try {
const filename = path.join(root, file)
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > 1024 * 1024) continue
const document = yaml.load(fs.readFileSync(filename, 'utf8'), { schema: patchSchema })
for (const value of configSecrets([document])) add(value)
if (!file.endsWith('.credentials.yaml') || !document || typeof document !== 'object') continue
if (document.refs && typeof document.refs === 'object' && !Array.isArray(document.refs)) {
for (const value of Object.values(document.refs)) add(value)
}
if (document.records && typeof document.records === 'object' && !Array.isArray(document.records)) {
for (const record of Object.values(document.records)) {
if (record?.kind === 'api-key') add(record.key)
}
}
} catch { /* The CLI handles unreadable or invalid native configuration. */ }
}
const declared = new Set(['ZGCI_API_KEY', ...deepseekNativeConfig({ env, home }).credentialKeys])
const dotenvFiles = [path.join(root, '.env')]
if (typeof options.workdir === 'string' && path.isAbsolute(options.workdir)) {
dotenvFiles.push(path.join(options.workdir, '.env'))
}
for (const file of dotenvFiles) {
try {
const stat = fs.statSync(file)
if (!stat.isFile() || stat.size > 1024 * 1024) continue
for (const [key, value] of Object.entries(parseEnv(fs.readFileSync(file, 'utf8')))) {
if (declared.has(key) || /(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(key)) add(value)
}
} catch { /* The CLI handles unreadable or invalid native environment files. */ }
}
return [...secrets]
}
function deepseekDefaultCredentialConfigured(options = {}) {
const env = options.env || process.env
const home = options.home || os.homedir()
const root = typeof env.DSH_HOME === 'string' && env.DSH_HOME
? env.DSH_HOME.replace(/^~(?=$|[\\/])/, home) : path.join(home, '.dsh')
if (!path.isAbsolute(root)) return false
const mapping = value => value && typeof value === 'object' && !Array.isArray(value)
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
let remaining = 8192
const jsonValue = (value, seen = new Set(), depth = 0) => {
if (--remaining < 0) return false
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
if (typeof value === 'number') return Number.isFinite(value)
if (depth > 32 || (!Array.isArray(value) && !mapping(value)) || seen.has(value)) return false
seen.add(value)
const valid = Object.values(value).every(item => jsonValue(item, seen, depth + 1))
seen.delete(value)
return valid
}
const validEnv = value => mapping(value) && Object.entries(value).every(([key, item]) => (
/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof item === 'string' && item.length > 0
))
try {
const filename = path.join(root, '.credentials.yaml')
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > 1024 * 1024
|| ((options.platform || process.platform) !== 'win32' && (stat.mode & 0o077))) return false
const document = yaml.load(fs.readFileSync(filename, 'utf8'))
if (!mapping(document) || document.version !== 1
|| (document.refs !== undefined && !validEnv(document.refs))
|| (document.records !== undefined && !mapping(document.records))) return false
for (const [key, record] of Object.entries(document.records || {})) {
if (!/^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(key) || !mapping(record)) return false
if (record.kind === 'api-key') {
if (Object.keys(record).some(field => !['kind', 'key', 'env'].includes(field))
|| (record.key !== undefined && (typeof record.key !== 'string' || !record.key.length))
|| (record.env !== undefined && !validEnv(record.env))) return false
} else if (record.kind === 'grant') {
if (Object.keys(record).some(field => !['kind', 'payload'].includes(field))
|| !Object.hasOwn(record, 'payload') || !jsonValue(record.payload)) return false
} else return false
}
const credential = document.refs?.ZGCI_API_KEY
return typeof credential === 'string' && /^[\x21-\x7e]+$/.test(credential)
} catch { return false }
}
function deepseekNativeConfig(options = {}) {
const env = options.env || process.env
const home = options.home || os.homedir()
const root = typeof env.DSH_HOME === 'string' && env.DSH_HOME ? env.DSH_HOME.replace(/^~(?=$|[\\/])/, home) : path.join(home, '.dsh')
if (!path.isAbsolute(root)) return { explicitModel: true, credentialKeys: [] }
const files = [path.join(root, 'profiles', 'acp', 'cordis.patch.yml'), path.join(root, 'cordis.patch.yml')]
const keys = []
let explicitModel = false
let provider = ''
for (const filename of files) {
let document
try {
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > 1024 * 1024) return { explicitModel: true, credentialKeys: [] }
document = yaml.load(fs.readFileSync(filename, 'utf8'), { schema: patchSchema })
} catch (error) {
if (error.code === 'ENOENT') continue
return { explicitModel: true, credentialKeys: [] }
}
const pending = [document]
const seen = new Set()
while (pending.length) {
const node = pending.pop()
if (!node || typeof node !== 'object' || seen.has(node)) continue
seen.add(node)
if (node.id === 'acp' && node.config && typeof node.config === 'object') {
if (['provider', 'model'].some(key => typeof node.config[key] === 'string' && node.config[key].trim())) explicitModel = true
if (typeof node.config.provider === 'string' && node.config.provider.trim()) provider = node.config.provider.trim()
}
if (typeof node.apiKeyEnv === 'string') keys.push(node.apiKeyEnv)
pending.push(...Object.values(node).reverse())
}
}
if (explicitModel && (!provider || provider === 'deepseek-official')) keys.push('DEEPSEEK_API_KEY')
return { explicitModel, credentialKeys: nativeSecretEnvKeys(keys) }
}
module.exports = { deepseekNativeConfig, deepseekConfigurationFingerprint, deepseekDefaultCredentialConfigured, deepseekNativeSecrets }
+11
View File
@@ -0,0 +1,11 @@
- id: sandbox-policy
disabled: false
config:
mode: read-only
workspaceRoot: !!js process.cwd()
- id: approval
disabled: false
config:
policy: never
- id: permission
disabled: true
+11
View File
@@ -0,0 +1,11 @@
- id: sandbox-policy
disabled: false
config:
mode: workspace-write
workspaceRoot: !!js process.cwd()
- id: approval
disabled: false
config:
policy: never
- id: permission
disabled: true
+15
View File
@@ -0,0 +1,15 @@
- id: llm-pi-ai
config:
providers:
zgci:
displayName: ZGCI
apiKeyEnv: ZGCI_API_KEY
api: openai-completions
baseURL: https://hub.zgci.org/v1
models:
- id: smart-router-v2
name: smart-router-v2
- id: acp
config:
provider: zgci
model: smart-router-v2
@@ -0,0 +1,107 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const JSON5 = require('json5')
function readSettings(file) {
try {
const stat = fs.statSync(file)
if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error('invalid settings')
const value = JSON5.parse(fs.readFileSync(file, 'utf8'))
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid settings')
return value
} catch (error) {
if (error.code === 'ENOENT') return {}
throw error
}
}
function readSystemSettings(file, platform) {
try {
fs.lstatSync(file)
} catch (error) {
if (error.code === 'ENOENT') return {}
throw error
}
// Gemini requires root-owned, non-writable system paths on POSIX. Windows
// uses ACL checks that cannot be inferred from Node's mode bits.
if (platform === 'win32') throw new Error('unverified system settings')
let current = path.resolve(file)
for (;;) {
const stat = fs.lstatSync(current)
if (stat.isSymbolicLink() || stat.uid !== 0 || (stat.mode & 0o022) !== 0) {
throw new Error('unverified system settings')
}
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
return readSettings(file)
}
function geminiNativeAuth(options = {}) {
const env = options.env || process.env
const platform = options.platform || process.platform
const home = options.home || os.homedir()
const base = env.GEMINI_CLI_HOME || home
const system = env.GEMINI_CLI_SYSTEM_SETTINGS_PATH || (platform === 'darwin'
? '/Library/Application Support/GeminiCli/settings.json' : platform === 'win32'
? 'C:\\ProgramData\\gemini-cli\\settings.json' : '/etc/gemini-cli/settings.json')
const defaults = env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH || path.join(path.dirname(system), 'system-defaults.json')
if (![base, system, defaults].every(value => typeof value === 'string' && path.isAbsolute(value))) return { uncertain: true }
const root = path.join(base, '.gemini')
try {
const layers = [readSystemSettings(defaults, platform), readSettings(path.join(root, 'settings.json'))]
const systemSettings = readSystemSettings(system, platform)
const validOverrides = ['oauth-personal', 'gemini-api-key', 'vertex-ai', 'cloud-shell', 'compute-default-credentials', 'gateway']
const override = validOverrides.includes(env.GEMINI_CLI_AUTH_OVERRIDE) ? env.GEMINI_CLI_AUTH_OVERRIDE : ''
let authType = override || (layers[1].security?.auth?.selectedType ?? layers[0].security?.auth?.selectedType ?? '')
let enforcedType = layers[1].security?.auth?.enforcedType ?? layers[0].security?.auth?.enforcedType ?? ''
const workdir = typeof options.workdir === 'string' && path.isAbsolute(options.workdir) ? options.workdir : ''
if (workdir && path.resolve(workdir) !== path.resolve(base)) {
let trusted = env.GEMINI_CLI_TRUST_WORKSPACE === 'true'
if (env.GEMINI_RESTRICTED_MODE === 'true' || env.GEMINI_CLI_TRUST_WORKSPACE === 'false') trusted = false
else if (!trusted) {
const enabled = systemSettings.security?.folderTrust?.enabled
?? layers[1].security?.folderTrust?.enabled ?? layers[0].security?.folderTrust?.enabled ?? true
if (enabled === false) trusted = true
else {
const trustFile = env.GEMINI_CLI_TRUSTED_FOLDERS_PATH || path.join(root, 'trustedFolders.json')
if (!path.isAbsolute(trustFile)) return { uncertain: true }
const rules = readSettings(trustFile)
const real = fs.realpathSync(workdir)
let longest = -1
for (const [folder, level] of Object.entries(rules)) {
if (!path.isAbsolute(folder) || !['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(level)) continue
const candidate = level === 'TRUST_PARENT' ? path.dirname(folder) : folder
const rootPath = fs.existsSync(candidate) ? fs.realpathSync(candidate) : path.resolve(candidate)
const relative = path.relative(rootPath, real)
if ((relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)))
&& rootPath.length > longest) {
longest = rootPath.length
trusted = level !== 'DO_NOT_TRUST'
}
}
}
}
if (trusted) {
const workspaceAuth = readSettings(path.join(workdir, '.gemini', 'settings.json')).security?.auth
authType = workspaceAuth?.selectedType ?? authType
enforcedType = workspaceAuth?.enforcedType ?? enforcedType
}
}
authType = systemSettings.security?.auth?.selectedType ?? authType
enforcedType = systemSettings.security?.auth?.enforcedType ?? enforcedType
if (!authType) {
if (env.GOOGLE_GENAI_USE_GCA === 'true') authType = 'oauth-personal'
else if (env.GOOGLE_GENAI_USE_VERTEXAI === 'true') authType = 'vertex-ai'
else if (env.GOOGLE_GEMINI_BASE_URL) authType = 'gateway'
else if (env.GEMINI_API_KEY) authType = 'gemini-api-key'
else if (env.CLOUD_SHELL === 'true' || env.GEMINI_CLI_USE_COMPUTE_ADC === 'true') authType = 'compute-default-credentials'
}
if (enforcedType && enforcedType !== authType) return { uncertain: true }
return { authType: typeof authType === 'string' ? authType : '', root }
} catch { return { uncertain: true } }
}
module.exports = { geminiNativeAuth }
@@ -0,0 +1,32 @@
const fs = require('node:fs')
const path = require('node:path')
const yaml = require('js-yaml')
const { nativeIdentityDirectory } = require('../native-config-directory.cjs')
const { hermesDotenvAssignments } = require('../hermes-dotenv.cjs')
const { configSecrets, nativeSecretEnvKeys } = require('./native-secret-environment.cjs')
function hermesConfigSecrets({ env, home, platform, declaredKeys }) {
const root = nativeIdentityDirectory('hermes', env, home, platform)
if (!root) return []
const read = name => {
const file = path.join(root, name)
const stat = fs.statSync(file)
if (!stat.isFile() || stat.size > 1024 * 1024) return ''
return fs.readFileSync(file, 'utf8')
}
const configs = []
for (const [name, parse] of [['config.yaml', yaml.load], ['auth.json', JSON.parse]]) {
try { const content = read(name); if (content) configs.push(parse(content)) } catch { /* CLI reports invalid settings. */ }
}
const secrets = new Set(configSecrets(configs))
const declared = new Set(nativeSecretEnvKeys(declaredKeys))
try {
for (const [key, value] of hermesDotenvAssignments(read('.env'), env)) {
if ((declared.has(key) || /(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(key))
&& value && value.length <= 8192) secrets.add(value)
}
} catch { /* Missing native environment is normal. */ }
return [...secrets]
}
module.exports = { hermesConfigSecrets }
@@ -0,0 +1,163 @@
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const { createHash } = require('node:crypto')
const JSON5 = require('json5')
const { nativeSecretEnvKeys, configSecrets } = require('./native-secret-environment.cjs')
function projectDirectories(workdir) {
if (typeof workdir !== 'string' || !path.isAbsolute(workdir)) return []
const directories = []
let current = path.resolve(workdir)
for (let depth = 0; depth < 64; depth += 1) {
directories.push(current)
if (fs.existsSync(path.join(current, '.git'))) return directories.reverse()
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
return directories.reverse()
}
function mimoConfigLayers(options = {}) {
const env = options.env || process.env
const home = options.home || os.homedir()
const root = env.XDG_CONFIG_HOME || path.join(home, '.config')
try {
const resolveNativePath = value => {
if (!value || path.isAbsolute(value)) return value
return typeof options.workdir === 'string' && path.isAbsolute(options.workdir)
? path.resolve(options.workdir, value) : null
}
const explicitFile = resolveNativePath(env.MIMOCODE_CONFIG)
const explicitDirectory = resolveNativePath(env.MIMOCODE_CONFIG_DIR)
if (explicitFile === null || explicitDirectory === null) return null
const projects = /^(?:1|true)$/i.test(env.MIMOCODE_DISABLE_PROJECT_CONFIG || '')
? [] : projectDirectories(options.workdir)
const files = [path.join(root, 'mimocode', 'mimocode.json'), path.join(root, 'mimocode', 'mimocode.jsonc'),
explicitFile, ...projects.flatMap(directory => [path.join(directory, 'mimocode.json'), path.join(directory, 'mimocode.jsonc')]),
...[...projects].reverse().flatMap(directory => [path.join(directory, '.mimocode', 'mimocode.json'), path.join(directory, '.mimocode', 'mimocode.jsonc')]),
path.join(home, '.mimocode', 'mimocode.json'), path.join(home, '.mimocode', 'mimocode.jsonc'),
explicitDirectory && path.join(explicitDirectory, 'mimocode.json'),
explicitDirectory && path.join(explicitDirectory, 'mimocode.jsonc')].filter(Boolean)
const configs = []
for (const file of files) {
let stat
try { stat = fs.statSync(file) } catch (error) {
if (error.code === 'ENOENT') continue
throw error
}
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return null
configs.push(JSON5.parse(fs.readFileSync(file, 'utf8')))
}
if (env.MIMOCODE_CONFIG_CONTENT) {
if (Buffer.byteLength(env.MIMOCODE_CONFIG_CONTENT) > 2 * 1024 * 1024) return null
configs.push(JSON5.parse(env.MIMOCODE_CONFIG_CONTENT))
}
return configs
} catch { return null }
}
function mimoConfigurationFingerprint(options = {}) {
const configs = mimoConfigLayers(options)
if (configs === null) return null
const recent = mimoModel(configs) ? null : mimoRecentModels(options.env || process.env, options.home || os.homedir())
return createHash('sha256').update(JSON.stringify([configs, recent])).digest('hex')
}
function mimoModel(configs) {
return configs.reduce((model, config) => typeof config?.model === 'string' ? config.model : model, '')
}
function mimoRecentModels(env, home) {
const stateRoot = env.XDG_STATE_HOME || path.join(home, '.local', 'state')
try {
const filename = path.join(stateRoot, 'mimocode', 'model.json')
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return []
const recent = JSON.parse(fs.readFileSync(filename, 'utf8'))?.recent
return (Array.isArray(recent) ? recent.slice(0, 32) : [])
.filter(item => typeof item?.providerID === 'string' && /^[A-Za-z0-9._-]+$/.test(item.providerID)
&& typeof item.modelID === 'string' && item.modelID)
.map(item => ({ providerID: item.providerID, modelID: item.modelID }))
} catch { return [] }
}
function mimoProviderIds(configs, env, home) {
const configuredModel = mimoModel(configs)
if (!configuredModel) return [...new Set(mimoRecentModels(env, home).map(item => item.providerID))]
const model = configuredModel.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,
(_match, name) => nativeSecretEnvKeys([name]).length ? env[name] || '' : '')
return model.includes('/') && model.split('/')[0] ? [model.split('/')[0]] : []
}
function mimoProviderKeys(options = {}, configs = mimoConfigLayers(options)) {
if (!configs) return []
try {
const result = []
for (const providerId of mimoProviderIds(configs, options.env || process.env, options.home || os.homedir())) {
let key
for (const config of configs) {
const options = config?.provider?.[providerId]?.options
if (options && Object.hasOwn(options, 'apiKey')) key = options.apiKey
}
if (typeof key === 'string' && key.trim()) result.push(key)
}
return result
} catch { return [] }
}
function mimoDeclaredCredentialKeys(options = {}) {
const configs = mimoConfigLayers(options)
if (!configs) return []
const keys = []
const visit = (value, depth = 0) => {
if (depth > 32 || keys.length >= 128) return
if (typeof value === 'string') {
for (const match of value.matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)) keys.push(match[1])
} else if (value && typeof value === 'object') {
for (const child of Object.values(value)) visit(child, depth + 1)
}
}
visit(mimoModel(configs))
const providerIds = mimoProviderIds(configs, options.env || process.env, options.home || os.homedir())
if (providerIds.length) {
const merge = (current, incoming, depth = 0) => {
if (depth > 32 || !incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return incoming
const result = current && typeof current === 'object' && !Array.isArray(current)
? current : Object.create(null)
for (const [key, value] of Object.entries(incoming)) {
result[key] = merge(result[key], value, depth + 1)
}
return result
}
for (const providerId of providerIds) {
let provider = null
for (const config of configs) {
if (Object.hasOwn(config?.provider || {}, providerId)) provider = merge(provider, config.provider[providerId])
}
visit(provider)
}
}
return nativeSecretEnvKeys(keys)
}
function mimoConfiguredProvider(options = {}) {
const env = options.env || process.env
return mimoProviderKeys(options).some(key => {
let missing = false
key = key.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
const value = nativeSecretEnvKeys([name]).length ? env[name] : ''
if (typeof value !== 'string' || !value.trim()) missing = true
return value || ''
})
return !missing && !/^\{.*\}$/.test(key) && Boolean(key.trim())
})
}
function mimoConfigSecrets(options = {}) {
const configs = mimoConfigLayers(options)
return configs ? configSecrets(configs) : []
}
module.exports = { mimoConfigurationFingerprint, mimoConfiguredProvider, mimoDeclaredCredentialKeys, mimoConfigSecrets }
@@ -0,0 +1,42 @@
const SECRET_ENV_KEYS = Symbol.for('meldwork.native-secret-env-keys')
const UNSAFE = /^(?:(?:PATH|HOME|USER|LOGNAME|SHELL|ENV|IFS|CDPATH|GLOBIGNORE|TMPDIR|TMP|TEMP|TERM|COMSPEC|SYSTEMROOT|WINDIR|USERPROFILE|PATHEXT|APPDATA|LOCALAPPDATA|HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|HERMES_EXEC_ASK|HERMES_YOLO_MODE)$|(?:BASH_|ZSH_|FISH_|NODE_|ELECTRON_|LD_|DYLD_|PYTHON|RUBY|PERL|JAVA_|GIT_|SSH_))/i
function nativeSecretEnvKeys(input) {
if (!Array.isArray(input)) return []
return [...new Set(input.filter(key => typeof key === 'string'
&& /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key) && !UNSAFE.test(key)))].slice(0, 128).sort()
}
function credentialLiteral(value) {
if (typeof value !== 'string') return false
const candidate = value.trim()
if (!candidate) return false
if (/^(?:null|none|undefined|true|false|~)$/i.test(candidate)) return false
if (/^\$(?:[A-Z_][A-Z0-9_]*|\{[A-Z_][A-Z0-9_]*\})$/i.test(candidate)) return false
if (/^(?:env|secretref-env|__env__):[A-Z_][A-Z0-9_]*$/i.test(candidate)) return false
return !/^[A-Z_][A-Z0-9_]*(?:API_KEY|TOKEN|CREDENTIAL)$/i.test(candidate)
}
function configSecrets(configs) {
const secrets = new Set()
const pending = configs.map(value => [value, 0])
const visited = new WeakMap()
while (pending.length && secrets.size < 128) {
const [value, depth] = pending.pop()
if (!value || typeof value !== 'object' || depth > 32) continue
if (visited.has(value) && visited.get(value) <= depth) continue
visited.set(value, depth)
for (const [key, item] of Object.entries(value)) {
if ((/(?:api[_-]?key|access[_-]?key|token|secret|password|authorization)$/i.test(key)
|| (key === 'key' && value.type === 'api_key'))
&& credentialLiteral(item) && item.length <= 8192 && !/^\{(?:env|file):[^}]+\}$/.test(item)) {
secrets.add(item)
const bearer = item.match(/^Bearer\s+(\S+)$/i)
if (bearer) secrets.add(bearer[1])
} else if (item && typeof item === 'object') pending.push([item, depth + 1])
}
}
return [...secrets]
}
module.exports = { SECRET_ENV_KEYS, nativeSecretEnvKeys, credentialLiteral, configSecrets }
@@ -0,0 +1,49 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
// Native OCR v1.13 provider EnvVar fields; this selects existing shell values,
// it does not set a provider, endpoint, or credential.
const OCR_PROVIDER_KEYS = Object.freeze({
anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', 'openai-responses': 'OPENAI_RESPONSES_API_KEY',
openrouter: 'OPENROUTER_API_KEY', edenai: 'EDENAI_API_KEY', gemini: 'GEMINI_API_KEY',
dashscope: 'DASHSCOPE_API_KEY', 'dashscope-tokenplan': 'DASHSCOPE_TOKENPLAN_KEY',
volcengine: 'ARK_API_KEY', deepseek: 'DEEPSEEK_API_KEY', 'tencent-tokenhub': 'TENCENT_TOKENHUB_API_KEY',
'hy-tokenplan': 'TENCENT_HUNYUAN_TOKENPLAN_KEY', iflytek: 'SPARK_API_KEY', kimi: 'MOONSHOT_API_KEY',
'kimi-global': 'MOONSHOT_GLOBAL_API_KEY', 'z-ai': 'Z_AI_API_KEY', 'z-ai-coding': 'Z_AI_CODING_API_KEY',
mimo: 'MIMO_API_KEY', minimax: 'MINIMAX_GLOBAL_API_KEY', 'minimax-cn': 'MINIMAX_API_KEY',
'baidu-qianfan': 'QIANFAN_API_KEY', 'ollama-cloud': 'OLLAMA_API_KEY', novita: 'NOVITA_API_KEY',
xai: 'XAI_API_KEY', litellm: 'LITELLM_API_KEY', siliconflow: 'SILICONFLOW_GLOBAL_API_KEY',
'siliconflow-cn': 'SILICONFLOW_API_KEY', mistral: 'MISTRAL_API_KEY',
})
function ocrConfig(options = {}) {
const env = options.env || process.env
const platform = options.platform || process.platform
const configured = platform === 'win32' ? env.USERPROFILE : env.HOME
const home = typeof configured === 'string' && configured.trim() ? configured.trim() : (options.home || os.homedir())
try {
const file = path.join(home, '.opencodereview', 'config.json')
const stat = fs.statSync(file)
if (!stat.isFile() || stat.size > 1024 * 1024) return null
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch { return null }
}
function ocrSelectedProvider(options = {}) {
const provider = ocrConfig(options)?.provider
return typeof provider === 'string' ? provider.trim() : ''
}
function ocrDeclaredCredentialKeys(options = {}) {
const config = ocrConfig(options)
const provider = typeof config?.provider === 'string' ? config.provider.trim() : ''
const key = Object.hasOwn(OCR_PROVIDER_KEYS, provider) ? OCR_PROVIDER_KEYS[provider] : ''
const entry = config?.providers?.[provider]
if (!key || !entry || typeof entry !== 'object' || Array.isArray(entry)) return []
if (typeof entry.api_key === 'string' && entry.api_key.trim()) return []
if (typeof entry.api_key_cmd === 'string' && entry.api_key_cmd.trim()) return []
return [key]
}
module.exports = { OCR_PROVIDER_KEYS, ocrDeclaredCredentialKeys, ocrSelectedProvider }
+81 -98
View File
@@ -1,10 +1,10 @@
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { isDeepStrictEqual } = require('node:util')
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
const MANAGED_OPENCLAW_PROVIDER_ID = 'meldwork-desktop'
const READ_ONLY_TOOLS = [
'read', 'web_search', 'web_fetch', 'memory_search', 'memory_get', 'session_status',
]
@@ -36,12 +36,12 @@ const OPENCLAW_RUNTIME_PATH_ENV_KEYS = Object.freeze([
'OPENCLAW_WORKSPACE_DIR',
])
const OPENCLAW_RUNTIME_CREDENTIAL_KEYS = new Set([
'MELDWORK_OPENCLAW_API_KEY',
'MELDWORK_OPENCLAW_NATIVE_API_KEY',
'OPENCLAW_GATEWAY_TOKEN',
])
const issuedOpenClawRuntimeGuards = new WeakSet()
const openClawRuntimeCredentialDigests = new WeakMap()
const openClawRuntimeConfigs = new WeakMap()
const OPENCLAW_ENV_SECRET_NAME = /^[A-Z][A-Z0-9_]{0,127}$/
const OPENCLAW_SECRET_MARKERS = new Set([
'secretref-managed',
@@ -89,7 +89,7 @@ function directoryIdentity(filename) {
})
}
function fileIdentity(filename) {
function readRuntimeConfigFile(filename) {
const resolved = path.resolve(filename)
const entry = fs.lstatSync(resolved)
if (!entry.isFile() || entry.isSymbolicLink() || fs.realpathSync(resolved) !== resolved) {
@@ -99,14 +99,22 @@ function fileIdentity(filename) {
const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | noFollow)
try {
const before = fs.fstatSync(descriptor)
if (!before.isFile() || !sameFilesystemIdentity(before, {
if (!before.isFile() || before.size > 256 * 1024 || !sameFilesystemIdentity(before, {
dev: identityValue(entry.dev),
ino: identityValue(entry.ino),
birthtime: identityValue(entry.birthtimeMs),
})) {
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
}
const contents = fs.readFileSync(descriptor)
const buffer = Buffer.alloc(256 * 1024 + 1)
let length = 0
while (length < buffer.length) {
const count = fs.readSync(descriptor, buffer, length, buffer.length - length, null)
if (!count) break
length += count
}
if (length === buffer.length) throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
const contents = buffer.subarray(0, length)
const after = fs.fstatSync(descriptor)
const current = fs.lstatSync(resolved)
if (!sameFilesystemIdentity(after, {
@@ -121,7 +129,7 @@ function fileIdentity(filename) {
}) || fs.realpathSync(resolved) !== resolved) {
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
}
return Object.freeze({
const identity = Object.freeze({
path: resolved,
dev: identityValue(before.dev),
ino: identityValue(before.ino),
@@ -130,11 +138,16 @@ function fileIdentity(filename) {
size: contents.length,
digest: crypto.createHash('sha256').update(contents).digest('hex'),
})
return { identity, contents: contents.toString('utf8') }
} finally {
fs.closeSync(descriptor)
}
}
function fileIdentity(filename) {
return readRuntimeConfigFile(filename).identity
}
function validateDirectoryIdentities(identities) {
for (const expected of identities) {
const current = directoryIdentity(expected.path)
@@ -158,26 +171,7 @@ function validateFileIdentity(expected) {
}
}
function normalizeProvider(input) {
const apiKey = typeof input?.OPENAI_API_KEY === 'string' ? input.OPENAI_API_KEY.trim() : ''
const baseUrl = String(input?.OPENAI_BASE_URL || '')
.trim()
.replace(/\/+$/, '')
.replace(/\/chat\/completions$/i, '')
const model = String(input?.OPENAI_MODEL || '').trim()
let parsed
try { parsed = new URL(baseUrl) } catch { throw new Error('OPENCLAW_PROVIDER_INVALID') }
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
if (!apiKey || apiKey.length > 8192 || isOpenClawSecretReference(apiKey)
|| !model || model.length > 120
|| (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback))
|| parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('OPENCLAW_PROVIDER_INVALID')
}
return { apiKey, baseUrl, model }
}
function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
function runtimePaths({ storageRoot, workdir, sessionRef, configName, runtimeVersion }) {
if (!path.isAbsolute(storageRoot) || !path.isAbsolute(workdir)) {
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
}
@@ -189,8 +183,15 @@ function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
}
const resolvedWorkdir = path.resolve(workdir)
// The managed scope is keyed by session, workspace, and CLI version. OpenClaw
// refuses to start on state written by an older CLI ("Legacy workspace setup
// state requires migration"), so a version-scoped directory gives an upgraded
// CLI a fresh isolated scope instead of a guaranteed startup failure.
const versionSegment = String(runtimeVersion || '').trim()
const scope = crypto.createHash('sha256')
.update(`${sessionRef || 'configure'}\0${resolvedWorkdir}`)
.update(versionSegment
? `${sessionRef || 'configure'}\0${resolvedWorkdir}\0${versionSegment}`
: `${sessionRef || 'configure'}\0${resolvedWorkdir}`)
.digest('hex')
.slice(0, 24)
const runtimeRoot = path.join(resolvedStorageRoot, 'openclaw-managed', scope)
@@ -268,6 +269,7 @@ function writeRuntimeConfig(runtime, config, credentials, credentialKey) {
credentialKeys: Object.freeze(credentialEntries.map(([key]) => key)),
})
issuedOpenClawRuntimeGuards.add(guard)
openClawRuntimeConfigs.set(guard, contents)
openClawRuntimeCredentialDigests.set(guard, Object.freeze(Object.fromEntries(
credentialEntries.map(([key, value]) => [
key,
@@ -277,7 +279,7 @@ function writeRuntimeConfig(runtime, config, credentials, credentialKey) {
return guard
}
function validateOpenClawRuntimeGuard(guard, env = {}) {
function validateRuntimeScope(guard, env = {}) {
const credentialDigests = guard && typeof guard === 'object'
? openClawRuntimeCredentialDigests.get(guard)
: undefined
@@ -301,10 +303,57 @@ function validateOpenClawRuntimeGuard(guard, env = {}) {
}
}
validateDirectoryIdentities(guard.directories)
}
function validateOpenClawRuntimeGuard(guard, env = {}) {
validateRuntimeScope(guard, env)
validateFileIdentity(guard.config)
return true
}
function comparableMigratedConfig(config) {
// OpenClaw materializes the implicit main agent and stamps migrations at startup.
const result = JSON.parse(JSON.stringify(config))
const meta = result.meta
if (meta && typeof meta === 'object' && !Array.isArray(meta)
&& Object.keys(meta).every(key => ['migrations', 'lastTouchedVersion', 'lastTouchedAt'].includes(key))
&& (meta.lastTouchedVersion === undefined || /^\d{4}\.\d{1,2}\.\d{1,2}(?:[-+.][\w.-]+)?$/.test(meta.lastTouchedVersion))
&& (meta.lastTouchedAt === undefined || (typeof meta.lastTouchedAt === 'string' && Number.isFinite(Date.parse(meta.lastTouchedAt))))
&& (meta.migrations === undefined || isDeepStrictEqual(meta.migrations, { modelPolicyAllowlist: true }))) {
delete result.meta
}
if (isDeepStrictEqual(result.agents?.entries, { main: {} })) delete result.agents.entries
if (isDeepStrictEqual(result.discovery?.wideArea, { enabled: false })) {
result.discovery.wideArea = {}
}
return result
}
function refreshOpenClawRuntimeAfterStartup(options) {
const guard = options?.openClawRuntimeGuard
const env = options?.env || {}
validateRuntimeScope(guard, env)
const { identity: config, contents } = readRuntimeConfigFile(guard.config.path)
if (process.platform !== 'win32' && (config.mode & 0o777) !== 0o600) {
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
}
validateDirectoryIdentities(guard.directories)
validateFileIdentity(config)
let equivalent = false
try {
equivalent = isDeepStrictEqual(
comparableMigratedConfig(JSON.parse(openClawRuntimeConfigs.get(guard))),
comparableMigratedConfig(JSON.parse(contents)),
)
} catch { /* malformed config is rejected below */ }
if (!equivalent) throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
const refreshed = Object.freeze({ ...guard, config })
issuedOpenClawRuntimeGuards.add(refreshed)
openClawRuntimeConfigs.set(refreshed, contents)
openClawRuntimeCredentialDigests.set(refreshed, openClawRuntimeCredentialDigests.get(guard))
return { ...options, openClawRuntimeGuard: refreshed }
}
function configureOpenClawGatewayRuntime(options, port) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('OPENCLAW_GATEWAY_PORT_INVALID')
@@ -313,7 +362,7 @@ function configureOpenClawGatewayRuntime(options, port) {
const env = options?.env || {}
validateOpenClawRuntimeGuard(guard, env)
const config = JSON.parse(fs.readFileSync(guard.config.path, 'utf8'))
const config = JSON.parse(openClawRuntimeConfigs.get(guard))
const gatewayCredentialKey = 'OPENCLAW_GATEWAY_TOKEN'
const nextConfig = {
...config,
@@ -395,74 +444,8 @@ function normalizedNativeModel(input) {
return model
}
function managedOpenClawOptions({
storageRoot, workdir, sessionRef = '', allowWrite = false, provider,
}) {
const normalized = normalizeProvider(provider)
const configName = allowWrite
? 'openclaw.provider.workspace-write.json'
: 'openclaw.provider.read-only.json'
const runtime = runtimePaths({
storageRoot, workdir, sessionRef, configName,
})
const { resolvedWorkdir, home, state, configPath } = runtime
const modelRef = `${MANAGED_OPENCLAW_PROVIDER_ID}/${normalized.model}`
const gatewayCredentialKey = 'OPENCLAW_GATEWAY_TOKEN'
const gatewayToken = crypto.randomBytes(32).toString('base64url')
const config = {
agents: {
defaults: {
workspace: resolvedWorkdir,
model: { primary: modelRef },
skipBootstrap: true,
},
},
models: {
mode: 'replace',
providers: {
[MANAGED_OPENCLAW_PROVIDER_ID]: {
baseUrl: normalized.baseUrl,
apiKey: {
source: 'env', provider: 'default', id: 'MELDWORK_OPENCLAW_API_KEY',
},
api: 'openai-completions',
models: [{ id: normalized.model, name: normalized.model, input: ['text'] }],
},
},
},
tools: toolPolicy(allowWrite),
gateway: {
mode: 'local',
bind: 'loopback',
auth: {
mode: 'token',
token: { source: 'env', provider: 'default', id: gatewayCredentialKey },
},
controlUi: { enabled: false },
},
}
const credentialKey = 'MELDWORK_OPENCLAW_API_KEY'
const openClawRuntimeGuard = writeRuntimeConfig(runtime, config, {
[credentialKey]: normalized.apiKey,
[gatewayCredentialKey]: gatewayToken,
}, credentialKey)
return {
openClawRuntimeGuard,
env: {
OPENCLAW_HOME: home,
OPENCLAW_STATE_DIR: state,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_WORKSPACE_DIR: resolvedWorkdir,
[credentialKey]: normalized.apiKey,
[gatewayCredentialKey]: gatewayToken,
},
}
}
function nativeOpenClawOptions({
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime,
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime, openclawVersion,
}) {
const modelRef = String(runtime?.model || '').trim()
const provider = runtime?.provider
@@ -494,7 +477,7 @@ function nativeOpenClawOptions({
? 'openclaw.native.workspace-write.json'
: 'openclaw.native.read-only.json'
const runtimePathsResult = runtimePaths({
storageRoot, workdir, sessionRef, configName,
storageRoot, workdir, sessionRef, configName, runtimeVersion: openclawVersion,
})
const { resolvedWorkdir, home, state, configPath } = runtimePathsResult
const modelConfig = { primary: modelRef }
@@ -555,7 +538,7 @@ function nativeOpenClawOptions({
module.exports = {
configureOpenClawGatewayRuntime,
isOpenClawSecretReference,
managedOpenClawOptions,
nativeOpenClawOptions,
refreshOpenClawRuntimeAfterStartup,
validateOpenClawRuntimeGuard,
}
@@ -0,0 +1,138 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const JSON5 = require('json5')
const { nativeSecretEnvKeys } = require('./native-secret-environment.cjs')
function opencodeConfigLayers(options = {}) {
const env = options.env || process.env
const home = options.home || os.homedir()
const workdir = typeof options.workdir === 'string' && path.isAbsolute(options.workdir)
? options.workdir : ''
const resolvePath = value => {
if (typeof value !== 'string' || !value) return ''
const expanded = value.replace(/^~(?=$|[\\/])/, home)
return path.isAbsolute(expanded) ? expanded : workdir ? path.resolve(workdir, expanded) : ''
}
const configRoot = env.XDG_CONFIG_HOME || path.join(home, '.config')
const globalRoot = path.join(configRoot, 'opencode')
const files = ['config.json', 'opencode.json', 'opencode.jsonc'].map(name => path.join(globalRoot, name))
const explicit = resolvePath(env.OPENCODE_CONFIG)
if (explicit) files.push(explicit)
const projectDisabled = /^(?:1|true)$/i.test(String(env.OPENCODE_DISABLE_PROJECT_CONFIG || ''))
if (workdir && !projectDisabled) {
let current = path.resolve(workdir)
const directories = []
for (let depth = 0; depth < 64; depth += 1) {
directories.push(current)
if (fs.existsSync(path.join(current, '.git'))) break
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
for (const directory of [...directories].reverse()) {
files.push(...['opencode.json', 'opencode.jsonc'].map(name => path.join(directory, name)))
}
for (const directory of directories) {
files.push(...['opencode.json', 'opencode.jsonc'].map(name => path.join(directory, '.opencode', name)))
}
}
const roots = [path.join(home, '.opencode'), resolvePath(env.OPENCODE_CONFIG_DIR)].filter(Boolean)
files.push(...roots.flatMap(root => ['opencode.json', 'opencode.jsonc'].map(name => path.join(root, name))))
const configs = []
for (const file of new Set(files)) {
try {
const stat = fs.statSync(file)
if (stat.isFile() && stat.size <= 2 * 1024 * 1024) configs.push(JSON5.parse(fs.readFileSync(file, 'utf8')))
} catch { /* Invalid or missing native configs are diagnosed by OpenCode. */ }
}
if (typeof env.OPENCODE_CONFIG_CONTENT === 'string'
&& Buffer.byteLength(env.OPENCODE_CONFIG_CONTENT) <= 2 * 1024 * 1024) {
try { configs.push(JSON5.parse(env.OPENCODE_CONFIG_CONTENT)) } catch { /* Native CLI reports invalid content. */ }
}
return { configs, env, home }
}
function opencodeDeclaredCredentialKeys(options = {}) {
const { configs, env, home } = opencodeConfigLayers(options)
const effectiveProviderList = name => {
const lists = configs.filter(config => Array.isArray(config?.[name])).map(config => config[name])
const selected = lists.at(-1)
if (!selected || !selected.every(value => typeof value === 'string')) return null
const resolved = selected.map(value => value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,
(_match, key) => env[key] || ''))
return resolved.every(Boolean) ? new Set(resolved) : null
}
const enabled = effectiveProviderList('enabled_providers')
const disabled = effectiveProviderList('disabled_providers')
const providerAllowed = provider => (!enabled || enabled.has(provider)) && !disabled?.has(provider)
const keys = []
const visit = (value, depth = 0) => {
if (depth > 32) return
if (typeof value === 'string') {
for (const match of value.matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)) keys.push(match[1])
} else if (value && typeof value === 'object') {
for (const child of Object.values(value)) visit(child, depth + 1)
}
}
for (const config of configs) {
for (const collection of [config?.provider, config?.providers]) {
if (Array.isArray(collection)) visit(collection)
else if (collection && typeof collection === 'object') {
for (const [provider, value] of Object.entries(collection)) if (providerAllowed(provider)) visit(value)
}
}
visit(config?.model)
visit(config?.small_model)
visit(config?.enabled_providers)
visit(config?.disabled_providers)
}
const resolveModel = model => typeof model === 'string'
? model.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => env[name] || '') : model
const effectiveModel = configs.findLast(config => typeof config?.model === 'string')?.model || ''
const effectiveSmallModel = configs.findLast(config => typeof config?.small_model === 'string')?.small_model || ''
const providers = new Set([resolveModel(effectiveModel), resolveModel(effectiveSmallModel)]
.filter(model => typeof model === 'string' && /^[A-Za-z0-9._-]+\/.+/.test(model))
.map(model => model.slice(0, model.indexOf('/'))))
const hasConfiguredModel = Boolean(effectiveModel)
if (!hasConfiguredModel) {
const stateRoot = env.XDG_STATE_HOME || path.join(home, '.local', 'state')
const filename = path.join(stateRoot, 'opencode', 'model.json')
try {
const stat = fs.statSync(filename)
if (stat.isFile() && stat.size <= 2 * 1024 * 1024) {
const recent = JSON.parse(fs.readFileSync(filename, 'utf8'))?.recent
if (Array.isArray(recent)) {
for (const item of recent.slice(0, 32)) {
if (typeof item?.providerID === 'string' && /^[A-Za-z0-9._-]+$/.test(item.providerID)
&& typeof item.modelID === 'string' && item.modelID) providers.add(item.providerID)
}
}
}
} catch { /* No usable recent native model is available. */ }
}
if (providers.size || !hasConfiguredModel) {
const cacheRoot = env.XDG_CACHE_HOME || path.join(home, '.cache')
const filename = path.join(cacheRoot, 'opencode', 'models.json')
try {
const stat = fs.statSync(filename)
if (stat.isFile() && stat.size <= 16 * 1024 * 1024) {
const metadata = JSON.parse(fs.readFileSync(filename, 'utf8'))
const candidates = hasConfiguredModel ? [...providers] : Object.keys(metadata).slice(0, 1024)
for (const provider of candidates) {
if (!providerAllowed(provider)) continue
const declared = Object.hasOwn(metadata, provider) ? metadata[provider]?.env : null
if (Array.isArray(declared)) keys.push(...declared.slice(0, 128))
if (keys.length >= 1024) break
}
}
} catch { /* Missing or invalid native metadata is handled by OpenCode. */ }
}
const safeKeys = []
for (let index = 0; index < Math.min(keys.length, 1024); index += 128) {
safeKeys.push(...nativeSecretEnvKeys(keys.slice(index, index + 128)))
}
return [...new Set(safeKeys)].sort()
}
module.exports = { opencodeDeclaredCredentialKeys, opencodeConfigLayers }
@@ -0,0 +1,29 @@
// Environment names used by Pi's built-in providers. Custom providers are
// discovered separately from the user's native models.json.
const PI_PROVIDER_CREDENTIAL_KEYS = Object.freeze({
'ant-ling': 'ANT_LING_API_KEY', 'qwen-token-plan': 'QWEN_TOKEN_PLAN_API_KEY',
'qwen-token-plan-cn': 'QWEN_TOKEN_PLAN_CN_API_KEY',
'qwen-token-plan-individual': 'QWEN_TOKEN_PLAN_API_KEY',
openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', 'github-copilot': 'COPILOT_GITHUB_TOKEN',
'azure-openai-responses': 'AZURE_OPENAI_API_KEY', nvidia: 'NVIDIA_API_KEY',
deepseek: 'DEEPSEEK_API_KEY', google: 'GEMINI_API_KEY',
'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',
cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', radius: 'RADIUS_API_KEY',
openrouter: 'OPENROUTER_API_KEY', 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY',
zai: 'ZAI_API_KEY', 'zai-coding-cn': 'ZAI_CODING_CN_API_KEY',
mistral: 'MISTRAL_API_KEY', minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY',
moonshotai: 'MOONSHOT_API_KEY', 'moonshotai-cn': 'MOONSHOT_API_KEY',
huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY', together: 'TOGETHER_API_KEY',
baseten: 'BASETEN_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',
'kimi-coding': 'KIMI_API_KEY', meta: 'META_API_KEY',
'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY', 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY',
xiaomi: 'XIAOMI_API_KEY', 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY',
'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',
'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',
})
const PI_BUILTIN_CREDENTIAL_KEYS = Object.freeze([...new Set([
...Object.values(PI_PROVIDER_CREDENTIAL_KEYS), 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN',
])])
const PI_BUILTIN_CONFIG_KEYS = Object.freeze(['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'])
module.exports = { PI_PROVIDER_CREDENTIAL_KEYS, PI_BUILTIN_CREDENTIAL_KEYS, PI_BUILTIN_CONFIG_KEYS }
@@ -0,0 +1,119 @@
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const JSON5 = require('json5')
const { nativeSecretEnvKeys, configSecrets } = require('./native-secret-environment.cjs')
function qwenNativeSelection(options = {}) {
const env = options.env || process.env
const platform = options.platform || process.platform
const configuredHome = platform === 'win32' ? env.USERPROFILE : env.HOME
const home = typeof configuredHome === 'string' && configuredHome.trim() ? configuredHome.trim() : (options.home || os.homedir())
const system = env.QWEN_CODE_SYSTEM_SETTINGS_PATH || (platform === 'darwin'
? '/Library/Application Support/QwenCode/settings.json' : platform === 'win32'
? 'C:\\ProgramData\\qwen-code\\settings.json' : '/etc/qwen-code/settings.json')
const defaults = env.QWEN_CODE_SYSTEM_DEFAULTS_PATH || path.join(path.dirname(system), 'system-defaults.json')
const resolveFile = file => path.isAbsolute(file) ? file
: typeof options.workdir === 'string' && path.isAbsolute(options.workdir) ? path.resolve(options.workdir, file) : ''
const user = path.join(home, '.qwen', 'settings.json')
const project = options.workdir && path.isAbsolute(options.workdir) && path.resolve(options.workdir) !== path.resolve(home)
? path.join(options.workdir, '.qwen', 'settings.json') : ''
let model = ''
let auth = ''
let inlineCredential = ''
const providers = new Map()
try {
const configs = new Map()
for (const entry of [defaults, user, project, system].filter(Boolean)) {
const file = resolveFile(entry)
if (!file) return { keys: [], unsupported: true }
let stat
try { stat = fs.statSync(file) } catch (error) { if (error.code === 'ENOENT') continue; throw error }
if (!stat.isFile() || stat.size > 1024 * 1024) return { keys: [], unsupported: true }
const settings = JSON5.parse(fs.readFileSync(file, 'utf8'))
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return { keys: [], unsupported: true }
configs.set(entry, settings)
}
if (Array.isArray(options.redactionSecrets)) {
options.redactionSecrets.push(...configSecrets([...configs.values()]))
}
const trustEnabled = configs.get(system)?.security?.folderTrust?.enabled
?? configs.get(user)?.security?.folderTrust?.enabled ?? false
let includeProject = trustEnabled === false
if (project && trustEnabled === true) {
const trustFile = resolveFile(env.GEMINI_CLI_TRUSTED_FOLDERS_PATH || path.join(home, '.qwen', 'trustedFolders.json'))
try {
const stat = fs.statSync(trustFile)
const rules = stat.isFile() && stat.size <= 1024 * 1024 ? JSON5.parse(fs.readFileSync(trustFile, 'utf8')) : null
const target = path.resolve(options.workdir)
if (!rules || typeof rules !== 'object' || Array.isArray(rules)) return { keys: [], unsupported: true }
const entries = Object.entries(rules)
const trusted = entries.some(([folder, level]) => {
if (!path.isAbsolute(folder) || !['TRUST_FOLDER', 'TRUST_PARENT'].includes(level)) return false
const root = path.resolve(level === 'TRUST_PARENT' ? path.dirname(folder) : folder)
const relative = path.relative(root, target)
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
})
const denied = entries.some(([folder, level]) => level === 'DO_NOT_TRUST'
&& path.isAbsolute(folder) && path.normalize(folder) === path.normalize(target))
includeProject = trusted || !denied
} catch (error) {
if (error.code !== 'ENOENT') return { keys: [], unsupported: true }
includeProject = true
}
}
for (const entry of [defaults, user, ...(includeProject && project ? [project] : []), system]) {
const settings = configs.get(entry)
if (!settings) continue
if (typeof settings.model?.name === 'string') model = settings.model.name
if (typeof settings.security?.auth?.selectedType === 'string') auth = settings.security.auth.selectedType
if (Object.hasOwn(settings.security?.auth || {}, 'apiKey')) inlineCredential = settings.security.auth.apiKey
if (settings.modelProviders && typeof settings.modelProviders === 'object' && !Array.isArray(settings.modelProviders)) {
providers.clear()
for (const [type, entries] of Object.entries(settings.modelProviders)) {
if (Array.isArray(entries)) providers.set(type, entries)
}
}
}
if (!auth) {
if (env.QWEN_OAUTH) auth = 'qwen-oauth'
else if (env.OPENAI_API_KEY && env.OPENAI_MODEL && env.OPENAI_BASE_URL) auth = 'openai'
else if (env.GEMINI_API_KEY && env.GEMINI_MODEL) auth = 'gemini'
else if (env.GOOGLE_API_KEY && env.GOOGLE_MODEL) auth = 'vertex-ai'
else if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_MODEL && env.ANTHROPIC_BASE_URL) auth = 'anthropic'
}
auth ||= typeof env.QWEN_DEFAULT_AUTH_TYPE === 'string' ? env.QWEN_DEFAULT_AUTH_TYPE : ''
if (!auth) return { keys: [], unsupported: false, authType: '' }
const selected = model ? (providers.get(auth) || []).filter(entry => entry?.id === model) : []
if (selected.length > 1) return { keys: [], unsupported: true, authType: auth }
if (selected[0]?.envKey) {
const keys = nativeSecretEnvKeys([selected[0].envKey])
return { keys, unsupported: keys.length === 0, authType: auth }
}
if (typeof inlineCredential === 'string' && inlineCredential.trim()) return { keys: [], unsupported: false, authType: auth }
const defaultKeys = { openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY',
gemini: 'GEMINI_API_KEY', 'vertex-ai': 'GOOGLE_API_KEY' }
return { keys: nativeSecretEnvKeys([defaultKeys[auth]]), unsupported: false, authType: auth }
} catch { return { keys: [], unsupported: true } }
}
function qwenCredentialSelection(options = {}) {
const { keys, unsupported } = qwenNativeSelection(options)
return { keys, unsupported }
}
function qwenConfiguredAuth(options = {}) {
return qwenNativeSelection(options).authType || ''
}
function qwenDeclaredCredentialKeys(options = {}) {
return qwenCredentialSelection(options).keys
}
function qwenConfigSecrets(options = {}) {
const redactionSecrets = []
qwenNativeSelection({ ...options, redactionSecrets })
return redactionSecrets
}
module.exports = { qwenCredentialSelection, qwenDeclaredCredentialKeys, qwenConfiguredAuth, qwenConfigSecrets }
@@ -0,0 +1,89 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { launchEnvironment } = require('./cli-discovery.cjs')
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/
function workBuddyModels(options = {}) {
const env = options.env || process.env
const root = env.CODEBUDDY_CONFIG_DIR || path.join(options.home || os.homedir(), '.workbuddy')
try {
const filename = path.join(root, 'models.json')
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return []
const value = JSON.parse(fs.readFileSync(filename, 'utf8'))
const models = Array.isArray(value) ? value : value.models
return (Array.isArray(models) ? models : []).filter(model => (
typeof model?.id === 'string' && MODEL_ID.test(model.id)
&& typeof model.apiKey === 'string' && model.apiKey.trim()
&& model.supportsToolCall === true && validUrl(model.url)
)).slice(0, 64)
} catch { return [] }
}
function validUrl(value) {
try {
const url = new URL(value)
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password
} catch { return false }
}
function workBuddySettings(options = {}) {
return {
modelId: options.modelId || '',
models: workBuddyModels(options).map(model => ({
id: model.id, name: String(model.name || model.id).slice(0, 120),
})),
}
}
function workBuddyEnvironment(options = {}) {
if (!options.modelId) return {}
const model = workBuddyModels(options).find(model => model.id === options.modelId)
if (!model) throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
// Use the CLI's supported provider settings, without copying desktop account
// tokens or attempting to unlock its host-managed encrypted credential store.
return {
CODEBUDDY_CONFIG_DIR: (options.env || process.env).CODEBUDDY_CONFIG_DIR
|| path.join(options.home || os.homedir(), '.workbuddy'),
CODEBUDDY_MODEL: `custom-local:${model.id}`,
CODEBUDDY_API_KEY: model.apiKey,
CODEBUDDY_BASE_URL: model.url,
}
}
function workBuddyCredentialState(options = {}) {
if (options.modelId) {
try {
workBuddyEnvironment(options)
return { state: 'ready', source: 'native-credential' }
} catch { return { state: 'unknown', source: 'workbuddy-model-unavailable' } }
}
const env = options.env || process.env
if (env.CODEBUDDY_API_KEY?.trim() || env.CODEBUDDY_AUTH_TOKEN?.trim()) {
return { state: 'ready', source: 'native-credential' }
}
// A models.json entry is not a selected provider. The desktop app's shared
// encrypted login also cannot be decoded by its standalone lite CLI.
if (launchEnvironment('workbuddy', options.executable, options).CODEBUDDY_FORCE_LITE_WB_BUNDLE !== '1') {
return { state: 'unknown', source: 'unverified' }
}
const home = options.home || os.homedir()
const platform = options.platform || process.platform
const root = platform === 'darwin' ? path.join(home, 'Library', 'Application Support')
: platform === 'win32' ? path.join(home, 'AppData', 'Local')
: path.join(home, '.local', 'share')
try {
const filename = path.join(root, 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info')
if (fs.statSync(filename).size <= 2 * 1024 * 1024) {
const auth = JSON.parse(fs.readFileSync(filename, 'utf8')).auth
if (auth?.accessToken && typeof auth.accessToken === 'object') {
return { state: 'unknown', source: 'workbuddy-desktop-locked' }
}
}
} catch { /* native CLI may still have an independent login */ }
return { state: 'unknown', source: 'unverified' }
}
module.exports = { workBuddyCredentialState, workBuddyEnvironment, workBuddySettings }
@@ -2,6 +2,7 @@ const fs = require('node:fs')
const http = require('node:http')
const os = require('node:os')
const path = require('node:path')
const { StringDecoder } = require('node:string_decoder')
const { spawn, spawnSync } = require('node:child_process')
const requestedPort = Number(process.env.MELDWORK_AGENT_BRIDGE_PORT || 8765)
@@ -33,6 +34,21 @@ const AGENT_DEFINITIONS = Object.freeze([
try { return JSON.parse(String(result.stdout || '')).loggedIn === true } catch { return false }
},
},
{ id: 'openclaw', label: 'OpenClaw' },
{ id: 'workbuddy', label: 'WorkBuddy', commands: ['codebuddy'] },
{ id: 'zcode', label: 'Zcode' },
{ id: 'deepseek', label: 'DeepSeek Harness', commands: ['dsh'] },
{ id: 'pi', label: 'Pi Agent', commands: ['pi', 'pi-agent', 'piagent'] },
{ id: 'kimi', label: 'Kimi Code' },
{ id: 'mimo', label: 'MiMo Code' },
{ id: 'gemini', label: 'Gemini CLI' },
{
id: 'opencode', label: 'OpenCode',
description: 'OpenCode exposed through an SSH-local Bridge.',
auth() { return null },
},
{ id: 'qwen', label: 'Qwen Code' },
{ id: 'opencodereview', label: 'OpenCodeReview', commands: ['ocr'] },
])
function json(res, status, body) {
@@ -56,10 +72,20 @@ function commandResult(spawnSyncFn, command, args) {
function listSkills(kind, options = {}) {
const readdirSync = options.readdirSync || fs.readdirSync
const home = options.home || os.homedir()
const env = options.env || process.env
const nativeRoot = (key, fallback) => {
const configured = typeof env[key] === 'string' ? env[key] : ''
if (!configured.trim()) return fallback
if (configured === '~') return home
if (configured.startsWith('~/')) return path.join(home, configured.slice(2))
return path.resolve(home, configured)
}
const roots = {
codex: [path.join(home, '.codex', 'skills'), path.join(home, '.agents', 'skills')],
hermes: [path.join(home, '.hermes', 'skills'), path.join(home, '.agents', 'skills')],
claude: [path.join(home, '.claude', 'skills'), path.join(home, '.agents', 'skills')],
codex: [path.join(nativeRoot('CODEX_HOME', path.join(home, '.codex')), 'skills'), path.join(home, '.agents', 'skills')],
hermes: [path.join(nativeRoot('HERMES_HOME', path.join(home, '.hermes')), 'skills'), path.join(home, '.agents', 'skills')],
claude: [path.join(nativeRoot('CLAUDE_CONFIG_DIR', path.join(home, '.claude')), 'skills'), path.join(home, '.agents', 'skills')],
opencode: [path.join(nativeRoot('XDG_CONFIG_HOME', path.join(home, '.config')), 'opencode', 'skills'),
path.join(nativeRoot('XDG_CONFIG_HOME', path.join(home, '.config')), 'opencode', 'skill')],
}[kind] || []
const names = new Set()
for (const root of roots) {
@@ -79,25 +105,27 @@ function discoverAgents(options = {}) {
const command = (name, args) => commandResult(spawnSyncFn, name, args)
const agents = []
for (const definition of AGENT_DEFINITIONS) {
const versionResult = command(definition.id, ['--version'])
const version = versionResult.status === 0 ? versionFrom(versionResult.stdout) : ''
const version = (definition.commands || [definition.id]).map(name => command(name, ['--version']))
.map(result => result.status === 0 ? versionFrom(result.stdout) : '').find(Boolean) || ''
if (!version) continue
const available = definition.auth(command)
const auth = definition.auth ? definition.auth(command) : null
const available = Boolean(definition.auth) && auth !== false
agents.push({
id: definition.id,
sourceKind: definition.id,
label: definition.label,
version,
description: definition.description,
description: definition.description || 'Detected on the server; this CLI is not yet available through the SSH Bridge.',
domains: ['general'],
inputTypes: ['text'],
permissionModes: ['read-only'],
available,
credentialState: available ? 'ready' : 'missing',
executionSupported: Boolean(definition.auth),
credentialState: auth === true ? 'ready' : auth === false ? 'missing' : 'unknown',
skills: listSkillsFn(definition.id),
session: {
supported: true,
resume: true,
supported: Boolean(definition.auth),
resume: Boolean(definition.auth),
cancel: true,
checkpoint: false,
},
@@ -133,23 +161,50 @@ function invocationFor(agentId, prompt, sessionRef = '') {
input: '',
}
}
if (agentId === 'opencode') {
return {
command: 'opencode',
args: ['run', '--format', 'json', '--agent', 'plan',
...(sessionRef ? ['--session', sessionRef] : []), prompt],
input: '',
}
}
throw new Error('REMOTE_AGENT_UNSUPPORTED')
}
function parseAgentOutput(agentId, stdout) {
if (agentId === 'opencode') {
let sessionRef = ''
let text = ''
let completed = false
for (const line of String(stdout || '').split(/\r?\n/)) {
let event
try { event = JSON.parse(line) } catch { continue }
if (typeof event.sessionID === 'string') sessionRef = event.sessionID
if (event.type === 'text' && event.part?.type === 'text' && typeof event.part.text === 'string') text += event.part.text
if (event.type === 'error') return null
if (event.type === 'step_finish' || event.part?.type === 'step-finish') {
completed = ['stop', 'end_turn'].includes(String(event.part?.reason || event.reason || '').toLowerCase())
}
}
return completed && text.trim() ? { text: text.trim(), sessionRef, outcome: 'completed' } : null
}
if (agentId === 'codex') {
const messages = []
let sessionRef = ''
let completed = false
for (const line of String(stdout || '').split(/\r?\n/)) {
try {
const event = JSON.parse(line)
if (event.type === 'turn.failed' || event.type === 'error') return null
if (event.type === 'turn.completed') completed = true
if (event.type === 'thread.started' && typeof event.thread_id === 'string') sessionRef = event.thread_id
if (event.type === 'item.completed' && event.item?.type === 'agent_message'
&& typeof event.item.text === 'string') messages.push(event.item.text)
} catch { /* ignore non-JSON diagnostics */ }
}
const text = messages.join('\n').trim()
return text ? { text, sessionRef, outcome: 'completed' } : null
return completed && text ? { text, sessionRef, outcome: 'completed' } : null
}
if (agentId === 'hermes') {
const lines = String(stdout || '').split(/\r?\n/)
@@ -162,6 +217,7 @@ function parseAgentOutput(agentId, stdout) {
}
return true
}).join('\n').trim()
if (hermesFailedTurn(text)) return null
return text ? { text, sessionRef, outcome: 'completed' } : null
}
if (agentId === 'claude') {
@@ -173,6 +229,17 @@ function parseAgentOutput(agentId, stdout) {
return null
}
function hermesFailedTurn(text) {
return /^Provider said:\s*\S.*$/m.test(text)
&& /^(?:Your request was not processed\. Send it again if you still want me to carry it out\.|This turn did not complete\. Some actions may already have run; verify their effects before resending\.)$/m.test(text)
}
function hasOpenCodeError(stdout) {
return String(stdout || '').split(/\r?\n/).some(line => {
try { return JSON.parse(line)?.type === 'error' } catch { return false }
})
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = ''
@@ -196,8 +263,9 @@ function parentSessionDetached(parentPid = process.ppid, readFileSync = fs.readF
}
}
function runAgent(agentId, prompt, sessionRef = '') {
function runAgent(agentId, prompt, sessionRef = '', signal = null) {
return new Promise(resolve => {
if (signal?.aborted) return resolve({ outcome: 'cancelled' })
let invocation
try { invocation = invocationFor(agentId, prompt, sessionRef) } catch {
resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNSUPPORTED' } })
@@ -205,16 +273,85 @@ function runAgent(agentId, prompt, sessionRef = '') {
}
const child = spawn(invocation.command, invocation.args, {
stdio: ['pipe', 'pipe', 'pipe'], cwd: process.env.HOME || undefined,
detached: process.platform !== 'win32',
...(agentId === 'opencode' ? { env: { ...process.env, OPENCODE_PERMISSION: JSON.stringify({
'*': 'deny', read: 'allow', glob: 'allow', grep: 'allow', list: 'allow',
webfetch: 'allow', websearch: 'allow', question: 'deny', plan_enter: 'deny', plan_exit: 'deny',
}) } } : {}),
})
let stdout = ''
let stdoutBytes = 0
const stdoutDecoder = new StringDecoder('utf8')
let terminalResult = null
let terminalTimer = null
let terminalStopRequested = false
let forceTimer = null
let cancelled = false
let invalidOutput = false
let pendingOutputLine = ''
const sendSignal = value => {
if (process.platform !== 'win32' && child.pid) {
try { process.kill(-child.pid, value); return } catch { /* use direct child */ }
}
try { child.kill(value) } catch { /* already exited */ }
}
const cancel = () => {
cancelled = true
sendSignal('SIGTERM')
if (!forceTimer) forceTimer = setTimeout(() => sendSignal('SIGKILL'), 1500)
}
signal?.addEventListener('abort', cancel, { once: true })
if (signal?.aborted) cancel()
child.stdout.on('data', chunk => {
stdout += chunk.toString('utf8')
if (Buffer.byteLength(stdout) > MAX_RESPONSE_BYTES) child.kill('SIGTERM')
if (invalidOutput) return
stdoutBytes += chunk.length
const text = stdoutBytes <= MAX_RESPONSE_BYTES ? stdoutDecoder.write(chunk) : ''
stdout += text
const lines = `${pendingOutputLine}${text}`.split(/\r?\n/)
pendingOutputLine = lines.pop()
if (stdoutBytes > MAX_RESPONSE_BYTES || (agentId === 'opencode' && hasOpenCodeError(lines.join('\n')))) {
invalidOutput = true
terminalResult = null
if (terminalTimer) clearTimeout(terminalTimer)
sendSignal('SIGTERM')
if (!forceTimer) forceTimer = setTimeout(() => sendSignal('SIGKILL'), 1500)
return
}
if (agentId === 'opencode' && !terminalResult && !cancelled) {
terminalResult = parseAgentOutput(agentId, stdout)
if (terminalResult) terminalTimer = setTimeout(() => {
terminalStopRequested = true
child.kill('SIGTERM')
forceTimer = setTimeout(() => child.kill('SIGKILL'), 1500)
}, 1500)
}
})
child.stderr.resume()
child.once('error', () => resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNAVAILABLE' } }))
child.once('close', code => {
child.once('error', () => {
signal?.removeEventListener('abort', cancel)
if (forceTimer) clearTimeout(forceTimer)
resolve(cancelled ? { outcome: 'cancelled' }
: { outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNAVAILABLE' } })
})
child.once('close', (code, signalName) => {
if (terminalTimer) clearTimeout(terminalTimer)
if (forceTimer) clearTimeout(forceTimer)
signal?.removeEventListener('abort', cancel)
if (cancelled) return resolve({ outcome: 'cancelled' })
if (!invalidOutput) {
const tail = stdoutDecoder.end()
stdout += tail
pendingOutputLine += tail
}
if (invalidOutput || (agentId === 'opencode' && hasOpenCodeError(pendingOutputLine))) {
return resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_RESPONSE_INVALID' } })
}
if (terminalResult && (code === 0 || (terminalStopRequested
&& ['SIGTERM', 'SIGKILL'].includes(signalName)))) return resolve(terminalResult)
if (code !== 0) return resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_FAILED' } })
if (agentId === 'hermes' && hermesFailedTurn(stdout)) {
return resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_FAILED' } })
}
resolve(parseAgentOutput(agentId, stdout)
|| { outcome: 'failed', failure: { code: 'REMOTE_AGENT_RESPONSE_INVALID' } })
})
@@ -239,7 +376,13 @@ function createServer() {
if (!agent || typeof body.prompt !== 'string' || body.permissionMode !== 'read-only') {
return json(res, 400, { error: 'REQUEST_INVALID' })
}
return json(res, 200, await runAgent(agent.id, body.prompt, body.sessionRef || ''))
const controller = new AbortController()
const disconnect = () => { if (!res.writableFinished) controller.abort() }
res.once('close', disconnect)
if (res.destroyed) controller.abort()
const result = await runAgent(agent.id, body.prompt, body.sessionRef || '', controller.signal)
res.removeListener('close', disconnect)
if (!res.destroyed) return json(res, 200, result)
} catch {
return json(res, 400, { error: 'REQUEST_INVALID' })
}
@@ -265,6 +408,7 @@ function startServer() {
if (require.main === module || !require.main) startServer()
module.exports = {
AGENT_DEFINITIONS,
createServer,
discoverAgents,
invocationFor,
+145 -39
View File
@@ -2,8 +2,10 @@ const crypto = require('node:crypto')
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const net = require('node:net')
const { abortableHttpOperation, boundedHttpResponseText, discardHttpResponse, discardLateHttpResponse } = require('../bounded-http-response.cjs')
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
const { createHttpOutboundPayload, createSshOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
const { createAgentConnectorManifest } = require('../connectors/agent-connector-manifest.cjs')
@@ -22,7 +24,12 @@ const STORE_VERSION = 1
const DEFAULT_REQUEST_TIMEOUT_MS = 3 * 60 * 1000
const DEFAULT_RUN_TIMEOUT_MS = 10 * 60 * 1000
const MAX_REQUEST_TIMEOUT_MS = 15 * 60 * 1000
const SSH_DISCOVERY_COMMAND = 'if command -v codex >/dev/null 2>&1; then codex --version; fi'
const { AGENT_PROFILES } = require('../cli/cli-discovery.cjs')
const SSH_AGENT_DEFINITIONS = Object.entries(AGENT_PROFILES).map(([id, profile]) => ({ id, ...profile }))
const SSH_DISCOVERY_COMMAND = SSH_AGENT_DEFINITIONS.flatMap(definition =>
(definition.commands || [definition.id]).map(command =>
`if command -v ${command} >/dev/null 2>&1; then meldwork_version="$(${command} --version 2>/dev/null | head -n 1)"; if [ -n "$meldwork_version" ]; then printf '__MELDWORK_CLI__${definition.id}\\t%s\\n' "$meldwork_version"; fi; fi`),
).join('; ')
const SSH_CODEX_COMMAND = 'exec codex exec --json --skip-git-repo-check --sandbox read-only -'
const SSH_TUNNEL_READY = 'MELDWORK_AGENT_BRIDGE_READY'
const SSH_BRIDGE_SCRIPT = fs.readFileSync(require.resolve('./cloud-agent-bridge-server.cjs'), 'utf8')
@@ -131,16 +138,18 @@ function normalizeAgent(input) {
return {
id, sourceKind, label, version,
description: cleanText(input.description, 400),
available: input.available !== false,
executionSupported: input.executionSupported !== false && permissionModes.includes('read-only'),
available: input.available !== false && input.executionSupported !== false
&& permissionModes.includes('read-only'),
credentialState,
skills,
domains: [...new Set(domains)].slice(0, 16),
inputTypes: [...new Set(inputTypes)].slice(0, 16),
permissionModes: [...new Set(permissionModes)],
session: {
supported: input.session?.supported !== false,
resume: input.session?.resume !== false,
cancel: input.session?.cancel !== false,
supported: input.session?.supported === true,
resume: input.session?.supported === true && input.session?.resume === true,
cancel: input.session?.cancel === true,
checkpoint: false,
},
}
@@ -184,8 +193,10 @@ function manifestFor(record, agent) {
invocation: { recipeId: ssh ? SSH_RECIPE_ID : BRIDGE_RECIPE_ID, idempotencyMode: 'durable' },
domains: agent.domains,
session: agent.session,
inputTypes: agent.inputTypes,
permissionModes: agent.permissionModes,
// The current bridge request carries text only, regardless of what the
// remote Agent itself says it can accept.
inputTypes: ['text'],
permissionModes: ['read-only'],
eventProtocolVersion: 1,
eventTypes: ['Completed', 'Failed', 'Cancelled'],
usage: {
@@ -224,6 +235,7 @@ class CloudAgentBridgeStore {
this.sshExecute = typeof options.sshExecute === 'function' ? options.sshExecute : null
this.sshTunnelStart = typeof options.sshTunnelStart === 'function' ? options.sshTunnelStart : null
this.tunnels = new Map()
this.activeRuns = new Set()
this.timeoutMs = Number.isFinite(options.timeoutMs)
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.timeoutMs)))
: DEFAULT_REQUEST_TIMEOUT_MS
@@ -273,7 +285,13 @@ class CloudAgentBridgeStore {
const timeoutMs = Number.isFinite(options.timeoutMs)
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.timeoutMs)))
: this.timeoutMs
const timer = setTimeout(() => controller.abort(), timeoutMs)
let abortCause = ''
const abort = cause => {
if (abortCause) return
abortCause = cause
controller.abort()
}
const timer = setTimeout(() => abort('timeout'), timeoutMs)
const activityIntervalMs = Math.min(1000, Math.max(10, Math.floor(timeoutMs / 4)))
const reportActivity = () => {
try { options.onActivity?.() } catch { /* activity reporting must not fail the request */ }
@@ -282,22 +300,34 @@ class CloudAgentBridgeStore {
const activityTimer = options.onActivity
? setInterval(reportActivity, activityIntervalMs)
: null
const abortFromCaller = () => controller.abort()
const abortFromCaller = () => abort('caller')
options.signal?.addEventListener?.('abort', abortFromCaller, { once: true })
if (options.signal?.aborted) abortFromCaller()
try {
const response = await this.fetch(`${options.address}${pathname}`, {
const pendingResponse = this.fetch(`${options.address}${pathname}`, {
method: options.method || 'GET',
redirect: 'error',
signal: controller.signal,
headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },
...(options.body ? { body: JSON.stringify(options.body) } : {}),
...(options.body ? { body: options.bodyText ?? JSON.stringify(options.body) } : {}),
})
if (!response?.ok) throw bridgeError(`CLOUD_AGENT_BRIDGE_HTTP_${response?.status || 'FAILED'}`)
const text = await response.text()
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) fail('CLOUD_AGENT_BRIDGE_RESPONSE_TOO_LARGE')
discardLateHttpResponse(pendingResponse, controller.signal)
const response = await abortableHttpOperation(pendingResponse, controller.signal)
controller.signal.throwIfAborted()
if (!response?.ok) {
discardHttpResponse(response)
throw bridgeError(`CLOUD_AGENT_BRIDGE_HTTP_${response?.status || 'FAILED'}`)
}
const text = await abortableHttpOperation(
boundedHttpResponseText(response, MAX_RESPONSE_BYTES, 'CLOUD_AGENT_BRIDGE_RESPONSE_TOO_LARGE', controller.signal),
controller.signal,
)
controller.signal.throwIfAborted()
try { return JSON.parse(text) } catch { fail('CLOUD_AGENT_BRIDGE_RESPONSE_INVALID') }
} catch (error) {
if (error?.name === 'AbortError') fail('CLOUD_AGENT_BRIDGE_TIMEOUT')
if (error?.name === 'AbortError') {
fail(abortCause === 'caller' ? 'LOCAL_AGENT_EXECUTION_STOPPED' : 'CLOUD_AGENT_BRIDGE_TIMEOUT')
}
throw error
} finally {
clearTimeout(timer)
@@ -436,17 +466,31 @@ class CloudAgentBridgeStore {
}
async probeSsh(address) {
const result = await this.sshCommand(address, SSH_DISCOVERY_COMMAND)
const version = String(result.stdout || '').match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)?.[1]
if (!version) fail('CLOUD_AGENT_SSH_NO_AGENT')
const result = await this.sshCommand(address, loginShellCommand(SSH_DISCOVERY_COMMAND))
const output = String(result.stdout || '')
const found = new Map()
for (const line of output.split(/\r?\n/)) {
const match = line.match(/^__MELDWORK_CLI__([a-z]+)\t.*?\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)
if (match && !found.has(match[1])) found.set(match[1], match[2])
}
// Older SSH integrations returned only Codex's unprefixed version.
if (!found.size) {
const version = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)?.[1]
if (version) found.set('codex', version)
}
const agents = SSH_AGENT_DEFINITIONS.filter(definition => found.has(definition.id)).map(definition => ({
id: definition.id, label: definition.id === 'codex' ? 'Codex CLI' : definition.label, version: found.get(definition.id),
description: definition.id === 'codex' ? '' : 'Detected on the server; this CLI is not yet available through the SSH Bridge.',
available: definition.id === 'codex', executionSupported: definition.id === 'codex',
credentialState: definition.id === 'codex' ? 'ready' : 'unknown', domains: ['general'],
inputTypes: ['text'], permissionModes: ['read-only'],
session: { supported: false, resume: false, cancel: true, checkpoint: false },
}))
if (!agents.length) fail('CLOUD_AGENT_SSH_NO_AGENT')
return {
serverId: '',
serverLabel: normalizeSshAddress(address).address,
agents: [{
id: 'codex', label: 'Codex CLI', version, description: '', domains: ['general'],
inputTypes: ['text'], permissionModes: ['read-only'],
session: { supported: false, resume: false, cancel: true, checkpoint: false },
}],
agents,
}
}
@@ -608,6 +652,12 @@ class CloudAgentBridgeStore {
if (index < 0) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
this.stopTunnel(id)
this.records.splice(index, 1)
for (const active of this.activeRuns) {
if (active.bridgeId === id) {
active.removed = true
active.controller.abort()
}
}
this.persist()
return { deleted: true, bridgeId: id }
}
@@ -632,14 +682,13 @@ class CloudAgentBridgeStore {
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: 0,
custom: false,
connector: true,
cloud: true,
available: record.available && agent.available !== false,
credentialState: agent.credentialState,
availabilitySource: agent.executionSupported === false ? 'cloud-bridge-unsupported' : undefined,
serverLabel: record.label,
})))
}
@@ -661,28 +710,85 @@ class CloudAgentBridgeStore {
const agent = record.agents.find(item => item.id === agentId)
if (!agent) fail('CLOUD_AGENT_BRIDGE_AGENT_NOT_FOUND')
if (agent.available === false) fail('CLOUD_AGENT_BRIDGE_AGENT_UNAVAILABLE')
if (record.transport === 'ssh') return this.runSsh(record, agent, input)
return this.request(`/v1/agents/${encodeURIComponent(agentId)}/runs`, {
address: record.transport === 'ssh-tunnel' ? record.endpoint : record.address,
const target = {
transport: record.transport, address: record.address, endpoint: record.endpoint,
serverId: record.serverId,
manifestId: manifestFor(record, agent).manifestId,
}
if (target.transport === 'ssh') return this.runSsh(record, agent, input, target)
const body = {
prompt: input.prompt,
sessionRef: input.sessionRef || null,
permissionMode: input.permissionMode,
operationId: input.operationId,
resume: input.resume || null,
}
const serializedBody = JSON.stringify(body)
await input.onOutboundPayload?.(createHttpOutboundPayload({
prompt: input.prompt,
destination: target.transport === 'ssh-tunnel' ? '' : target.address,
tunneled: target.transport === 'ssh-tunnel',
wireBytes: Buffer.from(serializedBody, 'utf8'),
}))
this.assertRunTarget(record, agent.id, target, input.signal)
return this.runTransport(record, agentId, target, input.signal, signal => this.request(`/v1/agents/${encodeURIComponent(agentId)}/runs`, {
address: target.transport === 'ssh-tunnel' ? target.endpoint : target.address,
method: 'POST',
signal: input.signal,
signal,
timeoutMs: this.runTimeoutMs,
onActivity: input.onActivity,
body: {
prompt: input.prompt,
sessionRef: input.sessionRef || null,
permissionMode: input.permissionMode,
operationId: input.operationId,
resume: input.resume || null,
},
})
body,
bodyText: serializedBody,
}))
}
async runSsh(record, agent, input) {
async runTransport(record, agentId, target, callerSignal, execute) {
const active = { bridgeId: record.bridgeId, controller: new AbortController(), removed: false }
const abort = () => active.controller.abort()
callerSignal?.addEventListener('abort', abort, { once: true })
if (callerSignal?.aborted) abort()
this.activeRuns.add(active)
try {
const result = await execute(active.controller.signal)
if (active.removed) throw Object.assign(bridgeError('CLOUD_AGENT_BRIDGE_REMOVED'), { category: 'unknown' })
if (!this.runTargetMatches(record.bridgeId, agentId, target)) {
throw Object.assign(bridgeError('CLOUD_AGENT_BRIDGE_CHANGED'), { category: 'unknown' })
}
return result
} catch (error) {
if (active.removed) throw Object.assign(bridgeError('CLOUD_AGENT_BRIDGE_REMOVED'), { category: 'unknown' })
throw error
} finally {
this.activeRuns.delete(active)
callerSignal?.removeEventListener('abort', abort)
}
}
runTargetMatches(bridgeId, agentId, target) {
const current = this.records.find(item => item.bridgeId === bridgeId)
const agent = current?.agents.find(item => item.id === agentId)
if (!current || !current.available || !agent || agent.available === false
|| current.transport !== target.transport || current.address !== target.address
|| current.endpoint !== target.endpoint || current.serverId !== target.serverId) return false
try { return manifestFor(current, agent).manifestId === target.manifestId } catch { return false }
}
assertRunTarget(record, agentId, target, signal) {
if (signal?.aborted) fail('LOCAL_AGENT_EXECUTION_STOPPED')
const current = this.records.find(item => item.bridgeId === record.bridgeId)
if (current !== record || !this.runTargetMatches(record.bridgeId, agentId, target)) {
fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
}
}
async runSsh(record, agent, input, target) {
if (agent.id !== 'codex' || input.permissionMode !== 'read-only') {
fail('CLOUD_AGENT_SSH_PERMISSION_UNSUPPORTED')
}
const result = await this.sshCommand(record.address, SSH_CODEX_COMMAND, input.prompt, input.signal)
await input.onOutboundPayload?.(createSshOutboundPayload({ prompt: input.prompt }))
this.assertRunTarget(record, agent.id, target, input.signal)
const result = await this.runTransport(record, agent.id, target, input.signal,
signal => this.sshCommand(target.address, SSH_CODEX_COMMAND, input.prompt, signal))
const messages = []
let sessionRef = ''
for (const line of String(result.stdout || '').split('\n')) {
@@ -0,0 +1,25 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const toml = require('@iarna/toml')
const { redactSecrets } = require('../security/secret-redaction.cjs')
function codexConfiguredProvider(options = {}) {
const env = options.env || process.env
const home = options.home || os.homedir()
const root = env.CODEX_HOME ? String(env.CODEX_HOME).replace(/^~(?=$|[\\/])/, home) : path.join(home, '.codex')
if (!path.isAbsolute(root)) return ''
try {
const file = path.join(root, 'config.toml')
const stat = fs.statSync(file)
if (!stat.isFile() || stat.size > 1024 * 1024) return ''
const config = toml.parse(fs.readFileSync(file, 'utf8'))
const profile = typeof config.profile === 'string' ? config.profiles?.[config.profile] : null
const value = profile?.model_provider || config.model_provider
if (typeof value !== 'string') return ''
const provider = value.trim()
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(provider) && redactSecrets(provider) === provider ? provider : ''
} catch { return '' }
}
module.exports = { codexConfiguredProvider }
@@ -4,7 +4,7 @@ const path = require('node:path')
const { isSemanticVersion } = require('./agent-connector-manifest.cjs')
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
const { atomicWritePrivateFile, ensurePrivateDirectory } = require('../../security/private-file.cjs')
const { redactSecrets } = require('../../security/secret-redaction.cjs')
const STORE_VERSION = 1
@@ -153,6 +153,66 @@ class AgentConnectorInstanceStore {
}
}
recordExecution(input) {
const fields = ['runId', 'agentRunId', 'connectorId', 'connectorVersion', 'manifestId',
'manifestSchemaVersion', 'instanceId', 'recipeId', 'upstreamId', 'upstreamVersion', 'credentialRefId']
if (!exactKeys(input, fields)
|| ['runId', 'agentRunId', 'connectorId', 'instanceId', 'recipeId', 'upstreamId']
.some(key => typeof input[key] !== 'string' || !PUBLIC_ID.test(input[key]))
|| !MANIFEST_ID.test(input.manifestId)
|| !isSemanticVersion(input.connectorVersion) || !isSemanticVersion(input.upstreamVersion)
|| !Number.isSafeInteger(input.manifestSchemaVersion) || input.manifestSchemaVersion < 1
|| (input.credentialRefId !== null && !CREDENTIAL_REF.test(input.credentialRefId))) {
fail('AGENT_CONNECTOR_EXECUTION_RECORD_INVALID')
}
const directory = path.join(path.dirname(this.credentialStoragePath), 'agent-connector-executions')
const id = crypto.createHash('sha256').update(canonicalJson([input.runId, input.agentRunId])).digest('hex')
const filename = path.join(directory, `${id}.json`)
const temporary = path.join(directory, `.${id}.${crypto.randomBytes(8).toString('hex')}.tmp`)
const content = canonicalJson({ version: 1, ...input })
let descriptor
try {
ensurePrivateDirectory(path.dirname(this.credentialStoragePath))
ensurePrivateDirectory(directory)
descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL
| (fs.constants.O_NOFOLLOW || 0), 0o600)
fs.writeFileSync(descriptor, content, 'utf8')
fs.fsyncSync(descriptor)
fs.closeSync(descriptor)
descriptor = undefined
try {
fs.linkSync(temporary, filename)
} catch (error) {
if (error.code !== 'EEXIST') throw error
descriptor = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))
const stat = fs.fstatSync(descriptor)
if (!stat.isFile() || stat.size > 8192 || fs.readFileSync(descriptor, 'utf8') !== content) {
fail('AGENT_CONNECTOR_EXECUTION_RECORD_CONFLICT')
}
return
}
let directoryDescriptor
try {
directoryDescriptor = fs.openSync(directory, 'r')
fs.fsyncSync(directoryDescriptor)
} catch (error) {
const unsupported = ['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)
|| (process.platform === 'win32' && ['EPERM', 'EISDIR'].includes(error.code))
if (!unsupported) throw error
} finally {
if (directoryDescriptor !== undefined) fs.closeSync(directoryDescriptor)
}
} catch (error) {
if (error?.code === 'AGENT_CONNECTOR_EXECUTION_RECORD_CONFLICT') throw error
fail('AGENT_CONNECTOR_EXECUTION_RECORD_UNAVAILABLE')
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor)
try { fs.unlinkSync(temporary) } catch (error) {
if (error.code !== 'ENOENT') throw error
}
}
}
create(input) {
const normalized = normalizeCreateInput(input)
const instanceDocument = this.readInstanceDocument()
@@ -219,6 +279,25 @@ class AgentConnectorInstanceStore {
return this.readInstanceDocument().instances.map(frozenClone)
}
migrateManifest(previous, next) {
if (!previous || !next || !MANIFEST_ID.test(previous.manifestId || '')
|| !MANIFEST_ID.test(next.manifestId || '') || previous.connectorId !== next.connectorId
|| !PUBLIC_ID.test(next.connectorId || '') || !isSemanticVersion(next.connectorVersion)) {
fail('AGENT_CONNECTOR_INSTANCE_INVALID')
}
const document = this.readInstanceDocument()
let changed = false
for (const instance of document.instances) {
if (instance.manifestId !== previous.manifestId) continue
if (instance.connectorId !== previous.connectorId
|| instance.connectorVersion !== previous.connectorVersion) fail('AGENT_CONNECTOR_INSTANCE_STORE_UNAVAILABLE')
instance.manifestId = next.manifestId
instance.connectorVersion = next.connectorVersion
changed = true
}
if (changed) this.writeInstanceDocument(document)
}
resolveCredential(credentialRef) {
const normalizedRef = String(credentialRef || '')
if (!CREDENTIAL_REF.test(normalizedRef)) fail('AGENT_CONNECTOR_CREDENTIAL_UNAVAILABLE')
@@ -1,10 +1,14 @@
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { abortableHttpOperation, boundedHttpResponseText, discardHttpResponse, discardLateHttpResponse } = require('../bounded-http-response.cjs')
const { createHttpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
const {
createAgentConnectorManifest,
isSemanticVersion,
isUpstreamVersionCompatible,
serializeAgentConnectorManifest,
} = require('./agent-connector-manifest.cjs')
const { AgentConnectorRegistry } = require('./agent-connector-registry.cjs')
@@ -21,6 +25,9 @@ const { BRIDGE_RECIPE_ID, SSH_RECIPE_ID } = require('../cloud/cloud-agent-bridge
const LOCAL_DELEGATE_RECIPE_ID = 'external.local-agent.delegate'
const MAX_LOCAL_MANIFESTS = 64
const MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024
const DEFAULT_HTTP_TIMEOUT_MS = 10 * 60 * 1000
const SAMPLE_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
connectorId: 'external.codex-sample',
connectorVersion: '1.0.0',
@@ -49,7 +56,7 @@ const SAMPLE_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
},
outboundDestinations: [],
credentials: { mode: 'none', slots: [] },
license: 'LicenseRef-Meldwork-NC',
license: 'Apache-2.0',
})
const SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
connectorId: 'external.codex-provider-sample',
@@ -82,7 +89,7 @@ const SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest(
mode: 'credential-ref',
slots: [{ slotId: 'openai-api-key', type: 'api-key', required: true }],
},
license: 'LicenseRef-Meldwork-NC',
license: 'Apache-2.0',
})
const APPROVED_AGENT_CONNECTOR_MANIFESTS = Object.freeze([
SAMPLE_AGENT_CONNECTOR_MANIFEST,
@@ -117,7 +124,7 @@ const SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest(
},
outboundDestinations: [],
credentials: { mode: 'none', slots: [] },
license: 'LicenseRef-Meldwork-NC',
license: 'Apache-2.0',
})
const SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_PACKAGE = createAgentConnectorPackage({
publisher: { id: 'meldwork', name: 'Meldwork' },
@@ -138,7 +145,7 @@ function fail(code) {
function exactOptions(value) {
return value && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).every(key => [
'cloudBridges', 'fetch', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
'cloudBridges', 'fetch', 'httpTimeoutMs', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
].includes(key))
}
@@ -185,7 +192,7 @@ function delegatedCredentialEnvironment(manifest, values) {
fail('AGENT_CONNECTOR_CREDENTIAL_SCHEMA_UNSUPPORTED')
}
return values['openai-api-key']
? { OPENAI_API_KEY: values['openai-api-key'] }
? { CODEX_API_KEY: values['openai-api-key'] }
: {}
}
@@ -223,11 +230,14 @@ class LocalAgentConnectors {
|| typeof options.instanceStore.list !== 'function'
|| typeof options.instanceStore.listRecords !== 'function'
|| typeof options.instanceStore.resolveCredential !== 'function'
|| typeof options.instanceStore.recordExecution !== 'function'
|| typeof options.runAgent !== 'function'
|| (options.packageStore !== undefined
&& (typeof options.packageStore.installedPackages !== 'function'
|| typeof options.packageStore.packageForManifest !== 'function'))
|| (options.fetch !== undefined && typeof options.fetch !== 'function')) {
|| (options.fetch !== undefined && typeof options.fetch !== 'function')
|| (options.httpTimeoutMs !== undefined && (!Number.isInteger(options.httpTimeoutMs)
|| options.httpTimeoutMs < 1 || options.httpTimeoutMs > 20 * 60 * 1000))) {
fail('LOCAL_AGENT_CONNECTORS_OPTIONS_INVALID')
}
this.manifestDirectory = path.normalize(options.manifestDirectory)
@@ -235,6 +245,8 @@ class LocalAgentConnectors {
this.packageStore = options.packageStore || null
this.runAgent = options.runAgent
this.fetch = options.fetch || globalThis.fetch
this.httpTimeoutMs = options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS
this.httpRequests = new Set()
this.seedSample = options.seedSample === true
this.registry = this.createRegistry()
this.runtime = this.createRuntime(this.registry)
@@ -260,6 +272,8 @@ class LocalAgentConnectors {
createRuntime(registry) {
return new AgentConnectorRuntime({
registry,
recordExecution: record => this.instanceStore.recordExecution(record),
trustedLegacyEventRecipes: [LOCAL_DELEGATE_RECIPE_ID],
recipes: {
[LOCAL_DELEGATE_RECIPE_ID]: input => this.runDelegated(input),
[SDK_LOCAL_ECHO_RECIPE_ID]: input => this.runLocalEcho(input),
@@ -336,6 +350,25 @@ class LocalAgentConnectors {
}
refresh(detectedAgents = []) {
if (this.packageStore && typeof this.packageStore.audit === 'function'
&& typeof this.packageStore.inspect === 'function'
&& typeof this.instanceStore.migrateManifest === 'function') {
for (const installed of this.packageStore.installedPackages()) {
const events = this.packageStore.audit(installed.packageId)
let upgrade = null
for (const event of [...events].reverse()) {
if (event.action === 'imported') break
if (event.action === 'upgraded') { upgrade = event; break }
}
if (!upgrade) continue
try {
const previous = this.packageStore.inspect(upgrade.details.fromPackageId)
if (previous.state === 'disabled' && this.compatibleInstanceUpgrade(previous, installed)) {
this.instanceStore.migrateManifest(previous.manifest, installed.manifest)
}
} catch { /* Unmigrated instances remain unavailable and can be retried. */ }
}
}
const registry = this.createRegistry()
let diagnostics = []
try {
@@ -475,6 +508,10 @@ class LocalAgentConnectors {
&& semanticVersionFrom(agent.version)
))
if (!upstream) fail('AGENT_CONNECTOR_UPSTREAM_UNAVAILABLE')
const { trust: _trust, ...versionedManifest } = manifest
if (!isUpstreamVersionCompatible(versionedManifest, semanticVersionFrom(upstream.version))) {
fail('AGENT_CONNECTOR_UPSTREAM_INCOMPATIBLE')
}
const created = this.instanceStore.create({
manifestId: manifest.manifestId,
connectorId: manifest.connectorId,
@@ -487,7 +524,14 @@ class LocalAgentConnectors {
}
delete(instanceId) {
const result = this.instanceStore.delete(String(instanceId || ''))
const normalizedId = String(instanceId || '')
const result = this.instanceStore.delete(normalizedId)
for (const request of this.httpRequests) {
if (request.instanceId === normalizedId) {
request.trustChanged = true
request.controller.abort()
}
}
this.refresh(this.detectedUpstreams)
return result
}
@@ -534,9 +578,17 @@ class LocalAgentConnectors {
return this.requirePackageStore().audit(String(packageId || ''))
}
compatibleInstanceUpgrade(previous, target) {
return previous.publisher.id === target.publisher.id
&& previous.manifest.connectorId === target.manifest.connectorId
&& previous.provider.id === target.provider.id
&& canonicalJson(previous.manifest.credentials) === canonicalJson(target.manifest.credentials)
}
packageTransition(action, packageId) {
const store = this.requirePackageStore()
const normalizedId = String(packageId || '')
let replacedManifestId = ''
if (action === 'upgrade') {
const target = store.inspect(normalizedId)
const replaced = store.list().find(item => (
@@ -544,9 +596,18 @@ class LocalAgentConnectors {
&& item.connectorId === target.manifest.connectorId
&& item.packageId !== normalizedId
))
if (replaced && this.instanceStore.listRecords().some(item => (
item.manifestId === replaced.manifestId
))) fail('AGENT_CONNECTOR_PACKAGE_IN_USE')
if (replaced && [...this.httpRequests].some(request => request.packageId === replaced.packageId)) {
fail('AGENT_CONNECTOR_PACKAGE_IN_USE')
}
const instances = this.instanceStore.listRecords()
if (replaced && instances.some(item => item.connectorId === target.manifest.connectorId
&& item.manifestId !== replaced.manifestId)) fail('AGENT_CONNECTOR_PACKAGE_IN_USE')
if (replaced && instances.some(item => item.manifestId === replaced.manifestId)
&& (typeof this.instanceStore.migrateManifest !== 'function'
|| !this.compatibleInstanceUpgrade(store.inspect(replaced.packageId), target))) {
fail('AGENT_CONNECTOR_PACKAGE_IN_USE')
}
replacedManifestId = replaced?.manifestId || ''
}
if (action === 'remove') {
const manifestId = store.inspect(normalizedId).manifest.manifestId
@@ -555,7 +616,18 @@ class LocalAgentConnectors {
}
}
const result = store[action](normalizedId)
if (action === 'disable' || action === 'revoke') {
for (const request of this.httpRequests) {
if (request.packageId === normalizedId) {
request.trustChanged = true
request.controller.abort()
}
}
}
this.refresh(this.detectedUpstreams)
if (replacedManifestId && this.instanceStore.listRecords().some(item => item.manifestId === replacedManifestId)) {
fail('AGENT_CONNECTOR_INSTANCE_STORE_UNAVAILABLE')
}
return result
}
@@ -603,8 +675,6 @@ class LocalAgentConnectors {
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: manifest.inputTypes.includes('image') ? 32 : 0,
custom: !cloud,
connector: true,
@@ -635,6 +705,7 @@ class LocalAgentConnectors {
audio: snapshot.capabilities.inputTypes.includes('audio') ? 32 : 0,
video: snapshot.capabilities.inputTypes.includes('video') ? 32 : 0,
file: snapshot.capabilities.inputTypes.includes('file') ? 32 : 0,
fileFallback: false,
}
}
@@ -673,10 +744,11 @@ class LocalAgentConnectors {
operationId: input.operationId,
idempotencyKey: input.idempotencyKey,
connectorResume: input.resume,
...(input.credentialRefId ? { connectorCredentialIsolation: true } : {}),
...(input.credentialRefId ? { connectorCredentialIsolation: true,
connectorInstanceId: input.connector.instanceId } : {}),
...(Object.keys(credentialEnv).length ? { env: credentialEnv } : {}),
})
const outcome = String(result?.outcome || 'completed')
const outcome = String(result?.outcome || '')
if (outcome === 'completed' || outcome === 'partial') {
input.emit({ ...terminal, type: 'Completed', outcome })
} else if (outcome === 'waiting_input') {
@@ -739,7 +811,8 @@ class LocalAgentConnectors {
}
const text = input.resume?.type === 'input' ? input.resume.response : input.prompt
input.emit({ ...terminal, type: 'Completed', outcome: 'completed' })
return { text, outcome: 'completed', sessionRef: input.sessionRef || 'local-echo-session' }
return { text, outcome: 'completed', sessionRef: input.connector.capabilities.session.supported
? input.sessionRef || 'local-echo-session' : '' }
}
async runHttpJson(input) {
@@ -747,59 +820,128 @@ class LocalAgentConnectors {
if (!packageRecord || packageRecord.provider.id !== SDK_HTTP_JSON_RECIPE_ID) {
fail('AGENT_CONNECTOR_PACKAGE_NOT_INSTALLED')
}
const packageRevision = this.packageStore.audit(packageRecord.packageId).at(-1)?.eventId
const endpoint = packageRecord.provider.config.endpoint
const terminal = {
eventId: `${input.agentRunId}:terminal`,
cursor: `${input.agentRunId}:terminal`,
sequence: 1,
}
let credential = ''
let timedOut = false
let requestSent = false
const controller = new AbortController()
const activeRequest = { packageId: packageRecord.packageId, instanceId: input.connector.instanceId,
controller, trustChanged: false }
const abortTransport = () => controller.abort()
input.transportSignal?.addEventListener('abort', abortTransport, { once: true })
if (input.transportSignal?.aborted) abortTransport()
this.httpRequests.add(activeRequest)
let timer
try {
input.onOutboundPayload({ destination: endpoint, transport: 'http' })
const body = JSON.stringify({
prompt: input.prompt,
sessionRef: input.sessionRef || null,
resume: input.resume,
permissionMode: input.permissionMode,
operationId: input.operationId,
})
await abortableHttpOperation(input.onOutboundPayload(createHttpOutboundPayload({
prompt: input.prompt, destination: endpoint, wireBytes: Buffer.from(body, 'utf8'),
})), controller.signal)
const currentPackage = this.packageStore.packageForManifest(input.connector.manifestId)
if (!currentPackage || currentPackage.packageId !== packageRecord.packageId
|| this.packageStore.audit(packageRecord.packageId).at(-1)?.eventId !== packageRevision) {
fail('AGENT_CONNECTOR_PACKAGE_NOT_INSTALLED')
}
const currentExecution = this.registry.resolveExecution(input.connector.instanceId)
if (currentExecution.provenance.manifestId !== input.connector.manifestId
|| currentExecution.credentialRef !== input.credentialRefId) {
fail('AGENT_CONNECTOR_INSTANCE_NOT_FOUND')
}
const headers = { 'content-type': 'application/json' }
const authSlotId = packageRecord.provider.config.authSlotId
if (authSlotId) {
const credentials = this.instanceStore.resolveCredential(input.credentialRefId)
headers.authorization = `Bearer ${credentials[authSlotId]}`
credential = credentials[authSlotId]
headers.authorization = `Bearer ${credential}`
}
const response = await this.fetch(endpoint, {
controller.signal.throwIfAborted()
timer = setTimeout(() => { timedOut = true; controller.abort() }, this.httpTimeoutMs)
requestSent = true
const pendingResponse = this.fetch(endpoint, {
method: 'POST',
redirect: 'error',
headers,
signal: input.signal,
body: JSON.stringify({
prompt: input.prompt,
sessionRef: input.sessionRef || null,
resume: input.resume,
permissionMode: input.permissionMode,
operationId: input.operationId,
}),
signal: controller.signal,
body,
})
if (!response?.ok) throw Object.assign(new Error('AGENT_CONNECTOR_HTTP_FAILED'), {
code: 'AGENT_CONNECTOR_HTTP_FAILED', retryable: response?.status >= 500,
})
const text = await response.text()
if (typeof text !== 'string' || Buffer.byteLength(text) > 4 * 1024 * 1024) {
fail('AGENT_CONNECTOR_HTTP_RESPONSE_INVALID')
discardLateHttpResponse(pendingResponse, controller.signal)
const response = await abortableHttpOperation(pendingResponse, controller.signal)
controller.signal.throwIfAborted()
if (!response?.ok) {
discardHttpResponse(response)
throw Object.assign(new Error('AGENT_CONNECTOR_HTTP_FAILED'), {
code: 'AGENT_CONNECTOR_HTTP_FAILED', retryable: response?.status >= 500,
})
}
const text = await abortableHttpOperation(
boundedHttpResponseText(response, MAX_HTTP_RESPONSE_BYTES, 'AGENT_CONNECTOR_HTTP_RESPONSE_INVALID', controller.signal),
controller.signal,
)
controller.signal.throwIfAborted()
let parsed
try { parsed = JSON.parse(text) } catch { fail('AGENT_CONNECTOR_HTTP_RESPONSE_INVALID') }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
|| typeof parsed.text !== 'string' || parsed.text.includes('\u0000')
|| (parsed.outcome !== undefined && !['completed', 'partial', 'failed', 'cancelled'].includes(parsed.outcome))
|| (parsed.sessionRef !== undefined
&& (typeof parsed.sessionRef !== 'string' || parsed.sessionRef.length > 8192
|| /[\u0000-\u001f\u007f]/.test(parsed.sessionRef)))) {
|| /[\u0000-\u001f\u007f]/.test(parsed.sessionRef)))
|| (credential && (parsed.text.includes(credential)
|| parsed.sessionRef?.includes(credential)))) {
fail('AGENT_CONNECTOR_HTTP_RESPONSE_INVALID')
}
input.emit({ ...terminal, type: 'Completed', outcome: 'completed' })
return { text: parsed.text, sessionRef: parsed.sessionRef || '', outcome: 'completed' }
const outcome = parsed.outcome || 'completed'
if (outcome === 'failed' || parsed.success === false || parsed.error) {
const failure = { code: 'AGENT_CONNECTOR_HTTP_REMOTE_FAILED', category: 'execution', retryable: false }
input.emit({ ...terminal, type: 'Failed', ...failure })
return { outcome: 'failed', failure }
}
if (outcome === 'cancelled') {
input.emit({ ...terminal, type: 'Cancelled', reason: 'other' })
return { outcome: 'cancelled', sessionRef: parsed.sessionRef || '' }
}
input.emit({ ...terminal, type: 'Completed', outcome })
return { text: parsed.text, sessionRef: parsed.sessionRef || '', outcome }
} catch (error) {
if (input.signal?.aborted || error?.name === 'AbortError') {
if (activeRequest.trustChanged) {
const failure = { code: 'AGENT_CONNECTOR_HTTP_TRUST_CHANGED', category: requestSent ? 'unknown' : 'execution', retryable: false }
input.emit({ ...terminal, type: 'Failed', ...failure })
return { outcome: 'failed', failure }
}
if (timedOut && !input.transportSignal?.aborted) {
const failure = { code: 'AGENT_CONNECTOR_HTTP_TIMEOUT', category: 'timeout', retryable: false }
input.emit({ ...terminal, type: 'Failed', ...failure })
return { outcome: 'failed', failure }
}
if (input.transportSignal?.aborted && (requestSent || !input.connector.capabilities.session.cancel)) {
const failure = { code: 'AGENT_CONNECTOR_HTTP_TRANSPORT_ABORTED', category: requestSent ? 'unknown' : 'execution', retryable: false }
input.emit({ ...terminal, type: 'Failed', ...failure })
return { outcome: 'failed', failure }
}
if (input.signal?.aborted) {
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
return { outcome: 'cancelled', sessionRef: input.sessionRef || '' }
}
const failure = failureDetails(error)
if (credential && failure.code.includes(credential)) failure.code = 'AGENT_CONNECTOR_HTTP_FAILED'
input.emit({ ...terminal, type: 'Failed', ...failure })
return { outcome: 'failed', failure }
} finally {
this.httpRequests.delete(activeRequest)
clearTimeout(timer)
input.transportSignal?.removeEventListener('abort', abortTransport)
}
}
@@ -812,9 +954,6 @@ class LocalAgentConnectors {
sequence: 1,
}
try {
if (!['ssh', 'ssh-tunnel'].includes(entry.record.transport)) {
input.onOutboundPayload({ destination: entry.record.address, transport: 'http' })
}
const result = await this.cloudBridges.run({
bridgeId: entry.record.bridgeId,
agentId: entry.agent.id,
@@ -824,9 +963,11 @@ class LocalAgentConnectors {
permissionMode: 'read-only',
operationId: input.operationId,
resume: input.resume,
signal: input.signal,
signal: input.transportSignal,
onActivity: input.onActivity,
onOutboundPayload: input.onOutboundPayload,
})
if (input.transportSignal?.aborted) fail('CLOUD_AGENT_BRIDGE_TRANSPORT_ABORTED')
if (!result || typeof result !== 'object' || Array.isArray(result)
|| typeof result.text !== 'string' || result.text.includes('\u0000')
|| (result.sessionRef !== undefined
@@ -851,9 +992,10 @@ class LocalAgentConnectors {
outcome: result.outcome || 'completed',
}
} catch (error) {
if (input.signal?.aborted || error?.name === 'AbortError') {
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
return { outcome: 'cancelled', sessionRef: input.sessionRef || '' }
if (input.transportSignal?.aborted || error?.name === 'AbortError') {
const failure = { code: 'CLOUD_AGENT_BRIDGE_TRANSPORT_ABORTED', category: 'unknown', retryable: false }
input.emit({ ...terminal, type: 'Failed', ...failure })
return { outcome: 'failed', failure }
}
const failure = failureDetails(error)
input.emit({ ...terminal, type: 'Failed', ...failure })
@@ -9,7 +9,7 @@ const {
parseAgentConnectorManifest,
} = require('./agent-connector-manifest.cjs')
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
const { atomicWritePrivateFile, ensurePrivateDirectory } = require('../../security/private-file.cjs')
const { redactSecrets } = require('../../security/secret-redaction.cjs')
const AGENT_CONNECTOR_PACKAGE_VERSION = 1
@@ -61,6 +61,15 @@ const httpsEndpointSchema = z.string().min(9).max(2048).superRefine((value, cont
}
})
function assertSafePackageEndpoint(provider) {
if (provider.id !== SDK_HTTP_JSON_RECIPE_ID) return
try {
const pathname = decodeURIComponent(new URL(provider.config.endpoint).pathname)
if (redactSecrets(pathname) === pathname) return
} catch { /* Invalid encoding cannot be safely checked. */ }
fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
}
const publisherSchema = z.strictObject({
id: z.string().min(1).max(120).regex(PUBLIC_ID),
name: safeTextSchema(120),
@@ -108,7 +117,11 @@ const packageContentSchema = z.strictObject({
}
return
}
const endpoint = new URL(value.provider.config.endpoint)
let endpoint
try { endpoint = new URL(value.provider.config.endpoint) } catch {
context.addIssue({ code: 'custom', message: 'HTTP JSON endpoint invalid' })
return
}
const slotId = value.provider.config.authSlotId
const authSlot = manifest.credentials.slots.find(slot => slot.slotId === slotId)
if (manifest.transport.type !== 'http' || manifest.transport.protocol !== 'json'
@@ -124,6 +137,13 @@ const packageContentSchema = z.strictObject({
}
})
function supportsSdkCapabilities(manifest) {
return !manifest.session.checkpoint
&& manifest.invocation.idempotencyMode !== 'durable'
&& manifest.eventTypes.every(type => ['Completed', 'Failed', 'Cancelled'].includes(type))
&& Object.values(manifest.usage).every(value => value === false)
}
const packageRecordSchema = z.strictObject({
packageId: z.string().regex(PACKAGE_ID),
schemaVersion: z.literal(AGENT_CONNECTOR_PACKAGE_VERSION),
@@ -148,6 +168,10 @@ function canonicalPackage(record) {
function createAgentConnectorPackage(input) {
const result = packageContentSchema.safeParse(input)
if (!result.success) fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
assertSafePackageEndpoint(result.data.provider)
if (!supportsSdkCapabilities(parseAgentConnectorManifest(result.data.manifest))) {
fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
}
const content = {
...result.data,
manifest: parseAgentConnectorManifest(result.data.manifest),
@@ -259,7 +283,7 @@ function recordState(events) {
for (const event of events) {
const current = states.get(event.packageId)
if (event.action === 'imported') {
if (current || !exactKeys(event.details, ['originFilename', 'originSha256'])
if ((current && current.state !== 'removed') || !exactKeys(event.details, ['originFilename', 'originSha256'])
|| typeof event.details.originFilename !== 'string'
|| event.details.originFilename !== path.basename(event.details.originFilename)
|| !/^[a-f0-9]{64}$/.test(String(event.details.originSha256 || ''))) {
@@ -290,6 +314,13 @@ function recordState(events) {
|| !PACKAGE_ID.test(String(event.details.fromPackageId || ''))) {
fail('AGENT_CONNECTOR_PACKAGE_AUDIT_INVALID')
}
const previous = states.get(event.details.fromPackageId)
if (!previous || !['installed', 'disabled'].includes(previous.state)
|| event.details.fromPackageId === event.packageId) {
fail('AGENT_CONNECTOR_PACKAGE_AUDIT_INVALID')
}
previous.state = 'disabled'
previous.updatedAt = event.timestamp
current.state = 'installed'
} else {
if (!exactKeys(event.details, [])) fail('AGENT_CONNECTOR_PACKAGE_AUDIT_INVALID')
@@ -333,14 +364,8 @@ class AgentConnectorPackageStore {
}
ensureDirectories() {
fs.mkdirSync(this.packageDirectory, { recursive: true, mode: 0o700 })
for (const directory of [this.rootPath, this.packageDirectory]) {
const stat = fs.lstatSync(directory)
if (stat.isSymbolicLink() || !stat.isDirectory()) {
fail('AGENT_CONNECTOR_PACKAGE_STORE_UNAVAILABLE')
}
try { fs.chmodSync(directory, 0o700) } catch { /* best effort */ }
}
ensurePrivateDirectory(this.rootPath)
ensurePrivateDirectory(this.packageDirectory)
}
loadAudit() {
@@ -379,11 +404,13 @@ class AgentConnectorPackageStore {
this.requireAvailable()
const filename = this.packagePath(packageId)
try {
return parseAgentConnectorPackage(readRegularNoFollow(
const packageRecord = parseAgentConnectorPackage(readRegularNoFollow(
filename,
MAX_PACKAGE_BYTES,
'AGENT_CONNECTOR_PACKAGE_UNAVAILABLE',
))
if (packageRecord.packageId !== packageId) fail('AGENT_CONNECTOR_PACKAGE_ID_MISMATCH')
return packageRecord
} catch (error) {
if (error?.code?.startsWith('AGENT_CONNECTOR_')) throw error
fail('AGENT_CONNECTOR_PACKAGE_UNAVAILABLE')
@@ -410,6 +437,7 @@ class AgentConnectorPackageStore {
body.previousEventId,
)
let descriptor
let writeStarted = false
try {
this.ensureDirectories()
descriptor = fs.openSync(
@@ -423,13 +451,28 @@ class AgentConnectorPackageStore {
if (!stat.isFile() || stat.size + Buffer.byteLength(line) > MAX_AUDIT_BYTES) {
fail('AGENT_CONNECTOR_PACKAGE_AUDIT_LIMIT')
}
writeStarted = true
fs.writeFileSync(descriptor, line, 'utf8')
fs.fsyncSync(descriptor)
fs.fchmodSync(descriptor, 0o600)
fs.closeSync(descriptor)
descriptor = undefined
if (stat.size === 0) {
let directoryDescriptor
try {
directoryDescriptor = fs.openSync(this.rootPath, 'r')
fs.fsyncSync(directoryDescriptor)
} catch (error) {
const unsupported = ['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)
|| (process.platform === 'win32' && ['EPERM', 'EISDIR'].includes(error.code))
if (!unsupported) throw error
} finally {
if (directoryDescriptor !== undefined) fs.closeSync(directoryDescriptor)
}
}
} catch (error) {
if (descriptor !== undefined) try { fs.closeSync(descriptor) } catch {}
if (writeStarted) this.unavailableCode = 'AGENT_CONNECTOR_PACKAGE_AUDIT_UNAVAILABLE'
if (error?.code?.startsWith('AGENT_CONNECTOR_')) throw error
fail('AGENT_CONNECTOR_PACKAGE_AUDIT_UNAVAILABLE')
}
@@ -441,9 +484,11 @@ class AgentConnectorPackageStore {
import(input, originFilename = 'connector-package.json') {
this.requireAvailable()
const packageRecord = parseAgentConnectorPackage(input)
assertSafePackageEndpoint(packageRecord.provider)
if (!supportsSdkCapabilities(packageRecord.manifest)) fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
const existing = this.states.get(packageRecord.packageId)
if (existing) return this.inspect(packageRecord.packageId)
if (this.states.size >= MAX_PACKAGES) fail('AGENT_CONNECTOR_PACKAGE_LIMIT')
if (existing && existing.state !== 'removed') return this.inspect(packageRecord.packageId)
if (!existing && this.states.size >= MAX_PACKAGES) fail('AGENT_CONNECTOR_PACKAGE_LIMIT')
const filename = path.basename(String(originFilename || 'connector-package.json'))
if (!filename || filename.length > 255 || /[\u0000-\u001f\u007f]/.test(filename)) {
fail('AGENT_CONNECTOR_PACKAGE_ORIGIN_INVALID')
@@ -480,7 +525,14 @@ class AgentConnectorPackageStore {
if (!allowed[action]?.includes(current.state)) {
fail('AGENT_CONNECTOR_PACKAGE_TRANSITION_INVALID')
}
this.readPackage(normalizedId)
const target = this.readPackage(normalizedId)
if (action === 'installed' && !supportsSdkCapabilities(target.manifest)) {
fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
}
if (action === 'installed' && this.list().some(item => item.state === 'installed'
&& item.packageId !== normalizedId && item.connectorId === target.manifest.connectorId)) {
fail('AGENT_CONNECTOR_PACKAGE_UPGRADE_INVALID')
}
this.append(action, normalizedId)
return this.inspect(normalizedId)
}
@@ -500,17 +552,19 @@ class AgentConnectorPackageStore {
const current = this.stateFor(normalizedId)
if (current.state !== 'approved') fail('AGENT_CONNECTOR_PACKAGE_TRANSITION_INVALID')
const target = this.readPackage(normalizedId)
if (!supportsSdkCapabilities(target.manifest)) fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
const installed = this.list().filter(item => (
item.state === 'installed'
&& item.connectorId === target.manifest.connectorId
&& item.packageId !== normalizedId
))
if (installed.length !== 1) fail('AGENT_CONNECTOR_PACKAGE_UPGRADE_INVALID')
if (installed.length !== 1 || installed[0].publisher.id !== target.publisher.id) {
fail('AGENT_CONNECTOR_PACKAGE_UPGRADE_INVALID')
}
if (compareSemanticVersions(
target.manifest.connectorVersion,
installed[0].connectorVersion,
) <= 0) fail('AGENT_CONNECTOR_PACKAGE_UPGRADE_INVALID')
this.append('disabled', installed[0].packageId)
this.append('upgraded', normalizedId, { fromPackageId: installed[0].packageId })
return this.inspect(normalizedId)
}
@@ -544,6 +598,7 @@ class AgentConnectorPackageStore {
connectorVersion: item.manifest.connectorVersion,
manifestId: item.manifest.manifestId,
label: item.manifest.label,
supported: supportsSdkCapabilities(item.manifest),
origin: item.origin,
updatedAt: item.updatedAt,
})).sort((left, right) => (
@@ -555,9 +610,13 @@ class AgentConnectorPackageStore {
installedPackages() {
if (this.unavailableCode) return []
try {
return [...this.states.entries()]
const installed = [...this.states.entries()]
.filter(([, state]) => state.state === 'installed')
.map(([packageId]) => this.inspect(packageId))
.filter(item => supportsSdkCapabilities(item.manifest))
const counts = new Map()
for (const item of installed) counts.set(item.manifest.connectorId, (counts.get(item.manifest.connectorId) || 0) + 1)
return installed.filter(item => counts.get(item.manifest.connectorId) === 1)
} catch (error) {
this.unavailableCode = error?.code || 'AGENT_CONNECTOR_PACKAGE_STORE_UNAVAILABLE'
return []
@@ -1,4 +1,10 @@
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { detectAttachmentType } = require('../../attachments/attachment-records.cjs')
const { automaticPermissionResponse, READ_ONLY_SAFE_TOOL_KINDS } = require('../agent-permission-policy.cjs')
const { SDK_HTTP_JSON_RECIPE_ID } = require('./agent-connector-package-store.cjs')
const { normalizeSessionRef } = require('../../runs/session-reference.cjs')
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
const {
@@ -11,8 +17,12 @@ const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
const SHA256 = /^[a-f0-9]{64}$/
const MAX_PROMPT_BYTES = 4 * 1024 * 1024
const MAX_INPUT_RESPONSE_BYTES = 128 * 1024
const MAX_ATTACHMENTS = 16
// Keep this aligned with the workspace message attachment limit.
const MAX_ATTACHMENTS = 32
const OUTBOUND_TRANSPORTS = new Set(['http', 'a2a'])
const ABORTABLE_TRANSPORT_RECIPES = new Set([
SDK_HTTP_JSON_RECIPE_ID, 'external.cloud-agent-bridge', 'external.cloud-agent-ssh',
])
function runtimeError(code) {
const error = new Error(code)
@@ -28,18 +38,39 @@ function clone(value) {
return JSON.parse(canonicalJson(value))
}
function attachmentFileType(filename) {
if (typeof filename !== 'string' || filename.length > 4096 || !path.isAbsolute(filename)
|| /[\u0000-\u001f\u007f]/.test(filename)) return null
let fd
try {
const initial = fs.lstatSync(filename)
if (initial.isSymbolicLink() || !initial.isFile()) return null
fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))
const stat = fs.fstatSync(fd)
if (!stat.isFile() || stat.dev !== initial.dev || stat.ino !== initial.ino || stat.size > 128 * 1024 * 1024) return null
const bytes = Buffer.alloc(Math.min(stat.size, 512))
const size = fs.readSync(fd, bytes, 0, bytes.length, 0)
return { size: stat.size, detected: detectAttachmentType(bytes.subarray(0, size)) }
} catch { return null } finally { if (fd !== undefined) fs.closeSync(fd) }
}
function attachmentInputType(attachment) {
if (typeof attachment === 'string') return attachment ? 'image' : ''
if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment)) return ''
const declared = String(attachment.inputType || attachment.type || '')
if (['image', 'audio', 'video', 'file', 'structured-data'].includes(declared)) {
return declared
if (typeof attachment === 'string') {
const file = attachmentFileType(attachment)
return file && file.size <= 8 * 1024 * 1024 && file.detected?.mimeType.startsWith('image/') ? 'image' : ''
}
if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment)) return ''
const mimeType = String(attachment.mimeType || attachment.mediaType || '').toLowerCase()
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('audio/')) return 'audio'
if (mimeType.startsWith('video/')) return 'video'
return mimeType ? 'file' : ''
if (mimeType) return 'file'
if (Object.hasOwn(attachment, 'path')) return ''
const declared = String(attachment.inputType || attachment.type || '')
if (['image', 'audio', 'video', 'file', 'structured-data'].includes(declared)) {
return declared
}
return ''
}
function validatedAttachments(value, capabilities) {
@@ -49,11 +80,25 @@ function validatedAttachments(value, capabilities) {
}
const attachments = [...value]
for (const attachment of attachments) {
if (typeof attachment === 'string' && !capabilities.inputTypes.includes('image')) {
fail('AGENT_CONNECTOR_ATTACHMENT_UNSUPPORTED')
}
const inputType = attachmentInputType(attachment)
if (!inputType) fail('AGENT_CONNECTOR_ATTACHMENTS_INVALID')
if (!capabilities.inputTypes.includes(inputType)) {
fail('AGENT_CONNECTOR_ATTACHMENT_UNSUPPORTED')
}
if (attachment && typeof attachment === 'object' && inputType !== 'structured-data'
&& !Object.hasOwn(attachment, 'path')) fail('AGENT_CONNECTOR_ATTACHMENTS_INVALID')
if (attachment && typeof attachment === 'object' && Object.hasOwn(attachment, 'path')) {
const file = attachmentFileType(attachment.path)
const declared = String(attachment.mimeType || attachment.mediaType || '').toLowerCase()
if (!file || (['image', 'audio', 'video'].includes(inputType)
&& (file.detected?.mimeType !== declared || file.size > file.detected.maxBytes))
|| (inputType === 'file' && /^(?:image|audio|video)\//.test(file.detected?.mimeType || ''))) {
fail('AGENT_CONNECTOR_ATTACHMENTS_INVALID')
}
}
}
return attachments
}
@@ -68,10 +113,13 @@ function outboundOrigin(value) {
}
}
function assertOutboundDestination(connector, value) {
function assertOutboundDestination(connector, value, outbound = {}) {
const destinations = connector.capabilities.outboundDestinations
const destination = String(value || '')
if (!destination) {
if (connector.recipeId === 'external.cloud-agent-bridge'
&& connector.upstreamId === 'meldwork-cloud'
&& outbound.tunneled === true && destinations.length === 0) return ''
if (destinations.length || OUTBOUND_TRANSPORTS.has(connector.transport.type)) {
fail('AGENT_CONNECTOR_OUTBOUND_DESTINATION_REQUIRED')
}
@@ -84,7 +132,7 @@ function assertOutboundDestination(connector, value) {
return origin
}
function assertEventCapabilities(connector, input) {
function assertEventCapabilities(connector, input, permissionMode) {
const type = String(input?.type || '')
if (!connector.capabilities.eventTypes.includes(type)) {
fail('AGENT_CONNECTOR_EVENT_UNDECLARED')
@@ -92,6 +140,10 @@ function assertEventCapabilities(connector, input) {
if (type === 'Cancelled' && !connector.capabilities.session.cancel) {
fail('AGENT_CONNECTOR_CANCEL_UNSUPPORTED')
}
if (type === 'Permission' && input.permission === 'workspace-write'
&& permissionMode !== 'workspace-write') {
fail('AGENT_CONNECTOR_PERMISSION_MODE_UNSUPPORTED')
}
if (type === 'Usage') {
for (const field of Object.keys(input?.usage || {})) {
if (connector.capabilities.usage[field] !== true) {
@@ -101,13 +153,53 @@ function assertEventCapabilities(connector, input) {
}
}
function assertResultCapabilities(connector, result) {
if (String(result?.sessionRef || '') && !connector.capabilities.session.supported) {
function validatedSessionRef(value) {
if (value == null) return ''
if (typeof value !== 'string' || (value !== '' && normalizeSessionRef(value) !== value)) {
fail('AGENT_CONNECTOR_SESSION_INVALID')
}
return value
}
function assertResultCapabilities(connector, result, state) {
if (validatedSessionRef(result?.sessionRef) && !connector.capabilities.session.supported) {
fail('AGENT_CONNECTOR_SESSION_UNSUPPORTED')
}
if (result?.outcome === 'cancelled' && !connector.capabilities.session.cancel) {
fail('AGENT_CONNECTOR_CANCEL_UNSUPPORTED')
}
if (result.usage != null) {
if (typeof result.usage !== 'object' || Array.isArray(result.usage)) fail('AGENT_CONNECTOR_USAGE_UNDECLARED')
for (const [field, value] of Object.entries(result.usage)) {
if (connector.capabilities.usage[field] !== true) fail('AGENT_CONNECTOR_USAGE_UNDECLARED')
if (!state.events.some(event => event.type === 'Usage' && Object.hasOwn(event.usage, field))
|| value !== state.usage[field]) fail('AGENT_CONNECTOR_USAGE_UNREPORTED')
}
}
}
function observedUsage(state) {
const fields = new Set(state.events.filter(event => event.type === 'Usage')
.flatMap(event => Object.keys(event.usage)))
return Object.fromEntries([...fields].map(field => [field, state.usage[field]]))
}
function validatedOutcomeRefs(result, state) {
const refs = result?.outcomeRefs
if (refs != null) {
if (typeof refs !== 'object' || Array.isArray(refs)
|| Object.keys(refs).some(key => !['artifactIds', 'evidenceIds'].includes(key))) {
fail('AGENT_CONNECTOR_OUTCOME_UNDECLARED')
}
for (const field of ['artifactIds', 'evidenceIds']) {
if (refs[field] == null) continue
if (!Array.isArray(refs[field]) || refs[field].some(id => !state[field].includes(id))) {
fail('AGENT_CONNECTOR_OUTCOME_UNDECLARED')
}
}
}
if (!refs && !state.artifactIds.length && !state.evidenceIds.length) return undefined
return { artifactIds: [...state.artifactIds], evidenceIds: [...state.evidenceIds] }
}
function normalizeResume(input) {
@@ -126,8 +218,8 @@ function normalizeResume(input) {
fail('AGENT_CONNECTOR_RESUME_INVALID')
}
if (type === 'input') {
const response = String(input.response || '').trim()
if (!response || Buffer.byteLength(response) > MAX_INPUT_RESPONSE_BYTES) {
const response = typeof input.response === 'string' ? input.response : ''
if (!response.trim() || Buffer.byteLength(response) > MAX_INPUT_RESPONSE_BYTES) {
fail('AGENT_CONNECTOR_RESUME_INVALID')
}
return Object.freeze({
@@ -136,11 +228,16 @@ function normalizeResume(input) {
}
const status = String(input.status || '')
const optionId = String(input.optionId || '')
const permission = input.permission == null ? '' : String(input.permission)
if (!['approved', 'rejected'].includes(status) || !PUBLIC_ID.test(optionId)) {
fail('AGENT_CONNECTOR_RESUME_INVALID')
}
if (permission && !['workspace-read', 'workspace-write', 'network', 'browser', 'external-message'].includes(permission)) {
fail('AGENT_CONNECTOR_RESUME_INVALID')
}
return Object.freeze({
type, requestId, requestHash, sessionRefHash, sessionProvenanceHash, status, optionId,
...(permission ? { permission } : {}),
})
}
@@ -255,7 +352,9 @@ class AgentConnectorRuntime {
fail('AGENT_CONNECTOR_RUNTIME_REGISTRY_REQUIRED')
}
this.registry = options.registry
this.recordExecution = options.recordExecution || (() => {})
this.recipes = new Map()
this.trustedLegacyEventRecipes = new Set(options.trustedLegacyEventRecipes || [])
for (const [recipeId, handler] of recipeEntries(options.recipes)) {
this.registerRecipe(recipeId, handler)
}
@@ -362,7 +461,7 @@ class AgentConnectorRuntime {
if (!connector.capabilities.inputTypes.includes('text')) {
fail('AGENT_CONNECTOR_INPUT_UNSUPPORTED')
}
const sessionRef = String(options.sessionRef || '')
const sessionRef = validatedSessionRef(options.sessionRef)
if (sessionRef && !connector.capabilities.session.supported) {
fail('AGENT_CONNECTOR_SESSION_UNSUPPORTED')
}
@@ -374,12 +473,24 @@ class AgentConnectorRuntime {
|| !connector.capabilities.session.resume)) {
fail('AGENT_CONNECTOR_RESUME_UNSUPPORTED')
}
if (resume && !connector.capabilities.eventTypes.includes(resume.type === 'input' ? 'WaitingInput' : 'Permission')) {
fail('AGENT_CONNECTOR_RESUME_UNSUPPORTED')
}
if (resume && crypto.createHash('sha256').update(sessionRef).digest('hex') !== resume.sessionRefHash) {
fail('AGENT_CONNECTOR_SESSION_MISMATCH')
}
if (options.signal?.aborted && !connector.capabilities.session.cancel) {
fail('AGENT_CONNECTOR_CANCEL_UNSUPPORTED')
}
const attachments = validatedAttachments(options.attachments, connector.capabilities)
this.recordExecution({ ...execution.runtimeProvenance, runId, agentRunId })
const emit = (input) => {
assertEventCapabilities(connector, input)
assertEventCapabilities(connector, input, permissionMode)
if (input?.type === 'Permission' && input.decision !== 'requested'
&& (resume?.type !== 'permission' || resume.requestId !== input.requestId
|| resume.status !== input.decision || resume.permission !== input.permission)) {
fail('AGENT_CONNECTOR_PERMISSION_DECISION_UNBOUND')
}
const event = parseConnectorRunEvent(input, provenance)
const duplicate = state.events.find(existing => existing.eventId === event.eventId)
if (duplicate && canonicalJson(duplicate) === canonicalJson(event)) return event
@@ -390,9 +501,19 @@ class AgentConnectorRuntime {
return event
}
const onOutboundPayload = (outbound = {}) => {
assertOutboundDestination(connector, outbound.destination)
assertOutboundDestination(connector, outbound.destination, outbound)
return options.onOutboundPayload?.(outbound)
}
const onPermissionRequest = connector.capabilities.eventTypes.includes('Permission')
&& typeof options.onPermissionRequest === 'function'
? (request, context) => {
if (permissionMode === 'read-only' && (['workspace-write', 'write'].includes(request?.permission)
|| (request?.toolCall && !READ_ONLY_SAFE_TOOL_KINDS.has(request.toolCall.kind)))) {
return automaticPermissionResponse(request, { allowWrite: false })
}
return options.onPermissionRequest(request, context)
}
: undefined
const result = await handler(Object.freeze({
agent: Object.freeze({
@@ -412,26 +533,35 @@ class AgentConnectorRuntime {
sessionRef,
attachments,
signal: connector.capabilities.session.cancel ? options.signal : undefined,
transportSignal: ABORTABLE_TRANSPORT_RECIPES.has(execution.recipeId) ? options.signal : undefined,
onActivity: options.onActivity,
emit,
onProgress: options.onProgress,
onRuntimeEvent: options.onEvent,
onProgress: this.trustedLegacyEventRecipes.has(execution.recipeId) ? options.onProgress : undefined,
onRuntimeEvent: this.trustedLegacyEventRecipes.has(execution.recipeId) ? options.onEvent : undefined,
onOutboundPayload,
assertOutboundDestination: destination => assertOutboundDestination(
connector, destination,
),
onPermissionRequest: options.onPermissionRequest,
onPermissionRequest,
resume,
}))
if (!result || typeof result !== 'object' || Array.isArray(result)) {
fail('AGENT_CONNECTOR_RESULT_INVALID')
}
assertResultCapabilities(connector, result)
if (result.progress != null && !this.trustedLegacyEventRecipes.has(execution.recipeId)) {
fail('AGENT_CONNECTOR_EVENT_UNDECLARED')
}
assertResultCapabilities(connector, result, state)
const usage = observedUsage(state)
const outcomeRefs = validatedOutcomeRefs(result, state)
if (result.outcome != null && result.outcome !== state.status) {
fail('AGENT_CONNECTOR_RESULT_MISMATCH')
}
if (['waiting_input', 'waiting_permission'].includes(state.status)) {
if (!connector.capabilities.session.supported || !connector.capabilities.session.resume) {
fail('AGENT_CONNECTOR_RESUME_UNSUPPORTED')
}
const waiting = state.events.at(-1)
const validWaiting = state.status === 'waiting_input'
? waiting?.type === 'WaitingInput'
@@ -439,8 +569,9 @@ class AgentConnectorRuntime {
if (!validWaiting) fail('AGENT_CONNECTOR_WAITING_EVENT_REQUIRED')
return {
...result,
...(outcomeRefs ? { outcomeRefs } : {}),
outcome: state.status,
usage: state.usage,
usage,
waitingRequest: clone(waiting),
connector,
connectorEventState: clone(state),
@@ -460,7 +591,7 @@ class AgentConnectorRuntime {
category: terminal?.category || 'protocol',
retryable: terminal?.retryable === true,
},
usage: state.usage,
usage,
connector,
connectorEventState: clone(state),
}
@@ -470,15 +601,16 @@ class AgentConnectorRuntime {
text: terminal?.summary || '',
sessionRef: '',
outcome: 'cancelled',
usage: state.usage,
usage,
connector,
connectorEventState: clone(state),
}
}
return {
...result,
...(outcomeRefs ? { outcomeRefs } : {}),
outcome: state.status,
usage: state.usage,
usage,
connector,
connectorEventState: clone(state),
}
+5 -6
View File
@@ -1,6 +1,7 @@
const fs = require('node:fs')
const path = require('node:path')
const { agentRuntimeError } = require('./agent-runtime-contract.cjs')
const { networkEnvironment } = require('./cli/cli-network-environment.cjs')
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
const PROMPT_MODES = new Set(['stdin', 'argument'])
@@ -108,10 +109,11 @@ function executablePath(filename, platform = process.platform) {
return path.normalize(resolved)
}
function childEnvironment(source = process.env) {
const env = {}
function childEnvironment(source = process.env, platform = process.platform) {
const env = networkEnvironment(source, platform)
for (const name of CHILD_ENV_KEYS) {
const key = Object.keys(source).find(candidate => candidate.toLowerCase() === name.toLowerCase())
const key = Object.hasOwn(source, name) ? name
: Object.keys(source).find(candidate => candidate.toLowerCase() === name.toLowerCase())
const value = key ? source[key] : ''
if (typeof value === 'string' && value) env[name] = value
}
@@ -127,9 +129,6 @@ function publicProfile(definition, version = '') {
promptMode: definition.promptMode,
custom: true,
recommended: false,
providerCompatible: false,
providerSupport: 'custom-cli',
providerMode: 'custom',
installed: true,
installSupported: false,
installErrorCode: '',
+12 -9
View File
@@ -3,6 +3,7 @@ const { randomUUID } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { promisify } = require('node:util')
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
const {
CUSTOM_AGENT_KIND,
KILL_SETTLE_MS,
@@ -34,6 +35,7 @@ class CustomAgentStore {
storagePath,
platform = process.platform,
execFileFn = execFileAsync,
environment = async () => ({}),
createId = randomUUID,
now = () => new Date().toISOString(),
}) {
@@ -41,6 +43,7 @@ class CustomAgentStore {
this.storagePath = path.resolve(storagePath)
this.platform = platform
this.execFileFn = execFileFn
this.environment = environment
this.createId = createId
this.now = now
this.definitions = this.load()
@@ -59,13 +62,12 @@ class CustomAgentStore {
}
save() {
fs.mkdirSync(path.dirname(this.storagePath), { recursive: true })
const tempPath = `${this.storagePath}.tmp`
fs.writeFileSync(tempPath, `${JSON.stringify({ version: 1, agents: this.definitions }, null, 2)}\n`, {
mode: 0o600,
})
fs.renameSync(tempPath, this.storagePath)
try { fs.chmodSync(this.storagePath, 0o600) } catch { /* Windows ignores POSIX file modes. */ }
try {
atomicWritePrivateFile(this.storagePath, `${JSON.stringify({ version: 1, agents: this.definitions }, null, 2)}\n`)
} catch (error) {
this.definitions = this.load()
throw error
}
}
has(kind) {
@@ -82,10 +84,11 @@ class CustomAgentStore {
async probeVersion(definition) {
try {
const source = { ...process.env, ...await this.environment() }
const result = await this.execFileFn(definition.executable, ['--version'], {
timeout: 5000,
windowsHide: true,
env: childEnvironment(),
env: childEnvironment(source, this.platform),
})
return [result?.stdout, result?.stderr]
.flatMap(output => String(output || '').split(/\r?\n/))
@@ -206,7 +209,7 @@ class CustomAgentStore {
promptMode: definition.promptMode,
}))
}
const env = childEnvironment(options.env || process.env)
const env = childEnvironment({ ...process.env, ...await this.environment(), ...options.env }, this.platform)
return await new Promise((resolve, reject) => {
let child
try {
@@ -0,0 +1,59 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const yaml = require('js-yaml')
const { redactSecrets } = require('../security/secret-redaction.cjs')
const { hermesDotenvAssignments } = require('./hermes-dotenv.cjs')
function hermesProviderFromConfig(config, env = {}) {
const model = config?.model && typeof config.model === 'object' && !Array.isArray(config.model) ? config.model : {}
const providerText = value => typeof value === 'string' ? value.trim()
: typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : ''
let provider = providerText(model.provider)
for (const key of ['default', 'model', 'name']) {
const nested = model[key]
if (nested && typeof nested === 'object' && !Array.isArray(nested)
&& nested.provider && providerText(nested.provider) && (!provider || provider === 'auto')) provider = providerText(nested.provider)
}
if (!provider && config?.provider) provider = providerText(config.provider)
return provider || (typeof env.HERMES_INFERENCE_PROVIDER === 'string' ? env.HERMES_INFERENCE_PROVIDER.trim() : '')
}
function hermesProviderEnvironment(root, env = {}) {
try {
const file = path.join(root, '.env')
const stat = fs.statSync(file)
if (stat.isFile() && stat.size <= 1024 * 1024) {
let provider
for (const [key, value] of hermesDotenvAssignments(fs.readFileSync(file, 'utf8'), env)) {
if (key === 'HERMES_INFERENCE_PROVIDER') provider = value ?? ''
}
if (provider !== undefined) return { ...env, HERMES_INFERENCE_PROVIDER: provider }
}
} catch { /* No readable native environment file. */ }
return env
}
function hermesConfiguredProvider(options = {}) {
const env = options.env || process.env
const home = options.home || os.homedir()
const root = env.HERMES_HOME ? String(env.HERMES_HOME).replace(/^~(?=$|[\\/])/, home)
: (options.platform || process.platform) === 'win32'
? path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), `hermes${env.HERMES_DATA_DIR_SUFFIX || ''}`)
: path.join(home, `.hermes${env.HERMES_DATA_DIR_SUFFIX || ''}`)
if (!path.isAbsolute(root)) return ''
let config = {}
try {
const file = path.join(root, 'config.yaml')
const stat = fs.statSync(file)
if (!stat.isFile() || stat.size > 1024 * 1024) return ''
config = yaml.load(fs.readFileSync(file, 'utf8')) || {}
if (typeof config !== 'object' || Array.isArray(config)) return ''
} catch (error) {
if (error.code !== 'ENOENT') return ''
}
const value = hermesProviderFromConfig(config, hermesProviderEnvironment(root, env))
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(value) && redactSecrets(value) === value ? value : ''
}
module.exports = { hermesConfiguredProvider, hermesProviderFromConfig, hermesProviderEnvironment }
+24
View File
@@ -0,0 +1,24 @@
function hermesDotenvAssignments(content, env = {}) {
const values = { ...env }
const assignments = []
for (const line of content.split(/\r?\n/)) {
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/)
if (!match) continue
const [, key, raw] = match
const quoted = raw.match(/^(['"])(.*?)\1\s*(?:#.*)?$/)
if (/^['"]/.test(raw) && !quoted) {
assignments.push([key, null])
continue
}
let value = quoted ? quoted[2] : raw.replace(/\s+#.*$/, '').trim()
if (quoted?.[1] === '"') value = value.replace(/\\([nr"\\])/g, (_, c) => ({ n: '\n', r: '\r', '"': '"', '\\': '\\' })[c])
if (quoted?.[1] === "'") value = value.replace(/\\(['\\])/g, '$1')
value = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g,
(_, name, fallback) => Object.hasOwn(values, name) ? values[name] : fallback || '')
values[key] = value
assignments.push([key, value])
}
return assignments
}
module.exports = { hermesDotenvAssignments }
@@ -4,6 +4,17 @@ const fs = require('node:fs')
const path = require('node:path')
const { promisify } = require('node:util')
const { searchPath } = require('../cli/cli-adapters.cjs')
const { compareReleaseVersions, extractAgentVersion } = require('../agent-compatibility.cjs')
// True when the installed CLI is the recipe's version or newer. A substring
// test treated "11.2.3" as "1.2.3" and offered downgrades for newer CLIs.
function installedVersionCurrent(installedVersion, targetVersion) {
const installed = extractAgentVersion(installedVersion).split('-')[0]
const target = extractAgentVersion(targetVersion).split('-')[0]
const comparison = installed && target ? compareReleaseVersions(installed, target) : null
if (comparison != null) return comparison >= 0
return Boolean(targetVersion) && String(installedVersion || '').includes(targetVersion)
}
const execFileAsync = promisify(execFile)
const DETECTION_CACHE_TTL_MS = 30000
@@ -90,6 +101,11 @@ const NPM_RELEASES = Object.freeze({
detectedVersion: '0.1.0',
integrity: 'sha512-YFqiotp1sHDmj2BOiw2AbgCY2zm+c7Z36lh5JNL6KACEvYgerB5kqqldqcy/xI4Erry501DsTN1YPxo2mw6fAQ==',
}),
deepseek: Object.freeze({
packageName: '@deepseek-ai/dsh',
version: '0.2.1-rc.2',
integrity: 'sha512-tvY+yhfKRifthbqPe/OK0AYxM5ZkvqfYq/jMYgJcEUxiRRL1Vwra3Ti5TB6AXgSWQdiPHYh/Vp6SJa9yHwMNuw==',
}),
opencodereview: Object.freeze({
packageName: '@alibaba-group/open-code-review',
version: '1.8.6',
@@ -99,50 +115,18 @@ const NPM_RELEASES = Object.freeze({
const ALLOWED_SCRIPT_URLS = new Set(Object.values(HERMES_RECIPES).map(recipe => recipe.url))
const AGENT_CATALOG = Object.freeze([
{
kind: 'codex', label: 'Codex', recommended: false, providerCompatible: false,
providerSupport: 'responses-required',
},
{
kind: 'claude', label: 'Claude Code', recommended: false, providerCompatible: false,
providerSupport: 'anthropic-required',
},
{
kind: 'hermes', label: 'Hermes', recommended: true, providerCompatible: true,
providerSupport: 'supported',
},
{
kind: 'workbuddy', label: 'WorkBuddy', recommended: true, providerCompatible: true,
providerSupport: 'experimental',
},
{
kind: 'openclaw', label: 'OpenClaw', recommended: true, providerCompatible: true,
providerSupport: 'supported',
},
{
kind: 'pi', label: 'Pi Agent', recommended: false, providerCompatible: false,
providerSupport: 'native-config',
},
{
kind: 'opencode', label: 'OpenCode', recommended: false, providerCompatible: false,
providerSupport: 'native-config',
},
{
kind: 'opencodereview', label: 'OpenCodeReview', recommended: false, providerCompatible: true,
providerSupport: 'supported',
},
{
kind: 'kimi', label: 'Kimi Code', recommended: false, providerCompatible: false,
providerSupport: 'native-config',
},
{
kind: 'mimo', label: 'MiMo Code', recommended: false, providerCompatible: false,
providerSupport: 'native-config',
},
{
kind: 'qwen', label: 'Qwen Code', recommended: false, providerCompatible: true,
providerSupport: 'supported',
},
{ kind: 'codex', label: 'Codex', recommended: false },
{ kind: 'claude', label: 'Claude Code', recommended: false },
{ kind: 'hermes', label: 'Hermes', recommended: true },
{ kind: 'workbuddy', label: 'WorkBuddy', recommended: true },
{ kind: 'openclaw', label: 'OpenClaw', recommended: true },
{ kind: 'pi', label: 'Pi Agent', recommended: false },
{ kind: 'opencode', label: 'OpenCode', recommended: false },
{ kind: 'opencodereview', label: 'OpenCodeReview', recommended: false },
{ kind: 'kimi', label: 'Kimi Code', recommended: false },
{ kind: 'mimo', label: 'MiMo Code', recommended: false },
{ kind: 'deepseek', label: 'DeepSeek Harness', recommended: false },
{ kind: 'qwen', label: 'Qwen Code', recommended: false },
])
function installRecipe(kind, platform) {
@@ -372,6 +356,7 @@ function verifiedRecipeAgent(agent, recipe) {
}
module.exports = {
installedVersionCurrent,
AGENT_CATALOG,
DETECTION_CACHE_TTL_MS,
abortError,
@@ -3,6 +3,7 @@ const fs = require('node:fs')
const { randomUUID } = require('node:crypto')
const { DISPLAY_LIMIT, listLocalAgentSkills } = require('../../skills/local-skill-catalog.cjs')
const {
installedVersionCurrent,
AGENT_CATALOG,
DETECTION_CACHE_TTL_MS,
abortError,
@@ -32,6 +33,11 @@ const COMPATIBILITY_REASONS = new Set([
'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
'LOCAL_AGENT_PROTOCOL_UNAVAILABLE',
])
const INCONCLUSIVE_REASONS = new Set([
'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
'LOCAL_AGENT_CAPABILITY_PROBE_FAILED',
'LOCAL_AGENT_AUTH_REQUIRED',
])
function publicCompatibility(agent) {
if (!agent) {
@@ -60,12 +66,15 @@ function publicCompatibility(agent) {
? agent.versionIdentified
: Boolean(resolvedVersion)
const compatible = compatibilityState === 'compatible'
const incompatibilityReason = compatibilityState === 'incompatible'
const incompatibilityReason = compatibilityState === 'unknown'
&& INCONCLUSIVE_REASONS.has(agent.incompatibilityReason)
? agent.incompatibilityReason
: compatibilityState === 'incompatible'
? (COMPATIBILITY_REASONS.has(agent.incompatibilityReason)
? agent.incompatibilityReason
: 'LOCAL_AGENT_VERSION_UNSUPPORTED')
: ''
const incompatibilityProbe = compatibilityState === 'incompatible'
const incompatibilityProbe = Boolean(incompatibilityReason)
&& /^[a-z0-9-]{1,80}$/.test(String(agent.incompatibilityProbe || ''))
? agent.incompatibilityProbe
: ''
@@ -164,6 +173,10 @@ class AgentInstaller extends EventEmitter {
agents: AGENT_CATALOG.map(profile => {
const agent = installed.get(profile.kind)
const recipe = installRecipe(profile.kind, this.platform)
const targetVersion = recipe?.detectedVersion || recipe?.version || ''
const currentVersion = String(agent?.version || '')
const upToDate = Boolean(agent && targetVersion
&& installedVersionCurrent(currentVersion, targetVersion))
let installSupported = Boolean(recipe)
let installErrorCode = ''
if (!recipe) {
@@ -177,6 +190,8 @@ class AgentInstaller extends EventEmitter {
...profile,
installed: Boolean(agent),
version: agent?.version || '',
targetVersion,
installAction: agent ? (upToDate ? 'current' : 'update') : 'install',
...publicCompatibility(agent),
installSupported,
installErrorCode,
@@ -230,7 +245,10 @@ class AgentInstaller extends EventEmitter {
this.invalidateDetectionCache()
const installed = await abortable(this.detectedAgents(), signal)
if (signal.aborted) throw abortError()
if (installed.some(agent => agent.kind === profile.kind)) {
const current = installed.find(agent => agent.kind === profile.kind)
const targetVersion = recipe.detectedVersion || recipe.version
if (current && (current.compatibilityState !== 'compatible'
|| installedVersionCurrent(current.version, targetVersion))) {
throw installerError('INSTALL_AGENT_ALREADY_INSTALLED')
}
let command = recipe.interpreter || ''
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,280 @@
const fs = require('node:fs')
const path = require('node:path')
const { createHmac, randomBytes } = require('node:crypto')
const { networkEnvironment } = require('./cli/cli-network-environment.cjs')
const NATIVE_CONFIG_IDENTITY_DIRECTORY = Symbol('nativeConfigIdentityDirectory')
const NATIVE_ENVIRONMENT_IDENTITY = Symbol('nativeEnvironmentIdentity')
const NATIVE_AUTH_IDENTITY_FILES = Symbol('nativeAuthIdentityFiles')
const NATIVE_PROJECT_CONFIG_PATHS = Symbol('nativeProjectConfigPaths')
const environmentIdentityKey = randomBytes(32)
const NETWORK_CERTIFICATE_KEYS = ['SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE']
function nativeNetworkIdentityFiles(env = {}, platform = process.platform) {
const source = networkEnvironment(env, platform)
const paths = platform === 'win32' ? path.win32 : path.posix
return NETWORK_CERTIFICATE_KEYS.map(key => source[key])
.filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
}
function cloudIdentityPathKeys(kind) {
return ['AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_WEB_IDENTITY_TOKEN_FILE', 'AWS_CA_BUNDLE',
'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', 'GOOGLE_APPLICATION_CREDENTIALS',
...(kind === 'hermes' ? ['VERTEX_CREDENTIALS_PATH'] : []),
...(kind === 'claude' ? ['AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_FEDERATED_TOKEN_FILE'] : [])]
}
function cloudIdentityFiles(kind, env, home, platform) {
const paths = platform === 'win32' ? path.win32 : path.posix
const configuredHome = platform === 'win32' ? env?.USERPROFILE : env?.HOME
const root = typeof configuredHome === 'string' && paths.isAbsolute(configuredHome) ? configuredHome : home
const defaults = typeof root === 'string' && paths.isAbsolute(root)
? { AWS_CONFIG_FILE: paths.join(root, '.aws', 'config'),
AWS_SHARED_CREDENTIALS_FILE: paths.join(root, '.aws', 'credentials') } : {}
const cloudRoot = env?.CLOUDSDK_CONFIG || (platform === 'win32'
? env?.APPDATA && paths.join(env.APPDATA, 'gcloud')
: root && paths.join(env?.XDG_CONFIG_HOME || paths.join(root, '.config'), 'gcloud'))
return [...cloudIdentityPathKeys(kind).map(key => env?.[key] || defaults[key]),
...(['hermes', 'pi', 'opencode'].includes(kind) && typeof cloudRoot === 'string' && paths.isAbsolute(cloudRoot)
? [paths.join(cloudRoot, 'application_default_credentials.json')] : [])]
.filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
}
function nativeProjectConfigPaths(kind, env = {}) {
let entries = kind === 'opencode' ? [[env.OPENCODE_CONFIG, false], [env.OPENCODE_CONFIG_DIR, true]]
: kind === 'mimo' ? [[env.MIMOCODE_CONFIG, false], [env.MIMOCODE_CONFIG_DIR, true]] : []
if (kind === 'zcode') entries = [[env.ZCODE_AGENT_CA_CERT, false]]
if (kind === 'gemini' || kind === 'qwen') {
const settings = env[kind === 'gemini' ? 'GEMINI_CLI_SYSTEM_SETTINGS_PATH' : 'QWEN_CODE_SYSTEM_SETTINGS_PATH']
const defaults = env[kind === 'gemini' ? 'GEMINI_CLI_SYSTEM_DEFAULTS_PATH' : 'QWEN_CODE_SYSTEM_DEFAULTS_PATH']
|| (typeof settings === 'string' && settings ? path.join(path.dirname(settings), 'system-defaults.json') : '')
entries = [[settings, false], [defaults, false], ...(kind === 'gemini' ? [[env.GOOGLE_APPLICATION_CREDENTIALS, false],
[env.GEMINI_CLI_TRUSTED_FOLDERS_PATH, false]]
: [[env.GEMINI_CLI_TRUSTED_FOLDERS_PATH, false]])]
}
if (kind === 'pi' || kind === 'claude' || kind === 'hermes') {
entries = cloudIdentityPathKeys(kind).map(key => [env[key], false])
}
if (kind === 'opencode') entries.push(...cloudIdentityPathKeys(kind).map(key => [env[key], false]))
if (['gemini', 'hermes', 'pi', 'opencode'].includes(kind)
&& typeof env.CLOUDSDK_CONFIG === 'string' && env.CLOUDSDK_CONFIG) {
entries.push([path.join(env.CLOUDSDK_CONFIG, 'application_default_credentials.json'), false])
}
const network = networkEnvironment(env)
entries.push(...NETWORK_CERTIFICATE_KEYS.map(key => [network[key], false]))
return entries.flatMap(([value, directory]) => {
return typeof value === 'string' && value && !path.isAbsolute(value)
&& !(kind === 'opencode' && /^~(?=$|[\\/])/.test(value))
? [{ value, directory }] : []
})
}
function nativeEnvironmentIdentity(env) {
const entries = Object.entries(env).filter(([, value]) => typeof value === 'string')
.sort(([left], [right]) => left.localeCompare(right))
return createHmac('sha256', environmentIdentityKey).update(JSON.stringify(entries)).digest('hex')
}
function nativeAuthIdentityFiles(kind, env, home, platform = process.platform) {
if (kind === 'opencodereview') {
const paths = platform === 'win32' ? path.win32 : path.posix
const configuredHome = platform === 'win32' ? env?.USERPROFILE : env?.HOME
const roots = [...new Set([home, configuredHome].filter(root => typeof root === 'string' && paths.isAbsolute(root)))]
return roots.flatMap(root => ['config.json', '.env'].map(name => paths.join(root, '.opencodereview', name)))
}
if (kind === 'pi' || kind === 'claude' || kind === 'hermes') {
return cloudIdentityFiles(kind, env, home, platform)
}
if (kind === 'qwen') {
const paths = platform === 'win32' ? path.win32 : path.posix
const configuredHome = platform === 'win32' ? env?.USERPROFILE : env?.HOME
const effectiveHome = typeof configuredHome === 'string' && configuredHome.trim() ? configuredHome.trim() : home
const settings = env?.QWEN_CODE_SYSTEM_SETTINGS_PATH || (platform === 'darwin'
? '/Library/Application Support/QwenCode/settings.json' : platform === 'win32'
? 'C:\\ProgramData\\qwen-code\\settings.json' : '/etc/qwen-code/settings.json')
const defaults = env?.QWEN_CODE_SYSTEM_DEFAULTS_PATH
|| (typeof settings === 'string' && paths.isAbsolute(settings) ? paths.join(paths.dirname(settings), 'system-defaults.json') : '')
const userRoot = typeof effectiveHome === 'string' && paths.isAbsolute(effectiveHome) ? paths.join(effectiveHome, '.qwen') : ''
return [settings, defaults, ...(userRoot ? ['settings.json', 'oauth_creds.json', '.env'].map(name => paths.join(userRoot, name)) : []),
env?.GEMINI_CLI_TRUSTED_FOLDERS_PATH || (userRoot && paths.join(userRoot, 'trustedFolders.json')),
...(typeof effectiveHome === 'string' && paths.isAbsolute(effectiveHome) ? [paths.join(effectiveHome, '.env')] : [])]
.filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
}
if (kind === 'gemini') {
const paths = platform === 'win32' ? path.win32 : path.posix
const settings = env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH || (platform === 'darwin'
? '/Library/Application Support/GeminiCli/settings.json' : platform === 'win32'
? 'C:\\ProgramData\\gemini-cli\\settings.json' : '/etc/gemini-cli/settings.json')
const defaults = env?.GEMINI_CLI_SYSTEM_DEFAULTS_PATH
|| (typeof settings === 'string' && paths.isAbsolute(settings) ? paths.join(paths.dirname(settings), 'system-defaults.json') : '')
const cloudRoot = env?.CLOUDSDK_CONFIG || (platform === 'win32'
? env?.APPDATA && paths.join(env.APPDATA, 'gcloud')
: paths.join(env?.XDG_CONFIG_HOME || paths.join(env?.HOME || home || '', '.config'), 'gcloud'))
const credentials = env?.GOOGLE_APPLICATION_CREDENTIALS
|| (typeof cloudRoot === 'string' && paths.isAbsolute(cloudRoot)
? paths.join(cloudRoot, 'application_default_credentials.json') : '')
return [settings, defaults, credentials, env?.GEMINI_CLI_TRUSTED_FOLDERS_PATH,
...(typeof home === 'string' && paths.isAbsolute(home) ? [paths.join(home, '.env')] : [])]
.filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
}
if (kind === 'zcode') {
const paths = platform === 'win32' ? path.win32 : path.posix
const configured = platform === 'win32' ? env?.USERPROFILE : env?.HOME
const root = typeof configured === 'string' && configured.trim() ? configured.trim() : home
return [typeof root === 'string' && paths.isAbsolute(root) ? paths.join(root, '.zcode', 'cli', 'config.json') : '',
env?.ZCODE_AGENT_CA_CERT].filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
}
if (kind === 'mimo') {
const explicit = env?.MIMOCODE_CONFIG
const userRoot = typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.mimocode') : ''
const configRoot = env?.XDG_CONFIG_HOME || (home && path.join(home, '.config'))
const sharedRoot = typeof configRoot === 'string' && path.isAbsolute(configRoot) ? path.join(configRoot, 'mimocode') : ''
const stateRoot = env?.XDG_STATE_HOME || (home && path.join(home, '.local', 'state'))
return [explicit, ...[userRoot, sharedRoot].filter(Boolean)
.flatMap(root => ['mimocode.json', 'mimocode.jsonc'].map(name => path.join(root, name))),
typeof stateRoot === 'string' && path.isAbsolute(stateRoot) ? path.join(stateRoot, 'mimocode', 'model.json') : '']
.filter(filename => typeof filename === 'string' && path.isAbsolute(filename))
}
if (kind !== 'opencode') return []
const root = env?.XDG_DATA_HOME || (typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.local', 'share') : '')
const configRoot = env?.XDG_CONFIG_HOME || (home && path.join(home, '.config'))
const sharedRoot = typeof configRoot === 'string' && path.isAbsolute(configRoot) ? path.join(configRoot, 'opencode') : ''
const explicit = typeof env?.OPENCODE_CONFIG === 'string' && typeof home === 'string' && path.isAbsolute(home)
? env.OPENCODE_CONFIG.replace(/^~(?=$|[\\/])/, home) : env?.OPENCODE_CONFIG
const stateRoot = env?.XDG_STATE_HOME || (home && path.join(home, '.local', 'state'))
const cacheRoot = env?.XDG_CACHE_HOME || (home && path.join(home, '.cache'))
return [typeof root === 'string' && path.isAbsolute(root) ? path.join(root, 'opencode', 'auth.json') : '', explicit,
...(sharedRoot ? ['config.json', 'opencode.json', 'opencode.jsonc'].map(name => path.join(sharedRoot, name)) : []),
...(typeof home === 'string' && path.isAbsolute(home)
? ['opencode.json', 'opencode.jsonc'].map(name => path.join(home, '.opencode', name)) : []),
typeof stateRoot === 'string' && path.isAbsolute(stateRoot) ? path.join(stateRoot, 'opencode', 'model.json') : '',
typeof cacheRoot === 'string' && path.isAbsolute(cacheRoot) ? path.join(cacheRoot, 'opencode', 'models.json') : '',
...cloudIdentityFiles(kind, env, home, platform)]
.filter(filename => typeof filename === 'string' && path.isAbsolute(filename))
}
const NATIVE_CONFIG_DIRECTORIES = Object.freeze({
hermes: { key: 'HERMES_HOME', files: ['config.yaml', 'auth.json', '.env'], identityFiles: ['config.json'] },
openclaw: { key: 'OPENCLAW_STATE_DIR', files: ['openclaw.json'], directories: ['agents'] },
codex: { key: 'CODEX_HOME', files: ['config.toml', 'auth.json'], identityFiles: ['config.json', '.env'] },
claude: { key: 'CLAUDE_CONFIG_DIR', files: ['settings.json', '.credentials.json'], identityFiles: ['settings.local.json', '.env'] },
pi: { key: 'PI_CODING_AGENT_DIR', files: ['settings.json', 'auth.json', 'models.json'] },
opencode: { key: 'OPENCODE_CONFIG_DIR', files: ['opencode.json', 'opencode.jsonc'] },
gemini: { key: 'GEMINI_CLI_HOME', files: ['oauth_creds.json', 'gemini-credentials.json', 'google_accounts.json', 'settings.json', '.env'], identityFiles: ['trustedFolders.json'] },
kimi: { key: 'KIMI_CODE_HOME', files: ['config.toml'], identityFiles: ['.env'], directories: ['credentials'] },
mimo: { key: 'MIMOCODE_CONFIG_DIR', files: ['mimocode.json', 'mimocode.jsonc'] },
deepseek: { key: 'DSH_HOME', files: ['cordis.patch.yml', '.credentials.yaml'], identityFiles: ['.env'], directories: ['profiles'] },
workbuddy: { key: 'CODEBUDDY_CONFIG_DIR', files: ['models.json', 'settings.json', '.credentials.json'] },
zcode: { key: 'HOME', files: ['config.json'] },
qwen: { key: 'HOME', files: ['settings.json', 'oauth_creds.json', '.env'], identityFiles: ['trustedFolders.json'] },
opencodereview: { key: 'HOME', files: ['config.json'], identityFiles: ['.env'] },
})
function openClawNativeRoot(env = {}, home, platform = process.platform) {
const paths = platform === 'win32' ? path.win32 : path.posix
if (typeof home !== 'string' || !paths.isAbsolute(home)) return ''
const expand = (value, base) => value.replace(/^~(?=$|[\\/])/, () => base)
const selectedHome = typeof env.OPENCLAW_HOME === 'string' && env.OPENCLAW_HOME.trim()
? expand(env.OPENCLAW_HOME.trim(), home) : home
if (!paths.isAbsolute(selectedHome)) return ''
const state = typeof env.OPENCLAW_STATE_DIR === 'string' ? env.OPENCLAW_STATE_DIR.trim() : ''
if (state) {
const root = expand(state, selectedHome)
return paths.isAbsolute(root) ? paths.resolve(root) : ''
}
const current = paths.join(selectedHome, '.openclaw')
const legacy = paths.join(selectedHome, '.clawdbot')
return !fs.existsSync(current) && fs.existsSync(legacy) ? legacy : current
}
function nativeIdentityDirectory(kind, env, home, platform = process.platform) {
if (kind === 'openclaw') return openClawNativeRoot(env, home, platform)
if (kind === 'qwen' || kind === 'opencodereview') {
const paths = platform === 'win32' ? path.win32 : path.posix
const base = (platform === 'win32' ? env?.USERPROFILE : env?.HOME) || home
return typeof base === 'string' && paths.isAbsolute(base) ? paths.join(base, kind === 'qwen' ? '.qwen' : '.opencodereview') : ''
}
if (kind === 'zcode') {
const paths = platform === 'win32' ? path.win32 : path.posix
const base = (platform === 'win32' ? env?.USERPROFILE : env?.HOME) || home
return typeof base === 'string' && paths.isAbsolute(base) ? paths.join(base, '.zcode', 'cli') : ''
}
if (kind === 'gemini') {
const base = env?.GEMINI_CLI_HOME || home
return typeof base === 'string' && path.isAbsolute(base) ? path.join(base, '.gemini') : ''
}
const spec = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, kind) ? NATIVE_CONFIG_DIRECTORIES[kind] : null
if (!spec || !path.isAbsolute(home)) return ''
const configured = env?.[spec.key]
if (typeof configured === 'string' && configured) {
const directory = kind === 'hermes' ? configured.replace(/^~(?=$|[\\/])/, home) : configured
return path.isAbsolute(directory) ? directory : ''
}
const defaults = {
hermes: platform === 'win32'
? path.join(env?.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), `hermes${env?.HERMES_DATA_DIR_SUFFIX || ''}`)
: path.join(home, `.hermes${env?.HERMES_DATA_DIR_SUFFIX || ''}`),
codex: path.join(home, '.codex'), claude: path.join(home, '.claude'), openclaw: path.join(home, '.openclaw'),
pi: path.join(home, '.pi', 'agent'), kimi: path.join(home, '.kimi-code'),
mimo: path.join(env?.XDG_CONFIG_HOME || path.join(home, '.config'), 'mimocode'),
opencode: path.join(env?.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode'),
deepseek: path.join(home, '.dsh'), workbuddy: path.join(home, '.workbuddy'),
}
return defaults[kind] || ''
}
function nativeConfigEnvironment(kind, preferences, env = {}, platform = process.platform) {
const spec = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, kind) ? NATIVE_CONFIG_DIRECTORIES[kind] : null
const directory = preferences?.[kind]?.nativeConfigDirectory
if (!spec || directory === undefined || directory === '') return env
if (typeof directory !== 'string' || directory.length > 4096
|| directory.includes('\u0000') || !path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if (kind === 'gemini') {
const name = path.basename(path.resolve(directory))
if ((platform === 'win32' ? name.toLowerCase() : name) !== '.gemini') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
}
if (kind === 'qwen' || kind === 'opencodereview') {
const resolved = path.resolve(directory)
const name = path.basename(resolved)
if ((platform === 'win32' ? name.toLowerCase() : name) !== (kind === 'qwen' ? '.qwen' : '.opencodereview')) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
const home = path.dirname(resolved)
return platform === 'win32' ? { ...env, HOME: home, USERPROFILE: home } : { ...env, HOME: home }
}
if (kind === 'zcode') {
const resolved = path.resolve(directory)
const name = path.basename(resolved)
const parent = path.basename(path.dirname(resolved))
if ((platform === 'win32' ? name.toLowerCase() : name) !== 'cli'
|| (platform === 'win32' ? parent.toLowerCase() : parent) !== '.zcode') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
const home = path.dirname(path.dirname(resolved))
return platform === 'win32' ? { ...env, HOME: home, USERPROFILE: home } : { ...env, HOME: home }
}
return { ...env, [spec.key]: kind === 'gemini' ? path.dirname(path.resolve(directory)) : directory }
}
function validateNativeConfigDirectory(kind, directory) {
const spec = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, kind) ? NATIVE_CONFIG_DIRECTORIES[kind] : null
if (!spec || typeof directory !== 'string' || directory.length > 4096
|| directory.includes('\u0000') || !path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
const caseName = value => process.platform === 'win32' ? value.toLowerCase() : value
if (kind === 'gemini' && caseName(path.basename(path.resolve(directory))) !== '.gemini') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if (kind === 'qwen' && caseName(path.basename(path.resolve(directory))) !== '.qwen') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if (kind === 'opencodereview' && caseName(path.basename(path.resolve(directory))) !== '.opencodereview') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
if (kind === 'zcode' && (caseName(path.basename(path.resolve(directory))) !== 'cli'
|| caseName(path.basename(path.dirname(path.resolve(directory)))) !== '.zcode')) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
let resolved
try {
resolved = fs.realpathSync(directory)
if (!fs.statSync(resolved).isDirectory()
|| !(spec.files.some(name => {
try { return fs.statSync(path.join(resolved, name)).isFile() } catch { return false }
}) || (spec.directories || []).some(name => {
try { return fs.statSync(path.join(resolved, name)).isDirectory() } catch { return false }
}))) throw new Error('invalid directory')
} catch { throw new Error('LOCAL_NATIVE_CONFIG_INVALID') }
return ['gemini', 'zcode', 'qwen', 'opencodereview'].includes(kind) ? path.resolve(directory) : resolved
}
module.exports = { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_ENVIRONMENT_IDENTITY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS, openClawNativeRoot,
nativeProjectConfigPaths, nativeNetworkIdentityFiles,
nativeEnvironmentIdentity, nativeAuthIdentityFiles, nativeConfigEnvironment,
nativeIdentityDirectory, validateNativeConfigDirectory }
@@ -0,0 +1,38 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { redactSecrets } = require('../security/secret-redaction.cjs')
const { qwenConfiguredAuth } = require('./cli/qwen-native-config.cjs')
function nativeUserConfigSummary(kind, options = {}) {
if (!['qwen', 'zcode'].includes(kind)) return null
const env = options.env || process.env
const platform = options.platform || process.platform
const configuredHome = platform === 'win32' ? env.USERPROFILE : env.HOME
const home = typeof configuredHome === 'string' && configuredHome.trim()
? configuredHome.trim() : (options.home || os.homedir())
const field = kind === 'qwen' ? 'authType' : 'provider'
const empty = { [field]: '' }
if (!path.isAbsolute(home)) return empty
try {
let value
if (kind === 'qwen') value = qwenConfiguredAuth(options)
else {
const envModel = typeof env.ZCODE_MODEL === 'string' ? env.ZCODE_MODEL.trim() : ''
if (envModel) value = envModel.includes('/') ? envModel.split('/')[0] : 'anthropic'
else {
const file = path.join(home, '.zcode', 'cli', 'config.json')
const stat = fs.statSync(file)
if (!stat.isFile() || stat.size > 1024 * 1024) return empty
const model = JSON.parse(fs.readFileSync(file, 'utf8'))?.model?.main
value = typeof model === 'string' ? (model.includes('/') ? model.split('/')[0] : 'anthropic') : model?.provider
}
}
if (typeof value !== 'string') return empty
const text = value.trim()
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(text) && redactSecrets(text) === text
? { [field]: text } : empty
} catch { return empty }
}
module.exports = { nativeUserConfigSummary }
+40 -8
View File
@@ -1,7 +1,8 @@
const crypto = require('node:crypto')
const path = require('node:path')
const STORE_VERSION = 1
const STORE_VERSION = 2
const ATTACHMENT_CHUNK_BYTES = 256 * 1024
const MAX_ATTACHMENT_BYTES = 128 * 1024 * 1024
const MAX_ATTACHMENTS = 4
const MAX_METADATA_BYTES = 64 * 1024
@@ -213,8 +214,13 @@ function detectAttachmentType(bytes, nameType = null, declaredMime = '') {
&& bytes.subarray(8, 12).toString('ascii') === 'WEBP') {
return TYPE_BY_MIME.get('image/webp')
}
if (bytes.length >= 3 && (bytes.subarray(0, 3).toString('ascii') === 'ID3'
|| (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0))) {
const id3Header = bytes.length >= 10 && bytes.subarray(0, 3).toString('ascii') === 'ID3'
&& bytes[3] >= 2 && bytes[3] <= 4 && bytes[4] !== 0xff
&& [...bytes.subarray(6, 10)].every(byte => byte < 0x80)
const mpegFrame = bytes.length >= 4 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0
&& ((bytes[1] >> 3) & 0x03) !== 1 && ((bytes[1] >> 1) & 0x03) !== 0
&& ((bytes[2] >> 4) & 0x0f) !== 15 && ((bytes[2] >> 2) & 0x03) !== 3
if (id3Header || mpegFrame) {
return TYPE_BY_MIME.get('audio/mpeg')
}
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF'
@@ -341,6 +347,18 @@ function validateAttachment(bytes, name, mimeType, requireMimeType) {
return actual
}
function chunkHashes(bytes) {
return Array.from({ length: Math.ceil(bytes.length / ATTACHMENT_CHUNK_BYTES) }, (_, index) => (
crypto.createHash('sha256').update(bytes.subarray(
index * ATTACHMENT_CHUNK_BYTES, (index + 1) * ATTACHMENT_CHUNK_BYTES,
)).digest('hex')
))
}
function chunkChecksum(chunks) {
return crypto.createHash('sha256').update(chunks.join('')).digest('hex')
}
function createAttachmentRecord(id, name, bytes, type) {
const metadata = {
id,
@@ -348,12 +366,14 @@ function createAttachmentRecord(id, name, bytes, type) {
mimeType: type.mimeType,
size: bytes.length,
}
const chunks = /^(?:image|audio|video)\//.test(type.mimeType) ? chunkHashes(bytes) : null
return {
metadata,
document: {
version: STORE_VERSION,
version: chunks ? STORE_VERSION : 1,
...metadata,
checksum: crypto.createHash('sha256').update(bytes).digest('hex'),
checksum: chunks ? chunkChecksum(chunks) : crypto.createHash('sha256').update(bytes).digest('hex'),
...(chunks ? { chunks } : {}),
},
}
}
@@ -361,14 +381,22 @@ function createAttachmentRecord(id, name, bytes, type) {
function parseAttachmentRecord(metadataBytes, id) {
let document
try { document = JSON.parse(metadataBytes.toString('utf8')) } catch { fail('LOCAL_ATTACHMENT_TAMPERED') }
if (sortedKeys(document) !== 'checksum,id,mimeType,name,size,version'
|| document.version !== STORE_VERSION || document.id !== id
const legacy = document?.version === 1
if (sortedKeys(document) !== (legacy
? 'checksum,id,mimeType,name,size,version' : 'checksum,chunks,id,mimeType,name,size,version')
|| (!legacy && document.version !== STORE_VERSION) || document.id !== id
|| typeof document.name !== 'string' || typeof document.mimeType !== 'string'
|| !Number.isSafeInteger(document.size) || document.size <= 0
|| document.size > MAX_ATTACHMENT_BYTES
|| typeof document.checksum !== 'string' || !/^[a-f0-9]{64}$/.test(document.checksum)) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
if (!legacy && (!Array.isArray(document.chunks)
|| document.chunks.length !== Math.ceil(document.size / ATTACHMENT_CHUNK_BYTES)
|| document.chunks.some(hash => typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash))
|| chunkChecksum(document.chunks) !== document.checksum)) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
const type = TYPE_BY_MIME.get(document.mimeType)
if (!type || sanitizeName(document.name, type) !== document.name) {
fail('LOCAL_ATTACHMENT_TAMPERED')
@@ -390,17 +418,21 @@ function validateStoredAttachment(bytes, document) {
|| detectAttachmentType(
bytes, declaredNameType(document.name), declaredMimeType(document.mimeType),
)?.mimeType !== document.mimeType
|| crypto.createHash('sha256').update(bytes).digest('hex') !== document.checksum) {
|| (document.version === 1
? crypto.createHash('sha256').update(bytes).digest('hex') !== document.checksum
: chunkChecksum(chunkHashes(bytes)) !== document.checksum)) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
}
module.exports = {
ATTACHMENT_FILE_EXTENSIONS,
ATTACHMENT_CHUNK_BYTES,
MAX_ATTACHMENT_BYTES,
MAX_METADATA_BYTES,
attachmentError,
createAttachmentRecord,
detectAttachmentType,
fail,
isAttachmentError,
normalizeDiscardReferences,
+34 -9
View File
@@ -1,4 +1,5 @@
const fs = require('node:fs')
const { Readable } = require('node:stream')
const {
assertImagePixelLimit,
@@ -12,6 +13,15 @@ const MAX_ATTACHMENT_DISCARD_REQUEST = 32
function attachmentIdsFromSnapshot(snapshot, groupId = '') {
const ids = []
for (const task of snapshot?.deliveryTasks || []) {
if (groupId && task.groupId !== groupId) continue
for (const version of task.versions) {
for (const attachment of version.attachments) ids.push(attachment.id)
}
for (const attempt of task.attempts) {
for (const source of attempt.sources) if (source.attachmentId) ids.push(source.attachmentId)
}
}
for (const message of Array.isArray(snapshot?.messages) ? snapshot.messages : []) {
if (groupId && message?.groupId !== groupId) continue
for (const attachment of Array.isArray(message?.attachments) ? message.attachments : []) {
@@ -109,6 +119,10 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
function preview(id) {
const { metadata, bytes } = availableStore().readWithMetadata(id)
return renderPreview(metadata, bytes)
}
function renderPreview(metadata, bytes) {
const attachment = {
id: metadata.id,
name: metadata.name,
@@ -142,35 +156,45 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
const id = mediaRequestId(request?.url)
if (!id) return new Response(null, { status: 404 })
let entry
try { entry = availableStore().readWithMetadata(id) } catch { return new Response(null, { status: 404 }) }
try {
const store = availableStore()
entry = typeof store.openMedia === 'function' ? store.openMedia(id) : store.readWithMetadata(id)
} catch { return new Response(null, { status: 404 }) }
const { metadata, bytes } = entry
if (!/^(?:image|audio|video)\//.test(metadata.mimeType) || bytes.length !== metadata.size) {
if (!/^(?:image|audio|video)\//.test(metadata.mimeType)
|| (bytes && bytes.length !== metadata.size)) {
return new Response(null, { status: 415 })
}
const range = mediaByteRange(request?.headers?.get?.('range'), bytes.length)
const range = mediaByteRange(request?.headers?.get?.('range'), metadata.size)
if (range === false) {
return new Response(null, {
status: 416,
headers: { 'Content-Range': `bytes */${bytes.length}` },
headers: { 'Content-Range': `bytes */${metadata.size}` },
})
}
const body = range ? bytes.subarray(range.start, range.end + 1) : bytes
const start = range?.start ?? 0
const end = range?.end ?? metadata.size - 1
const body = bytes ? bytes.subarray(start, end + 1)
: Readable.toWeb(Readable.from(entry.readRange(start, end)))
const headers = {
'Accept-Ranges': 'bytes',
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Length': String(body.length),
'Content-Length': String(end - start + 1),
'Content-Type': metadata.mimeType,
'X-Content-Type-Options': 'nosniff',
}
if (range) headers['Content-Range'] = `bytes ${range.start}-${range.end}/${bytes.length}`
if (range) headers['Content-Range'] = `bytes ${range.start}-${range.end}/${metadata.size}`
return new Response(body, { status: range ? 206 : 200, headers })
}
function importBuffer(input) {
const store = availableStore()
const metadata = store.importBuffer(input)
const imported = typeof store.importBufferWithBytes === 'function'
? store.importBufferWithBytes(input)
: { metadata: store.importBuffer(input), bytes: null }
const { metadata, bytes } = imported
try {
return preview(metadata.id)
return bytes ? renderPreview(metadata, bytes) : preview(metadata.id)
} catch (error) {
try { store.discard([metadata]) } catch { /* best effort */ }
throw error
@@ -178,6 +202,7 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
}
function importFiles(filenames) {
if (!Array.isArray(filenames)) throw new Error('LOCAL_ATTACHMENT_REFERENCE_INVALID')
if (filenames.length > MAX_ATTACHMENT_PICK_REQUEST) {
throw new Error('LOCAL_ATTACHMENT_COUNT_LIMIT')
}
+79 -7
View File
@@ -3,6 +3,7 @@ const fs = require('node:fs')
const path = require('node:path')
const {
ATTACHMENT_CHUNK_BYTES,
MAX_ATTACHMENT_BYTES,
MAX_METADATA_BYTES,
attachmentError,
@@ -77,12 +78,17 @@ class AttachmentStore {
}
importBuffer(input) {
return this.importBufferWithBytes(input).metadata
}
importBufferWithBytes(input) {
if (sortedKeys(input) !== 'bytes,mimeType,name'
|| typeof input.name !== 'string' || input.name.length > 4096
|| typeof input.mimeType !== 'string') {
fail('LOCAL_ATTACHMENT_INPUT_INVALID')
}
return this.importBytes(toBuffer(input.bytes), input.name, input.mimeType, true)
const bytes = toBuffer(input.bytes)
return { metadata: this.importBytes(bytes, input.name, input.mimeType, true), bytes }
}
importFile(filename) {
@@ -135,6 +141,67 @@ class AttachmentStore {
}
}
openMedia(id) {
const entry = this.loadEntryMetadata(normalizeId(id))
if (!entry.document.chunks) {
const legacy = this.loadEntry(id)
return { metadata: legacy.metadata, bytes: legacy.bytes }
}
try {
const stat = fs.lstatSync(entry.path)
if (stat.isSymbolicLink() || !stat.isFile() || stat.size !== entry.document.size
|| (process.platform !== 'win32' && (stat.mode & 0o777) !== FILE_MODE)
|| !isInside(this.rootRealPath, fs.realpathSync(entry.path))) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
} catch (error) {
if (isAttachmentError(error)) throw error
throw attachmentError('LOCAL_ATTACHMENT_TAMPERED')
}
const store = this
return {
metadata: entry.metadata,
async *readRange(start, end) {
let descriptor
try {
store.assertRoot()
const stat = fs.lstatSync(entry.path)
if (stat.isSymbolicLink() || !stat.isFile() || stat.size !== entry.document.size
|| (process.platform !== 'win32' && (stat.mode & 0o777) !== FILE_MODE)
|| !isInside(store.rootRealPath, fs.realpathSync(entry.path))) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0)
descriptor = fs.openSync(entry.path, fs.constants.O_RDONLY | noFollow)
const opened = fs.fstatSync(descriptor)
if (!opened.isFile() || opened.size !== stat.size
|| opened.dev !== stat.dev || opened.ino !== stat.ino
|| (process.platform !== 'win32' && (opened.mode & 0o777) !== FILE_MODE)) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
const first = Math.floor(start / ATTACHMENT_CHUNK_BYTES)
const last = Math.floor(end / ATTACHMENT_CHUNK_BYTES)
for (let index = first; index <= last; index += 1) {
const chunkStart = index * ATTACHMENT_CHUNK_BYTES
const chunkEnd = Math.min(entry.document.size, chunkStart + ATTACHMENT_CHUNK_BYTES) - 1
const parts = []
for await (const part of fs.createReadStream(entry.path, {
fd: descriptor, autoClose: false, start: chunkStart, end: chunkEnd,
})) parts.push(part)
const chunk = Buffer.concat(parts)
if (chunk.length !== chunkEnd - chunkStart + 1
|| crypto.createHash('sha256').update(chunk).digest('hex') !== entry.document.chunks[index]) {
fail('LOCAL_ATTACHMENT_TAMPERED')
}
yield chunk.subarray(Math.max(0, start - chunkStart), Math.min(chunk.length, end - chunkStart + 1))
}
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor)
}
},
}
}
discard(refs) {
this.assertRoot()
const discardedIds = []
@@ -281,7 +348,7 @@ class AttachmentStore {
}
}
loadEntry(id) {
loadEntryMetadata(id) {
this.assertRoot()
const directory = path.join(this.rootPath, id)
let directoryStat
@@ -306,17 +373,22 @@ class AttachmentStore {
)
const { document, type, metadata } = parseAttachmentRecord(metadataBytes, id)
const contentPath = path.join(directory, `${type.storageBase}.${type.extension}`)
return { directory, path: contentPath, document, metadata }
}
loadEntry(id) {
const entry = this.loadEntryMetadata(id)
const bytes = this.readStoredFile(
contentPath,
entry.path,
'LOCAL_ATTACHMENT_FILE_MISSING',
MAX_ATTACHMENT_BYTES,
)
validateStoredAttachment(bytes, document)
validateStoredAttachment(bytes, entry.document)
return {
directory,
path: contentPath,
directory: entry.directory,
path: entry.path,
bytes,
metadata,
metadata: entry.metadata,
}
}
@@ -0,0 +1,81 @@
const { V4_RECEIPT_MARKER_PREFIX } = require('./collaboration-receipt.cjs')
// Control markers an Agent appends to its answer. They steer the harness and
// must never reach the user's screen, even while the answer is still streaming.
const CONSENSUS_MARKERS = Object.freeze([
'[[MELDWORK_CONSENSUS:agree]]',
'[[MELDWORK_CONSENSUS:continue]]',
])
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Length of the longest buffer suffix that could still grow into a marker.
function heldSuffixLength(buffer, markers) {
let hold = 0
for (const marker of markers) {
for (let size = Math.min(marker.length - 1, buffer.length); size > hold; size -= 1) {
if (buffer.slice(-size).toLowerCase() === marker.slice(0, size).toLowerCase()) {
hold = size
break
}
}
}
return hold
}
// Streams answer deltas through a marker filter.
// plain nothing to hide: deltas pass through untouched.
// auto hide the legacy consensus markers.
// receipt hide the consensus markers and everything from the start of the
// collaboration receipt on (the receipt is parsed from the final text).
// `push` returns the text that is safe to show now; text that might be the
// start of a marker is held back until the next delta settles it, and
// `flush` releases what is left once the answer has finished.
function createAnswerMarkerFilter({ mode = 'plain' } = {}) {
if (mode === 'plain') {
return { push: delta => String(delta || ''), flush: () => '' }
}
const hideReceipt = mode === 'receipt'
const markers = hideReceipt
? [...CONSENSUS_MARKERS, V4_RECEIPT_MARKER_PREFIX]
: [...CONSENSUS_MARKERS]
const pattern = new RegExp(markers.map(escapeRegExp).join('|'), 'i')
let buffer = ''
let receiptStarted = false
return {
push(delta) {
if (receiptStarted) return ''
buffer += String(delta || '')
let visible = ''
while (buffer && !receiptStarted) {
const match = pattern.exec(buffer)
if (match) {
visible += buffer.slice(0, match.index)
buffer = buffer.slice(match.index + match[0].length)
if (hideReceipt
&& match[0].toLowerCase() === V4_RECEIPT_MARKER_PREFIX.toLowerCase()) {
receiptStarted = true
}
continue
}
const hold = heldSuffixLength(buffer, markers)
visible += hold ? buffer.slice(0, -hold) : buffer
buffer = hold ? buffer.slice(-hold) : ''
break
}
return visible
},
flush(finalText = '') {
const rest = buffer
buffer = ''
if (!hideReceipt) return rest
// A held fragment is real answer text only when the final answer ends
// with it; otherwise it was the start of the (hidden) receipt.
return !receiptStarted && rest && String(finalText).endsWith(rest) ? rest : ''
},
}
}
module.exports = { CONSENSUS_MARKERS, createAnswerMarkerFilter }
@@ -0,0 +1,295 @@
const { canonicalJson } = require('./context-pack-records.cjs')
const { parseLenientTaskDecision } = require('./task-decision.cjs')
const SHA256 = /^[a-f0-9]{64}$/
const V4_AGENT_PHASES = new Set([
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
])
const V4_RECEIPT_MARKER_PREFIX = '[[MELDWORK_COLLABORATION'
const V4_RECEIPT_MARKER = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?:([\s\S]*?)\]\]/i
const V4_RECEIPT_BLOCK = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?\]\]([\s\S]*?)\[\[\/MELDWORK_COLLABORATION(?:_JSON)?\]\]/i
const LEGACY_CONSENSUS_MARKER = /^\s*\[\[MELDWORK_CONSENSUS:(?:agree|continue)\]\]\s*$/gim
const V4_RECEIPT_MAX_TEXT = 800
const V4_RECEIPT_MAX_ITEMS = 16
const V4_RECEIPT_MAX_BYTES = 16 * 1024
function isV4InternalProtocolCommentary(value) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (!text || text.length > 4000 || /\[\[MELDWORK_COLLABORATION/i.test(text)) return false
const mentionsControl = /(?:\breceipt\b|structured marker|MELDWORK framework|ExitPlanMode|\u7ed3\u6784\u5316\s*receipt|receipt\s*\u6807\u8bb0)/i.test(text)
const describesInternalProcess = /(?:coordination\/self-introduction|not a code implementation task|no plan file|implementation planning|plan mode|waiting for (?:the )?(?:next|subsequent).*(?:phase|instruction)|\u534f\u4f5c\u534f\u8c03\u7c7b\u6587\u672c\u4ea7\u51fa|\u975e\u4ee3\u7801\u5b9e\u73b0\u4efb\u52a1|\u6267\u884c\u8ba1\u5212\u6d41\u7a0b|\u4e0d\u8c03\u7528\s*ExitPlanMode|\u672c\u8f6e\u4ea4\u4ed8|\u7b49\u5f85\u540e\u7eed.*(?:\u9636\u6bb5|\u6307\u4ee4))/i.test(text)
return mentionsControl && describesInternalProcess
}
function stripV4InternalProtocolTail(value) {
const paragraphs = String(value || '').trim().split(/\n{2,}/)
while (paragraphs.length && isV4InternalProtocolCommentary(paragraphs.at(-1))) {
paragraphs.pop()
}
return paragraphs.join('\n\n').trim()
}
function boundedReceiptText(value, limit = V4_RECEIPT_MAX_TEXT) {
if (typeof value !== 'string' || value.trim().length > limit) return ''
const text = cleanReceiptText(value, limit)
return text && !text.includes('\u0000') ? text : ''
}
function boundedReceiptList(value) {
if (value == null) return []
if (!Array.isArray(value) || value.length > V4_RECEIPT_MAX_ITEMS) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const result = value.map(item => boundedReceiptText(item))
if (result.some(item => !item)) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
return result
}
// A decision that is still unreadable after normalisation is treated as no
// decision: the orchestrator then asks the owner again instead of failing
// the Agent and removing it from the discussion.
function leniencyTaskDecision(value, summary) {
try {
return { taskDecision: parseLenientTaskDecision(value, { fallbackReason: summary }) }
} catch {
return {}
}
}
function parseV4CollaborationReceiptStrict(result, required = false, expectedPhase = '', options = {}) {
const rawText = typeof result?.text === 'string' ? result.text : ''
if (!required) return { text: rawText, collaboration: null }
let text = rawText
let receipt = result?.collaboration
// Natural discussion turns are free-form answers; a slightly malformed
// receipt there must not fail the turn and end the discussion. Strict
// phased-V4 receipts keep failing closed.
const lenient = expectedPhase === 'discussion'
let trailingVisibleText = ''
let unreadableReceipt = false
const block = rawText.match(V4_RECEIPT_BLOCK) || rawText.match(V4_RECEIPT_MARKER)
if (block) {
const markerStart = Number(block.index) || 0
const suffix = rawText.slice(markerStart + block[0].length).trim()
// A closing code fence after the receipt is formatting, not content.
if (suffix && !isV4InternalProtocolCommentary(suffix) && !/^[\s`~]*$/.test(suffix)) {
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
trailingVisibleText = suffix
}
const encoded = String(block[1] || '').trim()
if (!encoded || Buffer.byteLength(encoded) > V4_RECEIPT_MAX_BYTES) {
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
unreadableReceipt = true
}
if (!unreadableReceipt) {
try {
let parsed
try {
parsed = JSON.parse(encoded)
} catch (error) {
if (!lenient) throw error
// Common hand-written slips: smart quotes and trailing commas.
parsed = JSON.parse(encoded
.replace(/[\u201c\u201d]/g, '"')
.replace(/[\u2018\u2019]/g, "'")
.replace(/,\s*([}\]])/g, '$1'))
}
if (receipt != null && canonicalJson(receipt) !== canonicalJson(parsed)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_MISMATCH')
}
receipt = parsed
} catch (error) {
if (error?.message === 'LOCAL_RUN_COLLABORATION_RECEIPT_MISMATCH') throw error
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
unreadableReceipt = true
}
}
text = rawText.slice(0, markerStart) + (trailingVisibleText ? `\n\n${trailingVisibleText}` : '')
} else if (/\[\[MELDWORK_COLLABORATION/i.test(rawText)) {
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
unreadableReceipt = true
text = rawText.slice(0, rawText.search(/\[\[MELDWORK_COLLABORATION/i))
}
if (unreadableReceipt) receipt = null
if (lenient && receipt && typeof receipt === 'object' && !Array.isArray(receipt)) {
// Keep only discussion fields and fill a missing summary, so an extra key
// or a long summary does not discard the Agent's whole contribution.
const decisionReason = typeof receipt.taskDecision?.reason === 'string'
? receipt.taskDecision.reason
: ''
const summary = typeof receipt.summary === 'string' && receipt.summary.trim()
? receipt.summary.trim().slice(0, V4_RECEIPT_MAX_TEXT)
: (decisionReason.trim().slice(0, V4_RECEIPT_MAX_TEXT) || 'Discussion contribution')
receipt = {
version: 1,
phase: 'discussion',
summary,
...(receipt.taskDecision != null ? { taskDecision: receipt.taskDecision } : {}),
}
}
text = stripV4InternalProtocolTail(
text.replace(LEGACY_CONSENSUS_MARKER, '').replace(/\n{3,}/g, '\n\n'),
)
if (receipt == null && options.allowMissingV4Receipt === true && text) {
return { text, collaboration: null }
}
if (receipt == null && expectedPhase === 'proposal'
&& options.allowMissingProposalReceipt === true && text) {
receipt = {
version: 1,
phase: 'proposal',
summary: 'The Agent returned a visible proposal without a structured receipt.',
capabilities: ['Delivered a user-facing proposal'],
intendedWork: ['Addressed the current user task'],
deliverables: ['Visible Agent response'],
dependencies: [],
}
}
if (receipt == null) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_REQUIRED')
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
try {
if (Buffer.byteLength(canonicalJson(receipt)) > V4_RECEIPT_MAX_BYTES) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
} catch (error) {
if (error?.message === 'LOCAL_RUN_COLLABORATION_RECEIPT_INVALID') throw error
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const phase = String(expectedPhase || '')
const summary = boundedReceiptText(receipt.summary)
const allowed = new Set([
'version', 'phase', 'summary',
...(phase === 'discussion' ? ['taskDecision'] : []),
...(['challenge', 'verification'].includes(phase) ? ['verdict'] : []),
...(phase === 'proposal'
? ['capabilities', 'intendedWork', 'deliverables', 'dependencies'] : []),
...(phase === 'challenge'
? [
'proposedAssignments', 'finalizerKind', 'verifierKinds',
'supportedPlanHash', 'agreeToPlan',
] : []),
...(phase === 'work' ? ['workItemId', 'deliverables'] : []),
...(phase === 'synthesis' ? ['resolvedIssueIds'] : []),
])
if (!V4_AGENT_PHASES.has(phase) || !summary
|| Object.keys(receipt).some(key => !allowed.has(key))
|| (receipt.version != null && receipt.version !== 1)
|| (receipt.phase != null && receipt.phase !== phase)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
if (['challenge', 'verification'].includes(phase)
&& !['support', 'contradict'].includes(receipt.verdict)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const resolvedIssueIds = phase === 'synthesis'
? boundedReceiptList(receipt.resolvedIssueIds)
: null
if (phase === 'synthesis' && !Array.isArray(receipt.resolvedIssueIds)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
if (phase === 'challenge' && receipt.supportedPlanHash != null
&& !SHA256.test(String(receipt.supportedPlanHash))) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const proposalList = (field, requireItem = false) => {
if (!Array.isArray(receipt[field])) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const list = boundedReceiptList(receipt[field])
if (requireItem && list.length === 0) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
return list
}
const boundedList = value => {
if (value == null) return []
if (!Array.isArray(value) || value.length > V4_RECEIPT_MAX_ITEMS) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const result = value.map(item => boundedReceiptText(item))
if (result.some(item => !item)) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
return result
}
const normalized = {
version: 1,
phase,
summary,
...(phase === 'discussion' && receipt.taskDecision != null
? leniencyTaskDecision(receipt.taskDecision, summary) : {}),
...(['challenge', 'verification'].includes(phase) ? { verdict: receipt.verdict } : {}),
...(phase === 'proposal' ? {
capabilities: proposalList('capabilities', true),
intendedWork: proposalList('intendedWork', true),
deliverables: proposalList('deliverables', true),
dependencies: proposalList('dependencies'),
} : {}),
...(phase === 'challenge' ? {
...(Array.isArray(receipt.proposedAssignments)
? {
proposedAssignments: receipt.proposedAssignments.map(assignment => (
assignment && typeof assignment === 'object' && !Array.isArray(assignment)
&& assignment.role === 'finalizer'
? { ...assignment, role: 'integrator' }
: assignment
)),
}
: {}),
...(receipt.finalizerKind ? { finalizerKind: boundedReceiptText(receipt.finalizerKind, 120) } : {}),
...(Array.isArray(receipt.verifierKinds) ? { verifierKinds: boundedList(receipt.verifierKinds) } : {}),
...(receipt.supportedPlanHash ? { supportedPlanHash: receipt.supportedPlanHash } : {}),
...(typeof receipt.agreeToPlan === 'boolean' ? { agreeToPlan: receipt.agreeToPlan } : {}),
} : {}),
...(phase === 'work' ? {
...(receipt.workItemId ? { workItemId: boundedReceiptText(receipt.workItemId, 120) } : {}),
deliverables: boundedList(receipt.deliverables),
} : {}),
...(phase === 'synthesis' ? { resolvedIssueIds } : {}),
}
return { text, collaboration: Object.freeze(JSON.parse(canonicalJson(normalized))) }
}
function parseV4CollaborationReceipt(result, required = false, expectedPhase = '', options = {}) {
try {
return parseV4CollaborationReceiptStrict(result, required, expectedPhase, options)
} catch (error) {
const code = String(error?.message || '')
if (expectedPhase !== 'proposal'
|| options.allowMissingProposalReceipt !== true
|| !['LOCAL_RUN_COLLABORATION_RECEIPT_INVALID', 'LOCAL_RUN_COLLABORATION_RECEIPT_REQUIRED']
.includes(code)) {
throw error
}
const rawText = typeof result?.text === 'string' ? result.text : ''
const markerIndex = rawText.search(/\[\[MELDWORK_COLLABORATION/i)
const visibleText = (markerIndex >= 0 ? rawText.slice(0, markerIndex) : rawText)
.replace(LEGACY_CONSENSUS_MARKER, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
if (!visibleText) throw error
return {
text: visibleText,
collaboration: Object.freeze({
version: 1,
phase: 'proposal',
summary: 'The Agent returned a visible proposal without a valid structured receipt.',
capabilities: ['Delivered a user-facing proposal'],
intendedWork: ['Addressed the current user task'],
deliverables: ['Visible Agent response'],
dependencies: [],
}),
}
}
}
function cleanReceiptText(value, limit) {
return String(value || '').trim().slice(0, limit)
}
module.exports = {
V4_AGENT_PHASES,
V4_RECEIPT_MARKER_PREFIX,
parseV4CollaborationReceipt,
}
@@ -533,6 +533,37 @@ function publicCollaborationText(value, limit = MAX_TEXT_CHARS) {
.slice(0, limit)
}
const PRIVATE_FILESYSTEM_ROOT = /^\/(?:Users|home|private|var|tmp|etc|opt|Volumes|root|usr|System|Library|Applications|mnt|srv)(?:\/|$)/
function peerPathText(candidate, workspaceRoot) {
const trailing = candidate.match(/[.,;:!?]+$/)?.[0] || ''
const clean = trailing ? candidate.slice(0, -trailing.length) : candidate
if (workspaceRoot && (clean === workspaceRoot || clean.startsWith(`${workspaceRoot}/`))) {
return `${clean === workspaceRoot ? '.' : clean.slice(workspaceRoot.length + 1)}${trailing}`
}
if (PRIVATE_FILESYSTEM_ROOT.test(clean)) return `[path]${trailing}`
// Routes and commands such as /api/users or /login are not local files.
return candidate
}
// Peer-visible text for natural discussion transcripts. Unlike
// publicCollaborationText, files inside the shared workspace stay readable as
// workspace-relative paths and non-filesystem tokens (API routes, slash
// commands) are kept; other absolute local paths and secrets stay redacted.
function peerCollaborationText(value, options = {}) {
const workspaceRoot = typeof options.workspaceRoot === 'string'
? options.workspaceRoot.replace(/\/+$/, '')
: ''
return redactSecrets(String(value || ''))
.replace(/\bfile:\/\/(\/[^\s"'`<>]+)/giu, (_match, filePath) => peerPathText(filePath, workspaceRoot))
.replace(/(^|[\s("'`])(\/(?!\/)[^\s"'`<>)]*)/gmu, (_match, lead, candidate) => (
`${lead}${peerPathText(candidate, workspaceRoot)}`
))
.replace(/\b[A-Za-z]:\\(?:[^\s"'`<>]+\\)*[^\s"'`<>]*/gu, '[path]')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.trim()
}
function collaborationPackageText(handoff, entries) {
const lines = [
'MELDWORK_COLLABORATION_PACKAGE_V1',
@@ -557,6 +588,7 @@ function collaborationPackageText(handoff, entries) {
}
module.exports = {
peerCollaborationText,
BLACKBOARD_ENTRY_VERSION,
COLLABORATION_STATE_VERSION,
HANDOFF_VERSION,
@@ -256,10 +256,14 @@ const deliveryContentFields = {
wirePayloadRef: blobRefSchema,
wirePayloadHash: hashSchema,
wirePayloadBytes: z.number().int().nonnegative().max(MAX_CONTENT_BLOB_BYTES),
inputTokenCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
outputTokenCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
serialization: z.enum([
'cli-argv-stdin-v1',
'acp-session-prompt-v1',
'custom-cli-argv-stdin-v1',
'http-json-body-v1',
'ssh-stdin-v1',
]),
runtimeAdditions: z.array(runtimeAdditionSchema).max(MAX_RUNTIME_ADDITIONS),
sessionProvenance: sessionProvenanceSchema,
@@ -269,14 +273,20 @@ function validateDeliveryRelations(delivery, context) {
if (delivery.payloadHash !== delivery.payloadRef.hash) {
context.addIssue({ code: 'custom', path: ['payloadHash'], message: 'payload hash mismatch' })
}
if (delivery.wirePayloadHash !== delivery.wirePayloadRef.hash
|| delivery.wirePayloadBytes !== delivery.wirePayloadRef.size) {
if (delivery.wirePayloadHash !== delivery.wirePayloadRef.hash) {
context.addIssue({
code: 'custom',
path: ['wirePayloadHash'],
message: 'wire payload reference mismatch',
})
}
if (delivery.wirePayloadBytes !== delivery.wirePayloadRef.size) {
context.addIssue({
code: 'custom',
path: ['wirePayloadBytes'],
message: 'wire payload byte count mismatch',
})
}
const additionIds = delivery.runtimeAdditions.map(addition => addition.additionId)
if (new Set(additionIds).size !== additionIds.length) {
context.addIssue({
@@ -297,23 +307,18 @@ const deliveryRecordSchema = z.strictObject({
}).superRefine(validateDeliveryRelations)
function forbiddenField(key) {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '')
return normalized.includes('credential')
|| normalized.includes('password')
|| normalized.includes('apikey')
|| normalized.includes('accesskey')
|| normalized.includes('token')
|| normalized.includes('secret')
|| normalized.includes('authorization')
|| normalized.includes('privatekey')
|| normalized.includes('executable')
|| normalized.includes('command')
|| normalized.includes('reasoning')
|| normalized.includes('chainofthought')
|| normalized === 'cot'
|| normalized.includes('sessionref')
|| normalized === 'thought'
|| normalized === 'thoughts'
const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
const joined = words.join('')
if (['inputtokencount', 'outputtokencount', 'commandhistorycount'].includes(joined)) return false
return words.some(word => [
'credential', 'credentials', 'password', 'passwords', 'apikey', 'accesskey',
'token', 'tokens', 'secret', 'secrets', 'authorization', 'privatekey',
'executable', 'command', 'commands', 'reasoning', 'chainofthought',
'cot', 'sessionref', 'thought', 'thoughts',
].includes(word)) || /\b(?:api key|access key|private key|chain of thought|session ref)\b/
.test(words.join(' '))
}
function assertNoForbiddenContent(value, prefix, seen = new Set()) {
@@ -336,6 +341,7 @@ function assertNoForbiddenContent(value, prefix, seen = new Set()) {
function parseInput(value, prefix, maxBytes) {
if (Buffer.isBuffer(value) || value instanceof Uint8Array || typeof value === 'string') {
if (typeof value === 'string' && value.length > maxBytes) fail(`${prefix}_JSON_INVALID`)
const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : Buffer.from(value)
if (!bytes.length || bytes.length > maxBytes) fail(`${prefix}_JSON_INVALID`)
let parsed
@@ -347,7 +353,14 @@ function parseInput(value, prefix, maxBytes) {
function validated(schema, value, prefix) {
const result = schema.safeParse(value)
if (!result.success) fail(`${prefix}_SCHEMA_INVALID`)
if (!result.success) {
const error = contextRecordError(`${prefix}_SCHEMA_INVALID`)
const field = result.error.issues.find(issue => (
issue.code === 'custom' && ['wirePayloadHash', 'wirePayloadBytes'].includes(issue.path[0])
))?.path[0]
if (field) error.field = field
throw error
}
return result.data
}
@@ -375,14 +388,14 @@ function createContextPackRecord(input) {
...body,
}
if (record.parentPackId === record.contextPackId) fail('CONTEXT_PACK_SCHEMA_INVALID')
enforceCanonicalSize(record, 'CONTEXT_PACK', MAX_CONTEXT_PACK_RECORD_BYTES)
return JSON.parse(canonicalJson(record))
return JSON.parse(enforceCanonicalSize(record, 'CONTEXT_PACK', MAX_CONTEXT_PACK_RECORD_BYTES))
}
function parseContextPackRecord(input) {
const { parsed, serialized } = parseInput(
input, 'CONTEXT_PACK', MAX_CONTEXT_PACK_RECORD_BYTES,
)
if (!isPlainObject(parsed)) fail('CONTEXT_PACK_SCHEMA_INVALID')
assertNoForbiddenContent(parsed, 'CONTEXT_PACK')
const record = validated(contextPackRecordSchema, parsed, 'CONTEXT_PACK')
const { contextPackId, ...body } = record
@@ -405,14 +418,14 @@ function createDeliveryRecord(input) {
deliveryRecordId: deriveId('delivery-record', body),
...body,
}
enforceCanonicalSize(record, 'DELIVERY_RECORD', MAX_DELIVERY_RECORD_BYTES)
return JSON.parse(canonicalJson(record))
return JSON.parse(enforceCanonicalSize(record, 'DELIVERY_RECORD', MAX_DELIVERY_RECORD_BYTES))
}
function parseDeliveryRecord(input) {
const { parsed, serialized } = parseInput(
input, 'DELIVERY_RECORD', MAX_DELIVERY_RECORD_BYTES,
)
if (!isPlainObject(parsed)) fail('DELIVERY_RECORD_SCHEMA_INVALID')
assertNoForbiddenContent(parsed, 'DELIVERY_RECORD')
const record = validated(deliveryRecordSchema, parsed, 'DELIVERY_RECORD')
const { deliveryRecordId, ...body } = record
@@ -1,4 +1,5 @@
const crypto = require('node:crypto')
const { parseTaskDecision } = require('./task-decision.cjs')
const { normalizeContentBlobRef } = require('../attachments/content-blob-store.cjs')
const { canonicalJson } = require('./context-pack-records.cjs')
@@ -9,7 +10,7 @@ const V4_TEMPLATES = new Set(['concurrent-batch', 'discussion'])
const V4_WORKFLOWS = new Set(['manual', 'auto', 'concurrent-batch', 'discussion'])
const V4_PHASES = new Set([
'prepare', 'dispatch', 'running', 'reconcile',
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'commit', 'committed', 'completed', 'failed', 'stopped', 'human-gate',
])
const V4_SLOT_STATUSES = new Set([
@@ -60,7 +61,7 @@ const MESSAGE_ID = /^message-[a-f0-9]{64}$/u
const EVIDENCE_ID = /^evidence-[a-f0-9]{64}$/u
const ORCHESTRATION_FIELDS = new Set([
'version', 'workflow', 'template', 'phase', 'batchId', 'round',
'version', 'workflow', 'template', 'phase', 'discussionStyle', 'discussionWriterKind', 'batchId', 'round',
'currentKind', 'currentKinds', 'pendingKinds', 'activeKinds',
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'taskGraph',
@@ -278,10 +279,24 @@ function normalizeAssignment(input, targetKinds, index) {
}
}
function normalizeWorkAcceptance(input, errorCode) {
if (!isRecord(input) || !exactFields(input, ['minArtifactRefs', 'minEvidenceRefs', 'requiredEvidenceKinds'])
|| !Number.isInteger(input.minArtifactRefs) || input.minArtifactRefs < 1 || input.minArtifactRefs > MAX_V4_REFS
|| !Number.isInteger(input.minEvidenceRefs) || input.minEvidenceRefs < 1 || input.minEvidenceRefs > MAX_V4_REFS) {
fail(errorCode)
}
const allowedKinds = ['declaration', 'source-snapshot', 'observation', 'test-result', 'file-hash', 'review', 'other']
if (hasOwn(input, 'requiredEvidenceKinds') && (!Array.isArray(input.requiredEvidenceKinds)
|| input.requiredEvidenceKinds.some(kind => !allowedKinds.includes(kind))
|| new Set(input.requiredEvidenceKinds).size !== input.requiredEvidenceKinds.length)) fail(errorCode)
return { minArtifactRefs: input.minArtifactRefs, minEvidenceRefs: input.minEvidenceRefs,
...(hasOwn(input, 'requiredEvidenceKinds') ? { requiredEvidenceKinds: [...input.requiredEvidenceKinds].sort() } : {}) }
}
function normalizeCoordinationAssignment(input, targetKinds, index) {
if (!isRecord(input) || !exactFields(input, [
'taskId', 'ownerKind', 'role', 'objective', 'expectedOutput',
'inputRefs', 'artifactIds', 'dependsOn',
'inputRefs', 'artifactIds', 'dependsOn', 'acceptance',
])) fail('ORCHESTRATION_V4_COORDINATION_PLAN_INVALID')
const taskId = cleanId(input.taskId)
const ownerKind = cleanId(input.ownerKind)
@@ -299,6 +314,7 @@ function normalizeCoordinationAssignment(input, targetKinds, index) {
inputRefs: uniqueIds(input.inputRefs || [], MAX_V4_REFS).sort(),
artifactIds: uniqueIds(input.artifactIds || [], MAX_V4_REFS).sort(),
dependsOn: uniqueIds(input.dependsOn || [], MAX_V4_ASSIGNMENTS).sort(),
...(hasOwn(input, 'acceptance') ? { acceptance: normalizeWorkAcceptance(input.acceptance, 'ORCHESTRATION_V4_COORDINATION_PLAN_INVALID') } : {}),
index,
}
}
@@ -516,6 +532,16 @@ function createWorkReceipt(input = {}) {
!== canonicalJson(collaborationReceipt.artifactIds)) {
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
}
let dependencyWorkReceiptIds
if (Object.hasOwn(input, 'dependencyWorkReceiptIds')) {
if (!Array.isArray(input.dependencyWorkReceiptIds)
|| input.dependencyWorkReceiptIds.length > MAX_V4_ASSIGNMENTS
|| input.dependencyWorkReceiptIds.some(id => typeof id !== 'string' || !WORK_RECEIPT_ID.test(id))
|| new Set(input.dependencyWorkReceiptIds).size !== input.dependencyWorkReceiptIds.length) {
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
}
dependencyWorkReceiptIds = [...input.dependencyWorkReceiptIds].sort()
}
const resultHash = hashValue({ collaborationReceipt, artifacts })
const body = {
version: 1,
@@ -530,6 +556,7 @@ function createWorkReceipt(input = {}) {
collaborationReceipt,
resultHash,
artifacts,
...(dependencyWorkReceiptIds ? { dependencyWorkReceiptIds } : {}),
}
return {
...body,
@@ -541,7 +568,7 @@ function normalizeWorkReceipt(input) {
if (!isRecord(input) || !exactFields(input, [
'version', 'workReceiptId', 'snapshotHash', 'snapshotBodyHash',
'snapshotContentRef', 'planHash', 'taskId', 'ownerKind', 'slotId',
'operationId', 'collaborationReceipt', 'resultHash', 'artifacts',
'operationId', 'collaborationReceipt', 'resultHash', 'artifacts', 'dependencyWorkReceiptIds',
]) || input.version !== 1 || !WORK_RECEIPT_ID.test(String(input.workReceiptId || ''))) {
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
}
@@ -705,7 +732,7 @@ function normalizeSlot(input, targetKinds, topPhase, index, template) {
'slotId', 'agentKind', 'phase', 'status', 'operationId', 'agentRunId', 'queuePosition',
'snapshotHash', 'deliveryWatermark', 'receiptId', 'resultHash',
'assignedAt', 'startedAt', 'finishedAt', 'commitStatus', 'attempt', 'permission',
'resultRefs', 'resultBodyArtifactId', 'commitId', 'messageId', 'blackboardEntryId',
'resultRefs', 'resultBodyArtifactId', 'commitId', 'messageId', 'blackboardEntryId', 'workDependencyBinding',
])) fail('ORCHESTRATION_V4_SLOT_INVALID')
const slotId = cleanId(input.slotId)
const agentKind = cleanId(input.agentKind)
@@ -742,6 +769,19 @@ function normalizeSlot(input, targetKinds, topPhase, index, template) {
const messageId = input.messageId == null ? '' : cleanId(input.messageId)
const blackboardEntryId = input.blackboardEntryId == null
? '' : String(input.blackboardEntryId)
let workDependencyBinding
if (hasOwn(input, 'workDependencyBinding')) {
const binding = input.workDependencyBinding
if (phase !== 'work' || !isRecord(binding)
|| !exactFields(binding, ['taskId', 'dependencyWorkReceiptIds'])
|| !cleanId(binding.taskId) || !Array.isArray(binding.dependencyWorkReceiptIds)
|| binding.dependencyWorkReceiptIds.length > MAX_V4_ASSIGNMENTS
|| binding.dependencyWorkReceiptIds.some(id => typeof id !== 'string' || !WORK_RECEIPT_ID.test(id))
|| new Set(binding.dependencyWorkReceiptIds).size !== binding.dependencyWorkReceiptIds.length) {
fail('ORCHESTRATION_V4_SLOT_INVALID')
}
workDependencyBinding = { taskId: binding.taskId, dependencyWorkReceiptIds: [...binding.dependencyWorkReceiptIds].sort() }
}
if ((resultBodyArtifactId && !ARTIFACT_ID.test(resultBodyArtifactId))
|| (input.commitId != null && !commitId)
|| (input.messageId != null && !messageId)
@@ -791,6 +831,7 @@ function normalizeSlot(input, targetKinds, topPhase, index, template) {
deliveryWatermark, receiptId, resultHash, assignedAt, startedAt, finishedAt, commitStatus,
attempt,
...(permission ? { permission } : {}),
...(workDependencyBinding ? { workDependencyBinding } : {}),
...(resultRefs ? { resultRefs } : {}),
...(resultBodyArtifactId ? { resultBodyArtifactId } : {}),
...(commitId ? { commitId } : {}),
@@ -829,7 +870,7 @@ function normalizeDeliveryState(input, targetKinds, index) {
if (!isRecord(input) || !exactFields(input, [
'recipientKind', 'sessionRefHash', 'sessionProvenanceHash',
'sourceAgentKind', 'sourcePhase', 'watermark', 'snapshotHash', 'operationId',
'packageHash', 'deliveryId', 'status', 'updatedAt',
'packageHash', 'deliveryId', 'status', 'updatedAt', 'sourceMessages',
])) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
const recipientKind = cleanId(input.recipientKind)
const sourceAgentKind = cleanId(input.sourceAgentKind)
@@ -841,6 +882,20 @@ function normalizeDeliveryState(input, targetKinds, index) {
const packageHash = String(input.packageHash || '')
const deliveryId = cleanId(input.deliveryId)
const status = String(input.status || '')
let sourceMessages
if (hasOwn(input, 'sourceMessages')) {
if (!Array.isArray(input.sourceMessages) || !input.sourceMessages.length
|| input.sourceMessages.length > 100) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
sourceMessages = input.sourceMessages.map(message => {
if (!isRecord(message) || !exactFields(message, ['id', 'hash'])
|| !cleanId(message.id) || cleanId(message.id) !== message.id
|| typeof message.hash !== 'string' || !SHA256.test(message.hash)) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
return { id: message.id, hash: message.hash }
})
if (new Set(sourceMessages.map(message => message.id)).size !== sourceMessages.length) {
fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
}
}
if (!recipientKind || !sourceAgentKind || !targetKinds.includes(recipientKind)
|| !targetKinds.includes(sourceAgentKind) || !V4_PHASES.has(sourcePhase)
|| !Number.isSafeInteger(input.watermark) || input.watermark < 0
@@ -861,6 +916,7 @@ function normalizeDeliveryState(input, targetKinds, index) {
packageHash,
deliveryId,
status,
...(sourceMessages ? { sourceMessages } : {}),
updatedAt: safeTimestamp(input.updatedAt, index),
}
}
@@ -1545,6 +1601,8 @@ function validateV4Relations(orchestration, targetKinds) {
&& watermark.watermark >= workReceipt.collaborationReceipt.deliveryWatermark
))
if (!assignment || assignment.ownerKind !== workReceipt.ownerKind
|| (assignment.acceptance && (new Set(workReceipt.collaborationReceipt.artifactIds).size < assignment.acceptance.minArtifactRefs
|| new Set(workReceipt.collaborationReceipt.evidenceIds).size < assignment.acceptance.minEvidenceRefs))
|| !slot || slot.agentKind !== workReceipt.ownerKind
|| !operationBound
|| workReceipt.snapshotHash !== orchestration.snapshotHash
@@ -1564,6 +1622,32 @@ function validateV4Relations(orchestration, targetKinds) {
taskIds.add(workReceipt.taskId)
operationIds.add(workReceipt.operationId)
}
if (orchestration.coordinationPlan.assignments.some(assignment => (
taskIds.has(assignment.taskId) && assignment.dependsOn.some(id => !taskIds.has(id))
))) fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
for (const receipt of orchestration.workReceipts) {
if (!Object.hasOwn(receipt, 'dependencyWorkReceiptIds')) continue
const assignment = orchestration.coordinationPlan.assignments.find(item => item.taskId === receipt.taskId)
const dependencies = assignment.dependsOn.map(id => (
orchestration.workReceipts.find(item => item.taskId === id)?.workReceiptId
)).sort()
if (canonicalJson(receipt.dependencyWorkReceiptIds) !== canonicalJson(dependencies)) {
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
}
}
}
for (const slot of orchestration.slots) {
const binding = slot.workDependencyBinding
if (!binding) continue
const assignment = orchestration.coordinationPlan?.assignments.find(item => item.taskId === binding.taskId)
if (!assignment || assignment.ownerKind !== slot.agentKind) fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
const dependencies = assignment.dependsOn.map(id => (
orchestration.workReceipts?.find(item => item.taskId === id)?.workReceiptId
)).sort()
if (dependencies.some(id => !id)
|| canonicalJson(binding.dependencyWorkReceiptIds) !== canonicalJson(dependencies)) {
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
}
}
const assignmentsBySlot = new Map(orchestration.plan.assignments.map(item => [item.slotId, item]))
for (const slot of orchestration.slots) {
@@ -1585,11 +1669,12 @@ function validateV4Relations(orchestration, targetKinds) {
assignment.readOnly === false
))
const validWriterBinding = writerKind
? writableSlots.length === 1
&& writableAssignments.length === 1
&& writableSlots[0].agentKind === writerKind
&& writableAssignments[0].agentKind === writerKind
&& writableAssignments[0].slotId === writableSlots[0].slotId
? writableSlots.some(slot => slot.agentKind === writerKind)
&& writableSlots.length === writableAssignments.length
&& writableSlots.every(slot => writableAssignments.some(assignment => (
assignment.slotId === slot.slotId && assignment.agentKind === slot.agentKind
&& (writableSlots.length === 1 || assignment.role === 'writer')
)))
: writableSlots.length === 0 && writableAssignments.length === 0
if (!validWriterBinding) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
}
@@ -1601,10 +1686,21 @@ function validateV4Relations(orchestration, targetKinds) {
|| orchestration.synthesisBinding?.writerKind
|| orchestration.coordinationPlan?.finalizerKind
|| orchestration.commitState.writerKind
if (writableSlots.length > 1 || writableSlots.some(slot => (
|| orchestration.discussionWriterKind
const nativeWrites = Boolean(orchestration.discussionWriterKind
&& !orchestration.coordinationPlan && !orchestration.synthesisBinding
&& ['proposal', 'discussion'].includes(orchestration.phase))
if (!nativeWrites && (writableSlots.length > 1 || writableSlots.some(slot => (
slot.agentKind !== effectiveWriterKind
|| !['synthesis', 'human-gate'].includes(orchestration.phase)
))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|| !['discussion', 'synthesis', 'human-gate'].includes(orchestration.phase)
|| (orchestration.phase === 'discussion' && !orchestration.discussionWriterKind)
)))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
for (const assignment of ['proposal', 'discussion'].includes(orchestration.phase) ? orchestration.plan.assignments : []) {
const slot = orchestration.slots.find(item => item.slotId === assignment.slotId)
if (slot && assignment.readOnly !== (slot.permission !== 'workspace-write')) {
fail('ORCHESTRATION_V4_PERMISSION_INVALID')
}
}
}
const watermarkKeys = orchestration.deliveryWatermarks.map(item => `${item.agentKind}\u0000${item.phase}`)
if (new Set(watermarkKeys).size !== watermarkKeys.length) {
@@ -1784,6 +1880,14 @@ function parseOrchestrationV4(input, options = {}) {
}
const { workflow, template } = normalizeWorkflow(input.workflow, input.template)
const phase = phaseForTemplate(template, String(input.phase || ''))
const discussionStyle = hasOwn(input, 'discussionStyle')
? String(input.discussionStyle || '')
: ''
if (discussionStyle && (
workflow !== 'auto'
|| template !== 'discussion'
|| !['sequential', 'agent-led'].includes(discussionStyle)
)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
const batchId = cleanId(input.batchId)
const snapshotHash = String(input.snapshotHash || '')
if (!batchId || !SHA256.test(snapshotHash)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
@@ -1891,6 +1995,15 @@ function parseOrchestrationV4(input, options = {}) {
if (coordinationPlan && (workflow !== 'auto' || template !== 'discussion')) {
fail('ORCHESTRATION_V4_COORDINATION_PLAN_INVALID')
}
const discussionWriterKind = hasOwn(input, 'discussionWriterKind') ? cleanId(input.discussionWriterKind) : ''
if (hasOwn(input, 'discussionWriterKind') && (!discussionWriterKind
|| input.discussionWriterKind !== discussionWriterKind
|| !effectiveKinds.includes(discussionWriterKind)
|| workflow !== 'auto' || template !== 'discussion'
|| !['proposal', 'discussion', 'stopped', 'failed'].includes(phase)
|| synthesisBinding || coordinationPlan || commitState.writerKind)) {
fail('ORCHESTRATION_V4_PERMISSION_INVALID')
}
if (coordinationPlan) {
validateCoordinationAgreement(
coordinationPlan, slots, challengeBindings, participantKinds, snapshotHash,
@@ -2006,8 +2119,23 @@ function parseOrchestrationV4(input, options = {}) {
}
if (hasOwn(input, 'taskGraph')) {
try {
const { parseTaskGraphCursor } = require('./task-graph-records.cjs')
parseTaskGraphCursor(input.taskGraph, effectiveKinds)
const { parseTaskGraphCursor, assertTaskGraphDependencies, terminalTaskGraphState } = require('./task-graph-records.cjs')
const cursor = assertTaskGraphDependencies(parseTaskGraphCursor(input.taskGraph, effectiveKinds))
if (cursor.terminalState !== terminalTaskGraphState(cursor)) throw new Error('TASK_GRAPH_CURSOR_INVALID')
const nodes = new Map(cursor.graph.nodes.map(node => [node.nodeId, node]))
for (const state of cursor.nodeStates) {
const node = nodes.get(state.nodeId)
if (node.role === 'human' && ['accepted', 'rejected'].includes(state.status)) {
const decision = node.decisionOptions.find(option => option.optionId === state.decisionOptionId)
if (!decision || decision.kind !== (state.status === 'accepted' ? 'allow_once' : 'reject_once')
|| !state.entryIds.length) throw new Error('TASK_GRAPH_CURSOR_INVALID')
} else if (node.role !== 'human' && state.status === 'accepted'
&& ((node.acceptance.requireConclusion && !state.conclusionHash)
|| state.artifactIds.length < node.acceptance.minArtifactRefs
|| state.evidenceIds.length < node.acceptance.minEvidenceRefs)) {
throw new Error('TASK_GRAPH_CURSOR_INVALID')
}
}
} catch {
fail('ORCHESTRATION_V4_TASK_GRAPH_INVALID')
}
@@ -2017,6 +2145,8 @@ function parseOrchestrationV4(input, options = {}) {
workflow,
template,
phase,
...(discussionStyle ? { discussionStyle } : {}),
...(discussionWriterKind ? { discussionWriterKind } : {}),
batchId,
currentKinds,
activeKinds,
@@ -2161,7 +2291,7 @@ function parseCollaborationControlBlock(input, options = {}) {
'claims', 'findings', 'refs', 'writer', 'writerKind', 'batchId', 'entryIds',
'capabilities', 'intendedWork', 'deliverables', 'dependencies',
'proposedAssignments', 'finalizerKind', 'verifierKinds', 'supportedPlanHash',
'agreeToPlan', 'workItemId',
'agreeToPlan', 'workItemId', 'taskDecision',
]) || input.version !== 1) {
fail('COLLABORATION_CONTROL_BLOCK_INVALID')
}
@@ -2171,6 +2301,8 @@ function parseCollaborationControlBlock(input, options = {}) {
fail('COLLABORATION_CONTROL_BLOCK_INVALID')
}
const summary = safeText(input.summary, MAX_V4_SUMMARY_CHARS, true)
const taskDecision = input.taskDecision == null ? null : parseTaskDecision(input.taskDecision)
if (taskDecision && phase !== 'discussion') fail('COLLABORATION_CONTROL_BLOCK_INVALID')
const boundedList = (value, limit = MAX_V4_REFS, textLimit = MAX_V4_TEXT_CHARS) => {
if (value == null) return []
if (!Array.isArray(value) || value.length > limit) fail('COLLABORATION_CONTROL_BLOCK_INVALID')
@@ -2195,7 +2327,7 @@ function parseCollaborationControlBlock(input, options = {}) {
const proposedAssignments = (input.proposedAssignments || []).map(item => {
if (!isRecord(item) || !exactFields(item, [
'taskId', 'ownerKind', 'role', 'objective', 'expectedOutput',
'inputRefs', 'artifactIds', 'dependsOn',
'inputRefs', 'artifactIds', 'dependsOn', 'acceptance',
])) fail('COLLABORATION_CONTROL_BLOCK_INVALID')
const taskId = safeText(item.taskId, 120, true)
const ownerKind = safeText(item.ownerKind, 120, true)
@@ -2212,6 +2344,7 @@ function parseCollaborationControlBlock(input, options = {}) {
inputRefs: boundedList(item.inputRefs || [], MAX_V4_REFS, 120),
artifactIds: boundedList(item.artifactIds || [], MAX_V4_REFS, 120),
dependsOn: boundedList(item.dependsOn || [], MAX_V4_ASSIGNMENTS, 120),
...(hasOwn(item, 'acceptance') ? { acceptance: normalizeWorkAcceptance(item.acceptance, 'COLLABORATION_CONTROL_BLOCK_INVALID') } : {}),
}
})
const finalizerKind = input.finalizerKind == null ? '' : safeText(input.finalizerKind, 120, false)
@@ -2301,6 +2434,7 @@ function parseCollaborationControlBlock(input, options = {}) {
status,
summary,
conclusion,
...(taskDecision ? { taskDecision } : {}),
artifactIds,
evidenceIds,
findingIds,
@@ -2339,6 +2473,7 @@ function collaborationChars(block) {
...(Array.isArray(block.intendedWork) ? block.intendedWork : []),
...(Array.isArray(block.deliverables) ? block.deliverables : []),
...(Array.isArray(block.dependencies) ? block.dependencies : []),
...(block.taskDecision ? [block.taskDecision.reason, ...block.taskDecision.deliverables] : []),
...(Array.isArray(block.proposedAssignments)
? block.proposedAssignments.flatMap(item => [item.objective, item.expectedOutput])
: []),
@@ -2547,6 +2682,7 @@ function createCollaborationReceipt(input, options = {}) {
...(input.supportedPlanHash ? { supportedPlanHash: input.supportedPlanHash } : {}),
...(typeof input.agreeToPlan === 'boolean' ? { agreeToPlan: input.agreeToPlan } : {}),
...(input.workItemId ? { workItemId: input.workItemId } : {}),
...(input.taskDecision ? { taskDecision: input.taskDecision } : {}),
deliveryWatermark: input.deliveryWatermark || 0,
snapshotHash: input.snapshotHash,
...(typeof input.writer === 'boolean' ? { writer: input.writer } : {}),
+25 -8
View File
@@ -3,15 +3,17 @@ const crypto = require('node:crypto')
const wireBytesByPayload = new WeakMap()
function canonicalJson(value) {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(',')}]`
const normalized = JSON.parse(JSON.stringify(value))
function serialize(item) {
if (Array.isArray(item)) return `[${item.map(serialize).join(',')}]`
if (item && typeof item === 'object') {
return `{${Object.keys(item).sort().map(key => (
`${JSON.stringify(key)}:${serialize(item[key])}`
)).join(',')}}`
}
return JSON.stringify(item)
}
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map(key => (
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
)).join(',')}}`
}
return JSON.stringify(value)
return serialize(normalized)
}
function payloadWithBytes(fields, value) {
@@ -68,6 +70,19 @@ function createCustomOutboundPayload({ prompt, command, args, cwd, stdin, prompt
}, cliWirePayload(command, args, cwd, stdin))
}
function createHttpOutboundPayload({ prompt, destination, wireBytes, tunneled = false }) {
return payloadWithBytes({
prompt, destination, transport: 'http',
...(tunneled ? { tunneled: true } : {}),
serialization: 'http-json-body-v1', promptMode: 'json',
}, wireBytes)
}
function createSshOutboundPayload({ prompt }) {
return payloadWithBytes({ prompt, transport: 'ssh',
serialization: 'ssh-stdin-v1', promptMode: 'stdin' }, Buffer.from(prompt, 'utf8'))
}
function outboundWirePayloadBytes(payload) {
const bytes = payload && typeof payload === 'object' ? wireBytesByPayload.get(payload) : null
return bytes ? Buffer.from(bytes) : null
@@ -76,6 +91,8 @@ function outboundWirePayloadBytes(payload) {
module.exports = {
createAcpOutboundPayload,
createCustomOutboundPayload,
createHttpOutboundPayload,
createSshOutboundPayload,
createLegacyOutboundPayload,
outboundWirePayloadBytes,
}
+100
View File
@@ -0,0 +1,100 @@
const { redactSecrets } = require('../security/secret-redaction.cjs')
function parseTaskDecision(input) {
const invalid = () => { throw new Error('LOCAL_RUN_TASK_DECISION_INVALID') }
if (!input || typeof input !== 'object' || Array.isArray(input)
|| Object.keys(input).some(key => !['status', 'reason', 'deliverables', 'handoffTo', 'nextKinds', 'reviewedVersion'].includes(key))
|| !['completed', 'continue', 'blocked', 'needs-human'].includes(input.status)) invalid()
const text = (value, limit) => {
if (typeof value !== 'string' || !value.trim() || value.length > limit
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) invalid()
return redactSecrets(value.trim())
}
const reason = text(input.reason, 1600)
if (!Array.isArray(input.deliverables) || input.deliverables.length > 16) invalid()
const deliverables = input.deliverables.map(value => text(value, 800))
if (input.status === 'completed' && !deliverables.length) invalid()
if (input.handoffTo != null && (input.status !== 'continue'
|| typeof input.handoffTo !== 'string'
|| !/^[a-z0-9][a-z0-9-]{0,79}$/.test(input.handoffTo))) invalid()
if (input.nextKinds != null && (!Array.isArray(input.nextKinds)
|| input.nextKinds.length > 32
|| input.nextKinds.some(kind => typeof kind !== 'string'
|| !/^[a-z0-9][a-z0-9-]{0,79}$/i.test(kind))
|| (input.nextKinds.length > 0 && input.status !== 'continue'))) invalid()
const nextKinds = input.nextKinds?.map(kind => kind.toLowerCase())
if (nextKinds && new Set(nextKinds).size !== nextKinds.length) invalid()
if (input.reviewedVersion != null && (typeof input.reviewedVersion !== 'string'
|| !/^[a-f0-9]{64}$/.test(input.reviewedVersion))) invalid()
return {
status: input.status, reason, deliverables,
...(input.handoffTo != null ? { handoffTo: input.handoffTo } : {}),
...(nextKinds ? { nextKinds } : {}),
...(input.reviewedVersion != null ? { reviewedVersion: input.reviewedVersion } : {}),
}
}
const STATUS_SYNONYMS = new Map([
['completed', 'completed'], ['complete', 'completed'], ['done', 'completed'],
['finished', 'completed'], ['success', 'completed'], ['succeeded', 'completed'],
['continue', 'continue'], ['continuing', 'continue'], ['in-progress', 'continue'],
['in_progress', 'continue'], ['ongoing', 'continue'], ['pending', 'continue'],
['blocked', 'blocked'], ['blocker', 'blocked'],
['needs-human', 'needs-human'], ['needs_human', 'needs-human'],
['needs human', 'needs-human'], ['needshuman', 'needs-human'], ['human', 'needs-human'],
])
function agentKindToken(value) {
return typeof value === 'string'
? value.trim().replace(/^@+/, '').toLowerCase()
: ''
}
// Agents write task decisions by hand at the end of free text. Small slips
// (capitalised status, "done", an @handle, a single string instead of a list,
// a completed answer with a named deliverable) used to fail the whole turn
// and silently end the discussion. Normalise them first; the strict parser
// still validates the result.
function normalizeTaskDecision(input, options = {}) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return input
let status = STATUS_SYNONYMS.get(String(input.status || '').trim().toLowerCase())
|| String(input.status || '')
const textList = value => (Array.isArray(value) ? value : (value == null ? [] : [value]))
.filter(item => typeof item === 'string' && item.trim())
.map(item => item.trim().slice(0, 800))
.slice(0, 16)
const hasBlocker = value => Array.isArray(value) ? value.length > 0
: typeof value === 'string' ? Boolean(value.trim())
: value && typeof value === 'object' ? Object.keys(value).length > 0 : value === true
const explicitBlocker = input.blocked === true || hasBlocker(input.blockers) || hasBlocker(input.blocker)
if (status === 'completed' && explicitBlocker) status = 'blocked'
const blockerReason = Array.isArray(input.blockers) ? input.blockers.find(item => (
typeof item === 'string' && item.trim()
)) : (input.blockers || input.blocker)
const reason = explicitBlocker && typeof blockerReason === 'string' && blockerReason.trim()
? blockerReason.trim().slice(0, 1600)
: typeof input.reason === 'string' && input.reason.trim()
? input.reason.trim().slice(0, 1600)
: String(options.fallbackReason || '').trim().slice(0, 1600) || 'No reason given.'
const deliverables = textList(input.deliverables)
const nextKinds = [...new Set(textList(input.nextKinds).map(agentKindToken).filter(Boolean))]
const handoffTo = agentKindToken(input.handoffTo)
return {
status,
reason,
deliverables,
...(status === 'continue' && handoffTo ? { handoffTo } : {}),
// Keep an explicit list (even an empty one) so the stored decision says
// what the Agent wrote; peers can only be requested while continuing.
...(input.nextKinds != null
? { nextKinds: status === 'continue' ? nextKinds.slice(0, 32) : [] }
: {}),
...(input.reviewedVersion != null ? { reviewedVersion: input.reviewedVersion } : {}),
}
}
function parseLenientTaskDecision(input, options = {}) {
return parseTaskDecision(normalizeTaskDecision(input, options))
}
module.exports = { normalizeTaskDecision, parseLenientTaskDecision, parseTaskDecision }
@@ -3,6 +3,7 @@ const { z } = require('zod')
const { canonicalJson } = require('./context-pack-records.cjs')
const { redactSecrets } = require('../security/secret-redaction.cjs')
const { normalizeSummary } = require('../gates/human-gate-records.cjs')
const TASK_GRAPH_VERSION = 1
const TASK_GRAPH_CURSOR_VERSION = 1
@@ -55,12 +56,16 @@ const nodeContentFields = {
dependsOn: uniqueArray(publicIdSchema),
inputNodeIds: uniqueArray(publicIdSchema),
expectedOutput: boundedTextSchema,
claimKey: publicIdSchema.optional(),
acceptance: acceptanceSchema,
terminal: z.boolean(),
parallel: z.boolean(),
decisionOptions: uniqueArray(decisionOptionSchema, 16),
}
const nodeSchema = z.strictObject(nodeContentFields).superRefine((node, context) => {
if (node.claimKey !== undefined && (node.role !== 'primary' || redactSecrets(node.claimKey) !== node.claimKey)) {
context.addIssue({ code: 'custom', path: ['claimKey'], message: 'invalid claim key' })
}
if (node.inputNodeIds.some(nodeId => !node.dependsOn.includes(nodeId))) {
context.addIssue({ code: 'custom', path: ['inputNodeIds'], message: 'input must be a dependency' })
}
@@ -119,9 +124,11 @@ function validateGraphRelations(graph, targetKinds = null) {
fail('TASK_GRAPH_SCHEMA_INVALID')
}
const allowedKinds = targetKinds ? new Set(targetKinds) : null
const terminalIds = new Set(graph.nodes.filter(node => node.terminal).map(node => node.nodeId))
for (const node of graph.nodes) {
if (node.dependsOn.some(nodeId => !nodeIds.has(nodeId))
|| node.inputNodeIds.some(nodeId => !nodeIds.has(nodeId))
|| new Set(node.decisionOptions.map(option => option.optionId)).size !== node.decisionOptions.length
|| (allowedKinds && node.agentKind && !allowedKinds.has(node.agentKind))) {
fail('TASK_GRAPH_SCHEMA_INVALID')
}
@@ -138,11 +145,33 @@ function validateGraphRelations(graph, targetKinds = null) {
visited.add(nodeId)
}
for (const node of graph.nodes) visit(node.nodeId)
if (graph.nodes.some(node => !node.terminal && node.dependsOn.some(nodeId => terminalIds.has(nodeId)))) {
fail('TASK_GRAPH_SCHEMA_INVALID')
}
}
function createTaskGraph(input, targetKinds = null) {
const parsed = graphInputSchema.safeParse(input)
if (!parsed.success) fail('TASK_GRAPH_SCHEMA_INVALID')
if (parsed.data.nodes.some(node => node.role !== 'human'
&& !node.acceptance.requireConclusion && !node.acceptance.minArtifactRefs
&& !node.acceptance.minEvidenceRefs)) {
fail('TASK_GRAPH_SCHEMA_INVALID')
}
if (parsed.data.nodes.some(node => redactSecrets(node.nodeId) !== node.nodeId
|| node.decisionOptions.some(option => redactSecrets(option.optionId) !== option.optionId
|| redactSecrets(option.name) !== option.name || /[\u0000-\u001f\u007f]/u.test(option.name)))) {
fail('TASK_GRAPH_SCHEMA_INVALID')
}
if (parsed.data.nodes.some(node => node.role === 'human'
&& (!node.decisionOptions.some(option => option.kind === 'allow_once')
|| !node.decisionOptions.some(option => option.kind === 'reject_once')))) {
fail('TASK_GRAPH_SCHEMA_INVALID')
}
for (const node of parsed.data.nodes) {
if (node.role !== 'human') continue
try { normalizeSummary(node.expectedOutput) } catch { fail('TASK_GRAPH_SCHEMA_INVALID') }
}
const body = {
version: TASK_GRAPH_VERSION,
recordType: 'task-graph',
@@ -185,7 +214,7 @@ function createTaskGraphCursor(graphInput, now = Date.now()) {
})
}
function terminalStateFor(graph, nodeStates) {
function legacyTerminalStateFor(graph, nodeStates) {
const states = new Map(nodeStates.map(state => [state.nodeId, state]))
const terminal = graph.nodes.filter(node => node.terminal)
if (terminal.every(node => states.get(node.nodeId)?.status === 'accepted')) return 'accepted'
@@ -194,6 +223,13 @@ function terminalStateFor(graph, nodeStates) {
return 'running'
}
function terminalStateFor(graph, nodeStates) {
if (nodeStates.some(state => state.status === 'rejected')) return 'rejected'
if (nodeStates.some(state => state.status === 'failed')) return 'failed'
if (nodeStates.every(state => state.status === 'accepted')) return 'accepted'
return 'running'
}
function parseTaskGraphCursor(input, targetKinds = null) {
const parsed = cursorSchema.safeParse(input)
if (!parsed.success) fail('TASK_GRAPH_CURSOR_INVALID')
@@ -211,12 +247,23 @@ function parseTaskGraphCursor(input, targetKinds = null) {
? !['running', 'waiting'].includes(state.status)
: ['running', 'waiting'].includes(state.status)
))) fail('TASK_GRAPH_CURSOR_INVALID')
if (cursor.terminalState !== terminalStateFor(graph, cursor.nodeStates)) {
if (cursor.terminalState !== terminalStateFor(graph, cursor.nodeStates)
&& cursor.terminalState !== legacyTerminalStateFor(graph, cursor.nodeStates)) {
fail('TASK_GRAPH_CURSOR_INVALID')
}
return { ...cursor, graph }
}
function assertTaskGraphDependencies(cursorInput) {
const cursor = parseTaskGraphCursor(cursorInput)
const states = new Map(cursor.nodeStates.map(state => [state.nodeId, state.status]))
if (cursor.graph.nodes.some(node => states.get(node.nodeId) !== 'pending'
&& node.dependsOn.some(id => states.get(id) !== 'accepted'))) {
fail('TASK_GRAPH_CURSOR_INVALID')
}
return cursor
}
function updateTaskGraphCursor(cursorInput, nodeId, updates, now = Date.now()) {
const cursor = parseTaskGraphCursor(cursorInput)
const index = cursor.nodeStates.findIndex(state => state.nodeId === nodeId)
@@ -227,7 +274,7 @@ function updateTaskGraphCursor(cursorInput, nodeId, updates, now = Date.now()) {
const currentNodeIds = nodeStates
.filter(state => ['running', 'waiting'].includes(state.status))
.map(state => state.nodeId)
return parseTaskGraphCursor({
return assertTaskGraphDependencies({
...cursor,
nodeStates,
currentNodeIds,
@@ -262,6 +309,7 @@ module.exports = {
TASK_GRAPH_VERSION,
createTaskGraph,
createTaskGraphCursor,
assertTaskGraphDependencies,
parseTaskGraph,
parseTaskGraphCursor,
readyTaskGraphNodes,
+8 -3
View File
@@ -194,11 +194,14 @@ class HumanGateCoordinator {
const option = current.options.find(candidate => candidate.optionId === input.optionId)
if (!option) throw gateError('HUMAN_GATE_DECISION_INVALID')
const status = statusForHumanGateOption(option)
if (status === 'approved' && this.store.publicRecord(current).decisionContextUnavailable) {
throw gateError('HUMAN_GATE_CONTEXT_UNAVAILABLE')
}
if (Object.hasOwn(input, 'status') && input.status !== status) {
throw gateError('HUMAN_GATE_DECISION_INVALID')
}
const response = typeof input.response === 'string' ? input.response.trim() : ''
if ((current.type === 'input' && status === 'approved' && !response)
const response = typeof input.response === 'string' ? input.response : ''
if ((current.type === 'input' && status === 'approved' && !response.trim())
|| response.length > 32 * 1024
|| (current.type !== 'input' && Object.hasOwn(input, 'response'))
|| (status !== 'approved' && Object.hasOwn(input, 'response'))) {
@@ -208,9 +211,11 @@ class HumanGateCoordinator {
status,
optionId: option.optionId,
actorId: input.actorId || 'local-user',
decidedAt: input.decidedAt || this.now(),
decidedAt: input.decidedAt || current.decision?.decidedAt || this.now(),
...(response ? { response } : {}),
})
// Recovery owns recorded decisions; repeated submissions must not dispatch them again.
if (current.status !== 'pending') return publicHumanGate(record)
this.clearExpiry(record.gateId)
try {
this.onResumed(record, record.decision)
+6
View File
@@ -117,6 +117,11 @@ function createHumanGateRecord(input) {
...input,
summary: normalizeSummary(input?.summary),
})
if (normalized.options.some(option => redactSecrets(option.optionId) !== option.optionId
|| redactSecrets(option.kind) !== option.kind
|| redactSecrets(option.name) !== option.name || /[\u0000-\u001f\u007f]/u.test(option.name))) {
fail('HUMAN_GATE_SCHEMA_INVALID')
}
if (normalized.requestHash !== normalized.requestRef.hash) fail('HUMAN_GATE_SCHEMA_INVALID')
const body = {
version: HUMAN_GATE_VERSION,
@@ -196,6 +201,7 @@ module.exports = {
SYSTEM_REJECT_OPTION_ID,
createHumanGateRecord,
decideHumanGateRecord,
normalizeSummary,
parseHumanGateRecord,
publicHumanGate,
}
+36 -4
View File
@@ -1,10 +1,12 @@
const fs = require('node:fs')
const path = require('node:path')
const { createHash } = require('node:crypto')
const { ContentBlobStore } = require('../attachments/content-blob-store.cjs')
const {
canonicalJson,
} = require('../collaboration/outcome-records.cjs')
const { publicCollaborationText } = require('../collaboration/collaboration-records.cjs')
const {
createHumanGateRecord,
decideHumanGateRecord,
@@ -117,17 +119,22 @@ class HumanGateStore {
create(input = {}) {
const { request, ...recordInput } = input
const requestRef = this.contentBlobStore.put(canonicalPayload(request), {
const requestBytes = canonicalPayload(request)
const requestRef = {
algorithm: 'sha256',
hash: createHash('sha256').update(requestBytes).digest('hex'),
size: requestBytes.length,
mediaType: 'application/json',
})
}
const record = createHumanGateRecord({
...recordInput,
requestRef,
requestHash: requestRef.hash,
})
const existing = this.records.find(candidate => candidate.gateId === record.gateId)
if (!existing && this.records.length >= MAX_GATES) throw storeError('HUMAN_GATE_LIMIT')
this.contentBlobStore.put(requestBytes, { mediaType: 'application/json' })
if (existing) return existing
if (this.records.length >= MAX_GATES) throw storeError('HUMAN_GATE_LIMIT')
const next = [...this.records, record]
this.save(next)
this.records = next
@@ -184,11 +191,36 @@ class HumanGateStore {
return pending
}
publicRecord(record) {
const gate = publicHumanGate(record)
if (record.type !== 'decision' || record.status !== 'pending' || !record.agentRunId.startsWith('graph-decision-')) return gate
const unavailable = () => ({ ...gate, decisionContextUnavailable: true })
try {
const request = this.validateRequest(record)
if (!/^task-graph-[a-f0-9]{64}$/.test(request.graphId || '')
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(request.nodeId || '')
|| record.agentRunId !== `graph-decision-${createHash('sha256').update(JSON.stringify([request.graphId, request.nodeId])).digest('hex')}`) return unavailable()
if (request.decisionContext === undefined) return gate
if (!Array.isArray(request.decisionContext) || request.decisionContext.length > 32) return unavailable()
const decisionContext = request.decisionContext.map(item => {
if (!item || typeof item !== 'object' || Array.isArray(item)
|| Object.keys(item).sort().join(',') !== 'agentKind,text,truncated'
|| !/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/.test(item.agentKind || '')
|| typeof item.text !== 'string' || !item.text || item.text.length > 2000
|| typeof item.truncated !== 'boolean'
|| publicCollaborationText(item.text, 2000) !== item.text) return null
return { agentKind: item.agentKind, text: item.text, truncated: item.truncated }
})
if (!decisionContext.every(Boolean)) return unavailable()
return decisionContext.length ? { ...gate, decisionContext } : gate
} catch { return unavailable() }
}
list({ runId = '', pendingOnly = false } = {}) {
return this.records
.filter(record => (!runId || record.runId === runId)
&& (!pendingOnly || record.status === 'pending'))
.map(publicHumanGate)
.map(record => this.publicRecord(record))
}
}
@@ -17,6 +17,7 @@ const {
} = require('./knowledge-connector-memory.cjs')
const { KnowledgeConnectorRegistry } = require('./knowledge-connector-registry.cjs')
const { redactSecrets } = require('../security/secret-redaction.cjs')
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
const OBSIDIAN_KIND = 'obsidian'
const MEMORY_KIND = 'memory'
@@ -239,7 +240,13 @@ class LocalKnowledgeConnectors {
if (existing) return existing
const authorized = await this.registry.authorize(connectorId, input)
this.revokedConnectorIds.delete(connectorId)
this.saveRecords()
try {
this.saveRecords()
} catch (error) {
this.revokedConnectorIds.add(connectorId)
try { await this.registry.revoke(authorized.instanceId) } catch { this.registry = null }
throw error
}
return authorized
}
@@ -529,17 +536,12 @@ class LocalKnowledgeConnectors {
}
saveRecords() {
const directory = path.dirname(this.storagePath)
const temporaryPath = `${this.storagePath}.tmp`
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
fs.writeFileSync(temporaryPath, `${JSON.stringify({
atomicWritePrivateFile(this.storagePath, `${JSON.stringify({
version: 1,
revokedConnectorIds: [...this.revokedConnectorIds].sort(),
snapshots: [...this.snapshots.values()],
citations: [...this.citations.values()].map(result => result.citation),
}, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
fs.chmodSync(temporaryPath, 0o600)
fs.renameSync(temporaryPath, this.storagePath)
}, null, 2)}\n`)
}
}
+366 -96
View File
@@ -1,6 +1,12 @@
const electron = require('electron')
const { app, BrowserWindow, dialog, ipcMain, Menu, Notification, protocol, shell } = electron
const fs = require('node:fs')
const path = require('node:path')
const { redactSecrets } = require('./security/secret-redaction.cjs')
const { ensurePrivateDirectory } = require('./security/private-file.cjs')
const { agentVerificationIdentity, projectVerificationIdentity } = require('./workspace/agent-verification-identity.cjs')
const { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_ENVIRONMENT_IDENTITY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS,
nativeConfigEnvironment, nativeIdentityDirectory, nativeEnvironmentIdentity, nativeAuthIdentityFiles, nativeProjectConfigPaths, nativeNetworkIdentityFiles, validateNativeConfigDirectory } = require('./agents/native-config-directory.cjs')
const { AttachmentStore } = require('./attachments/attachment-store.cjs')
const { ContentBlobStore } = require('./attachments/content-blob-store.cjs')
const { ContextPackStore } = require('./collaboration/context-pack-store.cjs')
@@ -25,6 +31,11 @@ const {
shutdownAcpSessionRuntime,
} = require('./agents/cli/cli-adapters.cjs')
const { AgentInstaller } = require('./agents/installer/agent-installer.cjs')
const { networkEnvironment } = require('./agents/cli/cli-network-environment.cjs')
const { ocrDeclaredCredentialKeys } = require('./agents/cli/ocr-provider-environment.cjs')
const { mimoDeclaredCredentialKeys } = require('./agents/cli/mimo-native-config.cjs')
const { qwenDeclaredCredentialKeys } = require('./agents/cli/qwen-native-config.cjs')
const { opencodeDeclaredCredentialKeys } = require('./agents/cli/opencode-native-config.cjs')
const { AgentConnectorInstanceStore } = require('./agents/connectors/agent-connector-instance-store.cjs')
const { LocalAgentConnectors } = require('./agents/connectors/agent-connector-local.cjs')
const { AgentConnectorPackageStore } = require('./agents/connectors/agent-connector-package-store.cjs')
@@ -40,16 +51,24 @@ const {
const { ChannelInboxStore } = require('./channels/channel-inbox-store.cjs')
const { ChannelIngressRuntime } = require('./channels/channel-ingress-runtime.cjs')
const { CustomAgentStore } = require('./agents/custom-agent-store.cjs')
const { configDirectoryEnvironment } = require('./agents/cli/cli-config-directories.cjs')
const { nativeUserConfigSummary } = require('./agents/native-config-summary.cjs')
const { deepseekNativeConfig } = require('./agents/cli/deepseek-native-config.cjs')
const { hermesConfiguredProvider } = require('./agents/hermes-config-summary.cjs')
const { codexConfiguredProvider } = require('./agents/codex-config-summary.cjs')
const {
codexDeclaredCredentialKeys,
hermesDeclaredCredentialKeys,
zcodeDeclaredCredentialKeys,
nativeCredentialEnvironment,
piDeclaredCredentialKeys,
resolveNativeOpenClawRuntime,
resolveNativeCredentialState,
resolveNativeShellEnvironment,
} = require('./agents/local-agent-readiness.cjs')
const { LocalWorkspace } = require('./workspace/local-workspace.cjs')
const { MediaGenerationRuntime } = require('./media/media-generation-runtime.cjs')
const { resolveMediaProvider } = require('./media/media-provider-resolution.cjs')
const { registerDesktopIpc } = require('./shell/main-ipc.cjs')
const { workBuddyEnvironment, workBuddySettings } = require('./agents/cli/workbuddy-runtime.cjs')
const {
isAllowedExternalUrl,
isAllowedLocalNavigation: isAllowedLocalNavigationForPaths,
@@ -70,26 +89,39 @@ const {
resolveKnowledgeBaseSources,
} = require('./knowledge/local-knowledge-base.cjs')
const { LocalKnowledgeConnectors } = require('./knowledge/local-knowledge-connectors.cjs')
const { ProviderStore } = require('./providers/provider-store.cjs')
const { nativeOpenClawOptions } = require('./agents/cli/openclaw-runtime.cjs')
const { migrateLegacyProductData } = require('./product-data-migration.cjs')
const {
EXTERNAL_PROVIDER_KINDS,
providerAgentKind,
providerOptionsFor,
} = require('./providers/provider-options.cjs')
const LOCAL_AGENT_KINDS = new Set([
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
'codex', 'hermes', 'openclaw', 'workbuddy', 'zcode', 'pi', 'kimi', 'mimo', 'deepseek', 'claude', 'gemini', 'opencode', 'qwen',
'opencodereview',
])
const QUIT_CLEANUP_TIMEOUT_MS = 2500
let mainWindow = null
// Faults outside a request handler are written to a small private log in
// userData (secrets redacted, size-capped) instead of disappearing or
// taking the app down, so instability reports have evidence.
function recordMainProcessFault(kind, detail) {
const text = String(detail?.stack || detail?.message || detail || '').slice(0, 4000)
console.error(`[meldwork] ${kind}`, text)
try {
const file = path.join(app.getPath('userData'), 'meldwork-diagnostics.log')
try {
if (fs.statSync(file).size > 1024 * 1024) fs.renameSync(file, `${file}.1`)
} catch { /* first entry */ }
fs.appendFileSync(file, `${new Date().toISOString()} ${kind} ${redactSecrets(text)}\n`, { mode: 0o600 })
} catch { /* diagnostics must never throw */ }
}
process.on('unhandledRejection', reason => recordMainProcessFault('unhandledRejection', reason))
process.on('uncaughtException', error => recordMainProcessFault('uncaughtException', error))
let workspace = null
let workspaceStartupError = ''
let workspaceChangedListener = null
let workspaceRunFinishedListener = null
let workspaceRunEventListener = null
let installer = null
let nativeSkillDirectories = {}
let localAgentRefreshQueue = Promise.resolve()
let providerStore = null
let customAgentStore = null
let agentConnectors = null
let cloudAgentRuntime = null
@@ -226,62 +258,181 @@ function openExternalUrl(value) {
return true
}
function providerOptions(kind, context = {}) {
if (!EXTERNAL_PROVIDER_KINDS.has(kind)) return {}
const status = providerStore?.status(kind) || {}
if (!status.configured && !(kind === 'openclaw' && context.nativeRuntime)) return {}
return providerOptionsFor(
kind,
status.configured ? providerStore.envForAgent(kind) : {},
context,
status,
)
}
async function nativeShellEnvironment() {
async function nativeShellEnvironment(options = {}) {
const home = app.getPath('home')
const resolved = await resolveNativeShellEnvironment({ home })
let resolved = await resolveNativeShellEnvironment({ home, refresh: options.refresh === true })
const initialEnv = { ...process.env, ...resolved.env }
for (const key of resolved.unsetKeys || []) delete initialEnv[key]
const codexEnv = nativeConfigEnvironment('codex', workspace?.state.agentPreferences, initialEnv)
const codexCredentialKeys = codexDeclaredCredentialKeys({ home, env: codexEnv })
const hermesEnv = nativeConfigEnvironment('hermes', workspace?.state.agentPreferences, initialEnv)
const hermesCredentialKeys = hermesDeclaredCredentialKeys({ home, env: hermesEnv })
const deepseekEnv = nativeConfigEnvironment('deepseek', workspace?.state.agentPreferences, initialEnv)
const deepseekCredentialKeys = deepseekNativeConfig({ home, env: deepseekEnv }).credentialKeys
const zcodeEnv = nativeConfigEnvironment('zcode', workspace?.state.agentPreferences, initialEnv)
const zcodeCredentialKeys = zcodeDeclaredCredentialKeys({ home, workdir: options.workdir, env: zcodeEnv })
const qwenEnv = nativeConfigEnvironment('qwen', workspace?.state.agentPreferences, initialEnv)
const qwenCredentialKeys = qwenDeclaredCredentialKeys({ home, workdir: options.workdir, env: qwenEnv })
const ocrEnv = nativeConfigEnvironment('opencodereview', workspace?.state.agentPreferences, initialEnv)
const ocrCredentialKeys = ocrDeclaredCredentialKeys({ home, env: ocrEnv })
const piEnv = nativeConfigEnvironment('pi', workspace?.state.agentPreferences, initialEnv)
const piCredentialKeys = piDeclaredCredentialKeys({ home, env: piEnv })
const opencodeEnv = nativeConfigEnvironment('opencode', workspace?.state.agentPreferences, initialEnv)
let opencodeCredentialKeys = opencodeDeclaredCredentialKeys({ home, env: opencodeEnv, workdir: options.workdir })
const mimoEnv = nativeConfigEnvironment('mimo', workspace?.state.agentPreferences, initialEnv)
let mimoCredentialKeys = mimoDeclaredCredentialKeys({ home, env: mimoEnv, workdir: options.workdir })
for (let pass = 0; pass < 2; pass += 1) {
const declaredKeys = [...new Set([...codexCredentialKeys, ...hermesCredentialKeys, ...zcodeCredentialKeys, ...piCredentialKeys,
...opencodeCredentialKeys, ...mimoCredentialKeys, ...deepseekCredentialKeys, ...qwenCredentialKeys, ...ocrCredentialKeys])]
if (!declaredKeys.length) break
const chunks = []
for (let index = 0; index < declaredKeys.length; index += 32) chunks.push(declaredKeys.slice(index, index + 32))
const results = await Promise.all(chunks.map(extraEnvKeys => resolveNativeShellEnvironment({
home, refresh: options.refresh === true, extraEnvKeys,
})))
const extra = {}
const unset = []
for (let index = 0; index < chunks.length; index += 1) {
for (const key of chunks[index]) {
if (Object.hasOwn(results[index].env, key)) extra[key] = results[index].env[key]
if (results[index].unsetKeys?.includes(key)) unset.push(key)
}
}
resolved = { ...resolved, env: { ...resolved.env, ...extra },
unsetKeys: [...new Set([...(resolved.unsetKeys || []).filter(key => !declaredKeys.includes(key)),
...unset])] }
if (pass === 0) {
const enriched = { ...process.env, ...resolved.env }
for (const key of resolved.unsetKeys || []) delete enriched[key]
const nextEnv = nativeConfigEnvironment('opencode', workspace?.state.agentPreferences, enriched)
const nextKeys = opencodeDeclaredCredentialKeys({ home, env: nextEnv, workdir: options.workdir })
const nextMimoEnv = nativeConfigEnvironment('mimo', workspace?.state.agentPreferences, enriched)
const nextMimoKeys = mimoDeclaredCredentialKeys({ home, env: nextMimoEnv, workdir: options.workdir })
const needsAnotherPass = nextKeys.some(key => !opencodeCredentialKeys.includes(key))
|| nextMimoKeys.some(key => !mimoCredentialKeys.includes(key))
opencodeCredentialKeys = nextKeys
mimoCredentialKeys = nextMimoKeys
if (needsAnotherPass) continue
}
break
}
const env = { ...process.env, ...resolved.env }
for (const key of resolved.unsetKeys || []) delete env[key]
opencodeCredentialKeys = opencodeCredentialKeys.filter(key => typeof env[key] === 'string' && env[key].length > 0)
for (const [kind, { key }] of Object.entries(NATIVE_CONFIG_DIRECTORIES)) {
if (kind === 'zcode' || kind === 'opencodereview') continue
if (kind === 'qwen') {
const directory = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || ''
if (Object.hasOwn(nativeSkillDirectories, key) && nativeSkillDirectories[key] !== directory) skillCatalog?.invalidate(kind)
nativeSkillDirectories[key] = directory
continue
}
const directory = typeof env[key] === 'string' ? env[key] : ''
if ((nativeSkillDirectories[key] || '') !== directory) skillCatalog?.invalidate(kind)
nativeSkillDirectories[key] = directory
}
const sharedDirectory = typeof env.DSH_AGENTS_HOME === 'string' ? env.DSH_AGENTS_HOME : ''
if ((nativeSkillDirectories.DSH_AGENTS_HOME || '') !== sharedDirectory) skillCatalog?.invalidate('deepseek')
nativeSkillDirectories.DSH_AGENTS_HOME = sharedDirectory
const xdgConfig = typeof env.XDG_CONFIG_HOME === 'string' && path.isAbsolute(env.XDG_CONFIG_HOME)
? env.XDG_CONFIG_HOME : ''
if ((nativeSkillDirectories.XDG_CONFIG_HOME || '') !== xdgConfig) skillCatalog?.invalidate('opencode')
nativeSkillDirectories.XDG_CONFIG_HOME = xdgConfig
return {
...resolved,
env: { ...process.env, ...resolved.env },
env,
home,
codexCredentialKeys,
hermesCredentialKeys,
deepseekCredentialKeys,
zcodeCredentialKeys,
qwenCredentialKeys,
ocrCredentialKeys,
piCredentialKeys,
opencodeCredentialKeys,
mimoCredentialKeys,
}
}
function nativeDeclaredCredentialKeys(kind, captured) {
const properties = {
codex: 'codexCredentialKeys', hermes: 'hermesCredentialKeys', zcode: 'zcodeCredentialKeys',
pi: 'piCredentialKeys', opencode: 'opencodeCredentialKeys', mimo: 'mimoCredentialKeys',
deepseek: 'deepseekCredentialKeys', qwen: 'qwenCredentialKeys', opencodereview: 'ocrCredentialKeys',
}
const keys = Object.hasOwn(properties, kind) ? captured?.[properties[kind]] || [] : []
return kind === 'codex' ? ['CODEX_API_KEY', ...keys] : keys
}
async function detectLocalAgents() {
const shellEnvironment = await nativeShellEnvironment()
return detectAgents({ env: shellEnvironment.env, home: shellEnvironment.home })
const agents = await detectAgents({
env: shellEnvironment.env,
home: shellEnvironment.home,
nativeCredentialKeysForKind: kind => nativeDeclaredCredentialKeys(kind, shellEnvironment),
nativeEnvironmentForKind: kind => kind === 'openclaw'
? {} : nativeCredentialEnvironment(kind, nativeConfigEnvironment(kind, workspace?.state.agentPreferences, shellEnvironment.env),
nativeDeclaredCredentialKeys(kind, shellEnvironment)),
})
return agents.map(agent => {
const env = nativeConfigEnvironment(agent.kind, workspace?.state.agentPreferences, shellEnvironment.env)
const directory = nativeIdentityDirectory(agent.kind, env, shellEnvironment.home)
const result = { ...agent }
if (directory) Object.defineProperty(result, NATIVE_CONFIG_IDENTITY_DIRECTORY, { value: directory })
const authFiles = [...new Set([...nativeAuthIdentityFiles(agent.kind, env, shellEnvironment.home),
...nativeNetworkIdentityFiles(env)])]
if (authFiles.length) Object.defineProperty(result, NATIVE_AUTH_IDENTITY_FILES, { value: authFiles })
const projectPaths = nativeProjectConfigPaths(agent.kind, env)
if (projectPaths.length) Object.defineProperty(result, NATIVE_PROJECT_CONFIG_PATHS, { value: projectPaths })
if (agent.kind !== 'openclaw') {
const keys = nativeDeclaredCredentialKeys(agent.kind, shellEnvironment)
const fingerprint = nativeEnvironmentIdentity({
...(env.PATH ? { PATH: env.PATH } : {}),
...networkEnvironment(env), ...configDirectoryEnvironment(env),
...nativeCredentialEnvironment(agent.kind, env, keys),
})
Object.defineProperty(result, NATIVE_ENVIRONMENT_IDENTITY, { value: fingerprint, enumerable: true })
}
return result
})
}
function mediaFallbackProviders(env = process.env) {
const apiKey = String(
env.ZGCI_MEDIA_API_KEY
|| env.ZGCI_API_KEY
|| env.ZCGI_API_KEY
|| env.ZGCI_LLM_API_KEY
|| '',
).trim()
if (!apiKey) return []
const baseUrl = String(env.ZGCI_MEDIA_BASE_URL || 'https://hub.zgci.org/v1')
.trim()
.replace(/\/+$/, '')
.replace(/\/chat\/completions$/i, '')
let parsed
try { parsed = new URL(baseUrl) } catch { return [] }
if (parsed.protocol !== 'https:' || parsed.username || parsed.password
|| parsed.search || parsed.hash) {
return []
async function resolveProjectEnvironmentIdentities(workdir, kinds) {
const captured = await nativeShellEnvironment({ workdir, refresh: true })
const result = {}
for (const kind of kinds) {
if (!LOCAL_AGENT_KINDS.has(kind)) continue
if (kind === 'openclaw') {
const agent = workspace?.detectedAgents?.find(item => item.kind === kind)
if (!agent?.executable) continue
try {
const runtime = await resolveNativeOpenClawRuntime({ executable: agent.executable,
env: nativeConfigEnvironment(kind, workspace?.state.agentPreferences, captured.env), home: captured.home })
result[kind] = nativeEnvironmentIdentity({ runtime: JSON.stringify(runtime),
...networkEnvironment(captured.env) })
} catch { /* Fail closed for this Agent without blocking other kinds. */ }
continue
}
const keys = nativeDeclaredCredentialKeys(kind, captured)
const env = nativeConfigEnvironment(kind, workspace?.state.agentPreferences, captured.env)
const authFiles = [...new Set([...nativeAuthIdentityFiles(kind, env, captured.home),
...nativeNetworkIdentityFiles(env)])]
const agent = { kind, [NATIVE_AUTH_IDENTITY_FILES]: authFiles }
const environmentIdentity = nativeEnvironmentIdentity({
...(env.PATH ? { PATH: env.PATH } : {}),
...networkEnvironment(env), ...configDirectoryEnvironment(env),
...nativeCredentialEnvironment(kind, env, keys),
nativeAuthFiles: agentVerificationIdentity(agent),
})
const projectPaths = nativeProjectConfigPaths(kind, env)
if (projectPaths.length) {
agent[NATIVE_PROJECT_CONFIG_PATHS] = projectPaths
result[kind] = projectVerificationIdentity(kind, workdir, agent, environmentIdentity)
} else {
result[kind] = environmentIdentity
}
}
return [{
kind: 'zgci-media',
status: {
configured: true,
provider: 'ZGCI Media',
baseUrl,
model: 'glm',
},
credentials: { OPENAI_API_KEY: apiKey },
}]
return result
}
function workspaceStoragePath(userData = app.getPath('userData')) {
@@ -371,30 +522,65 @@ async function validateKnowledgeBaseSelections(targetKinds, selections) {
async function runCoreAgent(agent, prompt, workdir, options = {}) {
const connectorCredentialIsolation = options.connectorCredentialIsolation === true
const connectorInstanceId = options.connectorInstanceId
const runOptions = { ...options }
delete runOptions.connectorCredentialIsolation
const status = providerStore.status(agent.kind)
const shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment()
const nativeRuntime = !connectorCredentialIsolation
&& agent.kind === 'openclaw' && !status.configured
delete runOptions.connectorInstanceId
delete runOptions.nativeCredentialKeys
let connectorConfigEnv = {}
if (connectorCredentialIsolation) {
if (agent.kind !== 'codex' || !/^custom-[a-f0-9]{16}$/.test(String(connectorInstanceId || ''))) {
throw new Error('AGENT_CONNECTOR_CREDENTIAL_ISOLATION_UNSUPPORTED')
}
const privateRoot = path.join(app.getPath('userData'), 'meldwork-private')
const connectorRoot = path.join(privateRoot, 'connector-native')
const instanceRoot = path.join(connectorRoot, connectorInstanceId)
for (const directory of [privateRoot, connectorRoot, instanceRoot]) ensurePrivateDirectory(directory)
connectorConfigEnv = { CODEX_HOME: instanceRoot }
for (const [key, name] of [['XDG_CONFIG_HOME', 'config'], ['XDG_DATA_HOME', 'data'],
['XDG_STATE_HOME', 'state'], ['XDG_CACHE_HOME', 'cache'], ['XDG_RUNTIME_DIR', 'runtime']]) {
const directory = path.join(instanceRoot, name)
ensurePrivateDirectory(directory)
connectorConfigEnv[key] = directory
}
}
const shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment({ workdir })
const nativeKeys = connectorCredentialIsolation ? ['CODEX_API_KEY']
: nativeDeclaredCredentialKeys(agent.kind, shellEnvironment)
if (!connectorCredentialIsolation) {
const directory = workspace?.state.agentPreferences?.[agent.kind]?.nativeConfigDirectory
if (directory) validateNativeConfigDirectory(agent.kind, directory)
}
const selectedNativeEnv = shellEnvironment
? nativeConfigEnvironment(agent.kind, workspace?.state.agentPreferences, shellEnvironment.env) : null
// OpenClaw runs through its isolated native runtime scope; every other Agent
// CLI uses its own native credentials.
const nativeRuntime = !connectorCredentialIsolation && agent.kind === 'openclaw'
? await resolveNativeOpenClawRuntime({
executable: agent.executable,
env: shellEnvironment.env,
env: selectedNativeEnv,
home: shellEnvironment.home,
})
: null
const injected = connectorCredentialIsolation
const injected = connectorCredentialIsolation || agent.kind !== 'openclaw' || !nativeRuntime
? {}
: providerOptions(agent.kind, {
...runOptions,
workdir,
: nativeOpenClawOptions({
storageRoot: app.getPath('userData'),
nativeRuntime,
workdir,
sessionRef: runOptions.sessionRef,
allowWrite: runOptions.sandbox === 'workspace-write',
runtime: nativeRuntime,
openclawVersion: agent.version,
})
const nativeEnv = connectorCredentialIsolation || agent.kind === 'openclaw'
? {}
: {
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env),
...configDirectoryEnvironment(selectedNativeEnv),
...nativeCredentialEnvironment(agent.kind, selectedNativeEnv, nativeKeys),
...(agent.kind === 'workbuddy' ? workBuddyEnvironment({
env: selectedNativeEnv, home: shellEnvironment.home,
modelId: workspace?.state.agentPreferences.workbuddy?.modelId,
}) : {}),
...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}),
}
const callerEnv = connectorCredentialIsolation || agent.kind !== 'openclaw'
@@ -402,8 +588,16 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
: {}
return runAgent(agent, prompt, workdir, {
...runOptions,
...(shellEnvironment?.unsetKeys ? { nativeUnsetKeys: shellEnvironment.unsetKeys } : {}),
nativeCredentialKeys: nativeKeys,
...injected,
env: { ...nativeEnv, ...callerEnv, ...injected.env },
env: {
...networkEnvironment(shellEnvironment?.env),
...nativeEnv, ...callerEnv,
...(connectorCredentialIsolation ? {} : nativeConfigEnvironment(agent.kind, workspace?.state.agentPreferences)),
...injected.env,
...connectorConfigEnv,
},
})
}
@@ -475,6 +669,7 @@ async function requestLocalSkillTrust({ binding, coordinates, manifest }) {
function createWorkspace() {
cloudAgentStartRetry?.cancel()
const privateRoot = path.join(app.getPath('userData'), 'meldwork-private')
ensurePrivateDirectory(privateRoot)
const contentBlobStore = new ContentBlobStore({
rootPath: path.join(privateRoot, 'content-blobs'),
})
@@ -533,17 +728,6 @@ function createWorkspace() {
}),
connectors: [],
})
const mediaGeneration = new MediaGenerationRuntime({
getProvider: (kind, type, excludedKinds) => resolveMediaProvider({
requestedKind: kind,
type,
kinds: EXTERNAL_PROVIDER_KINDS,
excludedKinds,
statusFor: candidateKind => providerStore.status(candidateKind),
credentialsFor: candidateKind => providerStore.envForAgent(candidateKind),
fallbackProviders: mediaFallbackProviders(),
}),
})
const localWorkspace = new LocalWorkspace({
storagePath: workspaceStoragePath(),
contentBlobStore,
@@ -552,6 +736,7 @@ function createWorkspace() {
}),
outcomeStore,
agentFitMatrix: FROZEN_AGENT_FIT_MATRIX,
resolveProjectEnvironmentIdentities,
runLedger: cloudAgentRuntime.workspaceLedger(),
detectAgents: async () => {
await cloudAgentBridges?.refresh()
@@ -570,7 +755,10 @@ function createWorkspace() {
captureArtifactOutputs: (workdir, options) => captureArtifactOutputState(workdir, options),
captureAgentOutcomeDescriptors,
importAgentOutputs: input => importAgentOutputs(input, attachments.availableStore()),
validateSkillSelections: (kind, selections) => skillSnapshotSelections.prepare(kind, selections),
validateSkillSelections: async (kind, selections) => {
if (selections?.length) await nativeShellEnvironment()
return skillSnapshotSelections.prepare(kind, selections)
},
validateKnowledgeBaseSelections,
imageAttachmentLimit,
attachmentSupport: localAttachmentSupport,
@@ -578,18 +766,25 @@ function createWorkspace() {
if (customAgentStore.has(kind)) return { state: 'ready', source: 'custom-agent' }
if (agentConnectors.has(kind)) return { state: 'ready', source: 'agent-connector' }
const shellEnvironment = await nativeShellEnvironment()
const directory = workspace?.state.agentPreferences?.[kind]?.nativeConfigDirectory
if (directory) {
try { validateNativeConfigDirectory(kind, directory) } catch {
return { state: 'unknown', source: 'native-runtime-unavailable' }
}
}
return resolveNativeCredentialState(kind, {
executable: agent?.executable,
env: shellEnvironment.env,
env: nativeConfigEnvironment(kind, workspace?.state.agentPreferences, shellEnvironment.env),
home: shellEnvironment.home,
credentialSource: shellEnvironment.source,
nativeCredentialKeys: nativeDeclaredCredentialKeys(kind, shellEnvironment),
...(kind === 'workbuddy' ? { modelId: workspace?.state.agentPreferences.workbuddy?.modelId } : {}),
})
},
agentLabel: kind => customAgentStore.label(kind) || agentConnectors.label(kind),
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
isKnownAgent: kind => cloudAgentBridges.catalogEntries().some(agent => agent.kind === kind),
connectorRuntime: agentConnectors,
naturalAgentResponses: true,
generateMedia: input => mediaGeneration.generate(input),
runAgent: async (agent, prompt, workdir, options = {}) => {
if (customAgentStore.has(agent.kind)) {
return customAgentStore.run(agent.kind, prompt, workdir, {
@@ -651,8 +846,9 @@ async function localAgentCatalog() {
credentialState: agent.installed
? (state?.credentialState || agent.credentialState || 'unknown')
: 'missing',
availabilitySource: agent.installed ? (state?.availabilitySource || 'unverified') : 'none',
showInSidebar: Boolean(agent.installed && state?.showInSidebar),
availabilitySource: agent.installed ? (state?.availabilitySource || agent.availabilitySource || 'unverified') : 'none',
showInSidebar: Boolean(agent.installed && (state?.showInSidebar
?? (agent.cloud ? workspace?.state?.agentPreferences?.[agent.kind]?.showInSidebar ?? true : false))),
attachmentTypes: Object.entries(support)
.filter(([, limit]) => Number(limit) > 0)
.map(([type]) => type),
@@ -666,6 +862,7 @@ async function refreshLocalAgentState() {
if (!target) return null
const task = localAgentRefreshQueue.then(async () => {
if (workspace !== target) return workspace?.snapshot() || null
await nativeShellEnvironment({ refresh: true })
const snapshot = await target.refreshAgents()
if (workspace !== target) return workspace?.snapshot() || null
return snapshot
@@ -687,7 +884,7 @@ function initializeWorkspace() {
return next
}
function createWindow() {
function createWindow(startupError = workspaceStartupError) {
mainWindow = new BrowserWindow({
width: 1440,
height: 920,
@@ -734,11 +931,22 @@ function createWindow() {
showOfflinePage(errorCode)
})
mainWindow.webContents.on('did-finish-load', flushPendingOpenGroup)
// A crashed renderer used to leave a blank window; reload it so the
// workspace (whose state lives in the main process) comes back.
mainWindow.webContents.on('render-process-gone', (_event, details) => {
recordMainProcessFault('render-process-gone', details?.reason || 'unknown')
if (details?.reason !== 'clean-exit' && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.reload()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
loadFrontend()
flushPendingOpenGroup()
if (startupError) showOfflinePage(startupError)
else {
loadFrontend()
flushPendingOpenGroup()
}
return mainWindow
}
@@ -773,6 +981,22 @@ function registerIpc() {
getCloudAgentBridges: () => cloudAgentBridges,
getMainWindow: () => mainWindow,
getWorkspace: () => workspace,
getNativeConfigSummary: async (kind, currentWorkspace) => {
if (!['hermes', 'codex', 'qwen', 'zcode', 'deepseek'].includes(kind)) return null
const shell = await nativeShellEnvironment()
const env = nativeConfigEnvironment(kind, currentWorkspace.state.agentPreferences, shell.env)
if (kind === 'deepseek') return { provider: deepseekNativeConfig({ home: shell.home, env }).explicitModel ? '' : 'zgci' }
if (kind === 'qwen' || kind === 'zcode') return nativeUserConfigSummary(kind, { home: shell.home, env })
return { provider: kind === 'hermes'
? hermesConfiguredProvider({ home: shell.home, env })
: codexConfiguredProvider({ home: shell.home, env }) }
},
getWorkBuddySettings: async () => {
const shell = await nativeShellEnvironment()
return workBuddySettings({ ...shell,
env: nativeConfigEnvironment('workbuddy', workspace?.state.agentPreferences, shell.env),
modelId: workspace?.state.agentPreferences.workbuddy?.modelId })
},
installer,
isShutdownStarted: () => shutdownStarted,
knowledgeBaseStore,
@@ -783,10 +1007,11 @@ function registerIpc() {
openExternalUrl,
getOutcomeStore: () => workspace?.outcomeStore,
getSkillTrustStore: () => skillTrustStore,
providerStore,
providerAgentKind,
refreshLocalAgentState,
registerTrustedHandle,
resolveConnectorEnvironment: async names => (await resolveNativeShellEnvironment({
home: app.getPath('home'), extraEnvKeys: names, refresh: true,
})).env,
skillCatalog,
})
}
@@ -799,6 +1024,13 @@ if (!hasSingleInstanceLock) {
app.on('second-instance', activateMainWindow)
app.whenReady().then(async () => {
try {
ensurePrivateDirectory(app.getPath('userData'))
} catch (error) {
console.error('MELDWORK_PRIVATE_DIRECTORY_UNAVAILABLE', { code: String(error?.code || '') })
app.quit()
return
}
try {
migrateLegacyProductData(app.getPath('userData'))
} catch (error) {
@@ -812,16 +1044,18 @@ if (!hasSingleInstanceLock) {
const attachmentReferences = persistedAttachmentReferences(workspaceStoragePath())
customAgentStore = new CustomAgentStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-custom-agents.json'),
environment: async () => (await nativeShellEnvironment()).env,
})
cloudAgentBridges = new CloudAgentBridgeStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-cloud-agent-bridges.json'),
fetch: globalThis.fetch,
})
providerStore = new ProviderStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-provider.json'),
safeStorage: lazySafeStorage,
allowedKinds: [...LOCAL_AGENT_KINDS],
})
// The shared Provider store was removed; drop any legacy encrypted
// credentials so they do not linger on disk unread.
try {
const { rmSync } = require('node:fs')
rmSync(path.join(app.getPath('userData'), 'meldwork-provider.json'), { force: true })
} catch { /* best-effort cleanup */ }
knowledgeBaseStore = new KnowledgeBaseStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-knowledge-base.json'),
})
@@ -833,10 +1067,36 @@ if (!hasSingleInstanceLock) {
attachmentStore = null
}
attachments.registerProtocol(protocol)
skillCatalog = new LocalSkillCatalog({ home: app.getPath('home') })
skillCatalog = new LocalSkillCatalog({
home: app.getPath('home'),
configDirectoryForKind: kind => {
const key = NATIVE_CONFIG_DIRECTORIES[kind]?.key
if (!key) return ''
let directory = nativeConfigEnvironment(kind, workspace?.state.agentPreferences, nativeSkillDirectories)[key] || ''
if (directory === '~' || directory.startsWith('~/') || (process.platform === 'win32' && directory.startsWith('~\\'))) {
directory = path.join(app.getPath('home'), directory.slice(2))
}
if (directory && !path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
return directory
},
sharedDirectoryForKind: kind => {
if (kind === 'opencode') return nativeSkillDirectories.XDG_CONFIG_HOME || ''
if (kind !== 'deepseek') return ''
let directory = nativeSkillDirectories.DSH_AGENTS_HOME || ''
if (!directory.trim()) return ''
if (directory === '~' || directory.startsWith('~/') || directory.startsWith('~\\')) {
directory = path.join(app.getPath('home'), directory.slice(2))
}
if (!path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
return directory
},
})
installer = new AgentInstaller({
detectAgents: detectLocalAgents,
listSkills: kind => skillCatalog.list(kind),
listSkills: async kind => {
await nativeShellEnvironment()
return skillCatalog.list(kind)
},
})
installer.on('changed', state => {
if (mainWindow && !mainWindow.isDestroyed()
@@ -847,7 +1107,17 @@ if (!hasSingleInstanceLock) {
refreshLocalAgentState().catch(() => {})
}
})
initializeWorkspace()
try {
initializeWorkspace()
} catch (error) {
if (error?.message !== 'RUN_LEDGER_LOAD_FAILED') throw error
workspaceStartupError = 'RUN_LEDGER_LOAD_FAILED'
createWindow()
app.on('activate', () => {
if (!shutdownStarted && BrowserWindow.getAllWindows().length === 0) createWindow()
})
return
}
if (attachmentStore && attachmentReferences) {
try {
attachmentStore.cleanup(attachmentReferences)
+7 -2
View File
@@ -89,13 +89,18 @@
},
}
const locale = navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
const copy = messages[locale]
const errorCode = new URLSearchParams(location.search).get('code') || 'UNKNOWN'
const ledgerMessages = {
zh: { status: '本地运行记录无法读取', title: '无法打开 Meldwork', message: '为保护已有运行记录,应用已停止初始化。请备份应用数据并从有效备份恢复记录,之后重新启动。', code: '错误代码' },
en: { status: 'Local run history could not be read', title: 'Meldwork could not open', message: 'Initialization stopped to protect your existing run history. Back up the application data, restore the records from a valid backup, and restart.', code: 'Error code' },
}
const copy = errorCode === 'RUN_LEDGER_LOAD_FAILED' ? ledgerMessages[locale] : messages[locale]
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en'
document.title = copy.title
document.querySelector('#status').textContent = copy.status
document.querySelector('#title').textContent = copy.title
document.querySelector('#message').textContent = copy.message
document.querySelector('#error-code').textContent = `${copy.code}: ${new URLSearchParams(location.search).get('code') || 'UNKNOWN'}`
document.querySelector('#error-code').textContent = `${copy.code}: ${errorCode}`
</script>
</body>
</html>
Loaded 100 of 463 files, more files were not shown because too many files have changed in this diff. Show more