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
288 changed files with 26668 additions and 1964 deletions

No files matched your search

+67
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
+1
View File
@@ -11,6 +11,7 @@
- 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.
- 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.
+44 -19
View File
@@ -2,10 +2,6 @@
<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
**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.**
@@ -15,13 +11,35 @@ Meldwork is a local-first multi-Agent collaboration workspace for the Agent tool
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-Sun/Meldwork/releases/download/Meldwork-V1.0.7/Meldwork-1.0.7-arm64.dmg"><strong>Download Meldwork V1.0.7 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>
## What Meldwork is for
[![Watch the Meldwork product video on YouTube](assets/media/meldwork-handoff-preview.jpg)](https://youtu.be/ttyJxo1s9ns)
[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 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:
- research and information synthesis across local Agent tools and selected knowledge sources;
@@ -75,10 +93,29 @@ Meldwork detects and invokes compatible locally installed Agent commands. The ca
<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>
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.
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.
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.
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.
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.
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.
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
@@ -86,12 +123,6 @@ Meldwork runs as a local Electron application. Conversations, group configuratio
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.
## Download V1.0.7
For Apple silicon macOS, download the [Meldwork V1.0.7 prerelease](https://github.com/Ryder-Sun/Meldwork/releases/tag/Meldwork-V1.0.7) and choose [`Meldwork-1.0.7-arm64.dmg`](https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.7/Meldwork-1.0.7-arm64.dmg). The release also includes a ZIP archive and SHA-256 manifest. Install at least one supported local Agent CLI before opening the app.
The preview artifacts are ad-hoc signed and not notarized with an Apple Developer ID. macOS may require **Open Anyway** under **System Settings → Privacy & Security** on first launch.
## Run from source
Prerequisites: Node.js `22.12+` and npm.
@@ -111,12 +142,6 @@ npm --prefix frontend run build:desktop
npm --prefix desktop test
```
## V1.0.7
V1.0.7 preserves streamed replies when Auto Discussion is interrupted, handles Hermes native streaming and ACP session boundaries, and prevents MiMo headless runs from waiting on interactive plan prompts. Zcode joins the catalog before Pi Agent, with its official logo and native default configuration. WorkBuddy can reuse a configured desktop model selected in Meldwork.
Electron acceptance covered interrupted six-Agent unlimited discussions with simulated adapters, persistence after reload, and a real WorkBuddy reply. CLI and UI regression tests cover output parsing, cancellation, authentication errors, and redaction. Native Agent behavior still depends on the CLI and its provider: Zcode's default provider needs a valid API key, and an OpenCode route returning an invalid `tool_calls` finish reason remains an upstream issue. Unlimited discussion does not bypass stalled-process detection.
## Documentation
- [Architecture and product boundary](architecture.md)
+42 -19
View File
@@ -2,10 +2,6 @@
<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 自主协作推进任务
**提出目标,让 Agent 们协作完成。Meldwork 把你已有的 Agent 组织成一个工作小组,共享上下文、讨论方案、协作执行并相互检查,减少你在它们之间转述、催办和整合结果的工作。**
@@ -15,13 +11,35 @@ Meldwork 是一个本地优先的多 Agent 协作工作空间,用来连接你
你无需安排每一轮发言,但仍负责目标、权限和关键决策。遇到信息缺失、权限请求或需要取舍的问题时,任务可能等待你处理。工作区写入需要明确授权;协作过程、运行状态和产物保存在本地,方便回看和继续工作。
<p align="center">
<a href="https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.7/Meldwork-1.0.7-arm64.dmg"><strong>下载 Meldwork V1.0.7 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>
## Meldwork 适合什么工作
[![在 YouTube 观看 Meldwork 产品宣传视频](assets/media/meldwork-handoff-preview.jpg)](https://youtu.be/ttyJxo1s9ns)
[在 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>
当一个任务需要多个 Agent 共同推进,而你不想反复传递上下文、协调下一步时,Meldwork 更适合:
- 研究与信息综合:结合本地 Agent 工具和明确选择的知识源;
@@ -75,10 +93,27 @@ Meldwork 在本地保留任务上下文、Agent 回复、运行状态和产物
<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 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 会优先使用该配置,并传递其中声明的凭据环境变量。
WorkBuddy 也可以在「设置 → Agent」中选择已有的原生配置目录;模型列表、执行环境以及本地和插件 Skill 都会使用所选目录。
OpenClaw 也可以在「设置 → Agent」中选择原生状态目录。Meldwork 会从该目录读取所选 Agent 的模型配置,并在受保护的本地运行环境中执行。
切换 Agent 的原生配置目录时,Meldwork 会按目录分别保存本地会话,切回原目录后恢复对应会话。任务运行中或等待恢复时,暂时不能切换配置。
Zcode 请选择包含 `config.json` 的 `.zcode/cli` 目录。运行时 Zcode 会使用该目录所属的用户主目录,这也会影响其会话及其他文件中的主目录相对路径;项目配置仍可能覆盖用户配置。
Qwen Code 请选择包含设置或认证文件的 `.qwen` 目录。Qwen 会使用其所属的用户主目录保存原生会话、读取 Skill 和其他主目录相对文件;项目和系统设置仍遵循原生优先级。
Hermes 使用自身的 `config.yaml` 和 Provider 设置。如果模型、备用模型或 Provider 条目通过 `key_env` 或 `api_key_env` 声明凭据变量,Meldwork 会从启动环境或登录 shell 中读取这些变量并交给 Hermes;无关变量和不安全的进程控制变量会被排除。首次使用新的原生数据目录启动时,Hermes 可能会自行准备依赖。
## 上下文与本地边界
@@ -86,12 +121,6 @@ Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
## 下载 V1.0.7
Apple 芯片 macOS 用户请前往 [Meldwork V1.0.7 预发布页](https://github.com/Ryder-Sun/Meldwork/releases/tag/Meldwork-V1.0.7),下载 [`Meldwork-1.0.7-arm64.dmg`](https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.7/Meldwork-1.0.7-arm64.dmg)。发布页同时提供 ZIP 压缩包和 SHA-256 校验文件。启动应用前,请至少安装一个受支持的本地 Agent CLI。
该预览版安装包使用 ad-hoc 临时签名,未使用 Apple Developer ID 签名,也未进行公证。macOS 首次启动时可能需要在“系统设置 → 隐私与安全性”中选择“仍要打开 / Open Anyway”。
## 从源码运行
前置条件:Node.js `22.12+` 和 npm。
@@ -111,12 +140,6 @@ npm --prefix frontend run build:desktop
npm --prefix desktop test
```
## V1.0.7
V1.0.7 修复自动讨论中断后流式回复丢失,适配 Hermes 原生流式输出与 ACP 会话边界,并避免 MiMo 无界面运行卡在交互式计划提示。新增 Zcode,使用官方标志、排列在 Pi Agent 前并沿用原生默认配置;WorkBuddy 可以复用在 Meldwork 中选定的桌面已配置模型。
Electron 验收覆盖模拟适配器下六 Agent 无限轮讨论、中断和重载后内容保留,以及真实 WorkBuddy 回复。CLI 与界面回归测试覆盖输出解析、取消、认证报错和脱敏。真实执行仍取决于 CLI 与服务商:Zcode 默认服务商需要有效 API Key,OpenCode 路由返回错误 `tool_calls` 结束原因的问题仍需上游修复。无限轮讨论不会关闭进程停滞检测。
## 文档
- [架构与产品边界](architecture.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 的行为,不代表第三方服务已经通过独立认证。
+27
View File
@@ -17,12 +17,15 @@ API Key 或任意 Shell 执行能力。
- 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 托管工具策略执行;无法
满足当前权限契约的 Agent 会安全失败。群聊级工作目录写入默认关闭,只有用户明确授权后才会
@@ -75,6 +78,25 @@ CLI 解锁;可在 Meldwork 中选择桌面配置里已有且已配置鉴权的
Meldwork 仅保存模型标识,运行时由主进程读取原生配置,不向渲染层返回密钥。
Zcode 沿用默认配置;若其默认 Provider 缺少 API Key,需要先修复本机原生配置。
Hermes 的原生配置目录设置会显示 `config.yaml` 中显式指定的 Provider(包括嵌套模型及旧版根级配置),或配置中没有指定时的
`HERMES_INFERENCE_PROVIDER`。显示 `auto` 表示交由 Hermes 自动选择,并不代表已经确认最终路由;
无法读取或没有明确配置时显示未指定。此摘要不返回凭据。
Codex 设置显示用户配置(含所选 profile)中的 `model_provider`;项目配置或运行参数仍可覆盖它,
因此该值不等同于每次调用的最终 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 |
@@ -87,6 +109,7 @@ Zcode 沿用默认配置;若其默认 Provider 缺少 API Key,需要先修
| 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 本机配置,按授权使用 `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 本机配置 |
@@ -128,6 +151,10 @@ npm run dev
## 测试与打包
任务图的 `primary` 节点可以设置可选的 `claimKey`:相同键的结论会进行冲突检查,不同键适合独立交付物。未设置时沿用共享结论的检查方式;该字段是工作流输入的一部分,不会自动推断任务含义。
当前默认的 V4 群聊尚不支持任务图。旧版 V3 任务图的审查节点可能把指出问题的回复判为已通过;在此问题修复前,请人工检查审查内容,不要仅依据节点完成状态判断结果。
```bash
npm test
npm run pack
+14 -8
View File
@@ -1,16 +1,19 @@
{
"name": "meldwork-desktop",
"version": "1.0.7",
"version": "1.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meldwork-desktop",
"version": "1.0.7",
"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"
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "meldwork-desktop",
"version": "1.0.7",
"version": "1.0.9",
"private": true,
"description": "A local-first desktop workspace for multi-Agent collaboration",
"author": "Ryder-MHumble",
@@ -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": {
@@ -39,6 +42,9 @@
"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/**/*",
+50 -11
View File
@@ -1,14 +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],
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],
@@ -24,6 +25,7 @@ const AGENT_EXECUTABLE_NAMES = Object.freeze({
pi: ['pi', 'pi-agent', 'piagent'],
kimi: ['kimi'],
mimo: ['mimo'],
deepseek: ['dsh'],
claude: ['claude'],
gemini: ['gemini'],
opencode: ['opencode'],
@@ -65,6 +67,11 @@ const AGENT_COMPATIBILITY = Object.freeze({
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'], [
@@ -77,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',
@@ -106,6 +116,16 @@ const AGENT_COMPATIBILITY = Object.freeze({
]),
})
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,
@@ -170,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
@@ -183,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))]
@@ -208,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 => (
@@ -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 }
@@ -8,6 +8,12 @@ function sha256(value) {
// 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`
@@ -19,7 +25,8 @@ const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
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 = options.find(candidate => candidate?.kind === 'reject_once')
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
}
@@ -69,6 +76,7 @@ function assertResumedPermissionBinding(resumed, currentBinding, sessionRef) {
}
module.exports = {
terminalPlanRejection,
READ_ONLY_SAFE_TOOL_KINDS,
assertResumedPermissionBinding,
automaticPermissionResponse,
+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)
@@ -96,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'],
@@ -243,6 +247,7 @@ function failureCategory(code) {
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
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'
@@ -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 }
+122 -33
View File
@@ -1,8 +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,
@@ -20,15 +23,21 @@ 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
// 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.
@@ -355,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 || ''}`)
))
@@ -469,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
@@ -484,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 {
@@ -528,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?.()
})
@@ -546,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')
@@ -621,12 +626,19 @@ function runtimeSignature(agent, workdir, spec, profile, childEnv) {
workdir,
command: spec.command,
args: spec.args,
// The session mode (read-only plan versus write) is applied on every turn
// 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')
}
@@ -746,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(
@@ -762,9 +784,37 @@ async function createAcpRuntime(input) {
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)
}
},
@@ -864,6 +914,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
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 = {
@@ -878,6 +930,9 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
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 = []
@@ -904,8 +959,10 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
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) {
@@ -915,7 +972,16 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
})).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(() => {
@@ -928,22 +994,25 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
try {
if (!protocolSessionRef) {
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
@@ -969,7 +1038,18 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
prompt: [{ type: 'text', text: prompt }, ...imageBlocks],
}))
promptRequest.catch(() => {})
promptResult = await operation(promptRequest)
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
}
@@ -998,7 +1078,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
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'
@@ -1038,7 +1118,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
ACP_CANCEL_SETTLE_MS,
)
}
if (caughtError && promptSettled && !runtime.closed && !runtime.failureError) {
if (caughtError && caughtError.code !== 'LOCAL_AGENT_REPEATED_OUTPUT'
&& promptSettled && !runtime.closed && !runtime.failureError) {
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
value: true, enumerable: false, configurable: true,
})
@@ -1114,6 +1195,10 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
throw error
} finally {
runtime.active = false
if (runtime.contributionNeedsClose) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
}
runtime.lastUsedAt = Date.now()
await evictIdleAcpRuntimes()
}
@@ -1151,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) {
+49 -26
View File
@@ -30,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,
@@ -45,8 +46,10 @@ 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
@@ -135,6 +138,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
: agent.acpAvailable
const spec = invocation(agent.kind, agent.executable, workdir, sessionRef, {
platform,
...(agent.kind === 'deepseek' ? { env: options.env } : {}),
sandbox: options.sandbox,
attachments: options.attachments,
hermesAcpAvailable,
@@ -226,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 })
@@ -264,6 +270,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
let forceSettleTimeout
let terminalKillTimer
let terminalForceKillTimer
let windowsTermination = null
const progress = []
const progressIndexes = new Map()
let anonymousProgressId = 0
@@ -296,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)
}
})
@@ -329,7 +342,9 @@ async function runAgent(agent, prompt, workdir, options = {}) {
const terminal = (
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|| (agent.kind === 'hermes' && spec.eventTransport === 'stream-json' && event.type === 'result')
|| (agent.kind === 'pi' && event.type === 'agent_end')
|| (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'
&& !stepFinishContinues(event))
@@ -350,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')
@@ -420,16 +431,21 @@ async function runAgent(agent, prompt, workdir, options = {}) {
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) {
@@ -451,6 +467,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
})
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'
@@ -458,7 +480,7 @@ 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, {
@@ -516,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()
})
@@ -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 }
+243 -97
View File
@@ -1,12 +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')
@@ -20,6 +28,7 @@ const AGENT_PROFILES = {
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'] },
@@ -30,6 +39,8 @@ 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',
@@ -42,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',
'CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR',
])
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 = 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()
@@ -103,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'
@@ -118,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'),
@@ -147,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',
)
@@ -192,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(
@@ -230,7 +276,7 @@ function launchEnvironment(kind, executable, options = {}) {
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)
@@ -264,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 */ }
@@ -354,6 +409,8 @@ async function probeAgentCapabilities(kind, executable, 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),
@@ -361,73 +418,140 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
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]
}
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,
stat.size, stat.mtimeMs, stat.ctimeMs, shimTargetIdentity, environmentFingerprint, nativeConfiguration,
])
} catch { /* caching requires a verifiable executable identity */ }
const cacheKey = `${kind}:${executable}`
const prior = capabilityCache.get(cacheKey)
const recentCompatible = identity && prior?.identity === identity
&& Date.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 result = await execFileFn(command.command, command.args, {
timeout,
windowsHide: true,
env: childEnv,
})
return { probe, output: `${result.stdout || ''}\n${result.stderr || ''}` }
} catch (error) {
const timedOut = error?.killed === true || error?.code === 'ETIMEDOUT'
if (timedOut && timeout === 8000) continue
return { probe, error: true, timedOut }
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: '' }
}
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,
}
}
}
if (group.latest === record && identity && !results.some(result => result.timedOut)) {
capabilityCache.set(cacheKey, { identity, checkedAt: Date.now() })
}
}
}))
const failed = results.find(result => !result.timedOut && (result.error
|| result.probe.requiredText.some(value => !result.output.includes(value))
|| (result.probe.validateOutput && !result.probe.validateOutput(result.output))))
if (failed) capabilityCache.delete(cacheKey)
for (const result of results) {
const { probe } = result
if (result.timedOut && !failed) {
if (recentCompatible) continue
return {
compatibilityState: 'unknown',
incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
incompatibilityProbe: probe.id,
compatibilityState: 'compatible',
incompatibilityReason: '',
incompatibilityProbe: '',
...(kind === 'hermes' && results.some(result => result.output?.includes('--format')
&& result.output.includes('stream-json')) ? { streamJsonAvailable: true } : {}),
}
}
if (result.timedOut) 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))) {
return {
compatibilityState: 'incompatible',
incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
incompatibilityProbe: probe.id,
}
}
}
if (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)
}
}
@@ -440,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)
@@ -448,17 +578,17 @@ 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 { /* capability probes can still establish whether this installation works */ }
/* Capability probes can still establish whether this installation works. */
}
return { succeeded: false, version: '', versionIdentified: false }
})()
const { version } = await versionTask
const { version, versionIdentified } = await versionTask
const versionCompatibility = assessAgentVersion(kind, version)
const capabilityTask = Promise.resolve().then(async () => {
const result = await (options.probeAgentCapabilitiesFn || probeAgentCapabilities)(
@@ -486,6 +616,8 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
const [capabilityCompatibility, acpAvailable] = await Promise.all([capabilityTask, acpTask])
const compatibility = capabilityCompatibility.compatibilityState !== 'compatible'
? { ...versionCompatibility, ...capabilityCompatibility }
: kind === 'pi' && !version
? { ...versionCompatibility, compatibilityState: 'unknown', incompatibilityProbe: '' }
: {
...versionCompatibility,
compatibilityState: 'compatible',
@@ -497,6 +629,7 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
name: `${AGENT_PROFILES[kind].label} CLI`,
executable,
version,
...(!versionIdentified ? { versionIdentified: false } : {}),
...compatibility,
...(kind === 'hermes' ? {
acpAvailable,
@@ -511,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)
}
@@ -540,6 +685,7 @@ module.exports = {
ALLOWED_KINDS,
detectAgents,
launchEnvironment,
npmShimTarget,
prepareCommand,
probeAgentCapabilities,
resolveExecutable,
+39 -11
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) {
@@ -546,6 +573,7 @@ const CONNECTOR_EVENT_PROFILES = Object.freeze({
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,
+27 -21
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]
@@ -74,6 +76,8 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
}
if (kind === 'hermes') {
if (options.hermesStreamJsonAvailable === true
&& (options.hermesAcpAvailable === false || options.invocationTransport === 'stream-json'
|| (sessionRef && options.sessionTransport !== 'acp'))
&& (!sessionRef || options.sessionTransport !== 'acp')) {
return {
command: executable,
@@ -193,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 (!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',
@@ -227,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,
@@ -276,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',
+16 -14
View File
@@ -4,6 +4,7 @@ const path = require('node:path')
const {
KILL_SETTLE_MS,
terminateWindowsProcessTree,
TERMINATE_GRACE_MS,
childEnvironment,
} = require('./cli-process-support.cjs')
@@ -63,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
@@ -78,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 {
@@ -163,6 +163,8 @@ async function verifyGatewayHealth({
gatewayPort,
signal,
workdir,
spawnHealth = spawn,
terminateHealth = terminateGateway,
}) {
if (!gatewayChildAlive(gatewayChild)) throw gatewayStartError()
const args = [
@@ -173,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,
@@ -206,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
@@ -314,4 +316,4 @@ async function withOpenClawGateway(options, callback) {
throw gatewayStartError()
}
module.exports = { withOpenClawGateway }
module.exports = { withOpenClawGateway, verifyGatewayHealth }
@@ -257,14 +257,16 @@ function ingestStructuredEvent(state, event) {
state.outcome = stepFinishOutcome(event.message?.stopReason || event.stopReason || 'stop')
}
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' && event.willRetry !== true) {
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)
return
}
@@ -428,7 +430,8 @@ function finalizeStructuredState(state, context = {}) {
}
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'
? stripPiPromptEcho(state.text, context.prompt)
@@ -864,6 +867,8 @@ function classifyCliOutcome(kind, stdout) {
}
if (kind === 'pi') {
const terminal = events.findLast(event => event?.type === 'turn_end' || event?.type === 'agent_end')
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
@@ -934,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 {
@@ -952,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 }
+144 -10
View File
@@ -1,6 +1,16 @@
const path = require('node:path')
const fs = require('node:fs')
const JSON5 = require('json5')
const { launchEnvironment, searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli-network-environment.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,
@@ -26,13 +36,64 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
'OPENCLAW_CONFIG_PATH',
'OPENCLAW_WORKSPACE_DIR',
])
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
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 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 || authFailureCertainty(text)
@@ -40,6 +101,8 @@ function invalidSessionFailure(detail) {
// 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.
@@ -52,6 +115,9 @@ function invalidSessionFailure(detail) {
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.')
}
@@ -66,6 +132,12 @@ function failedAgentProcessError(detail, options = {}) {
)
}
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
@@ -76,7 +148,7 @@ function openClawChildEnvironment(workdir, options, platform) {
}
const env = {
...systemChildEnvironment(process.env, platform),
...inheritedEnvironment(options, platform),
...networkEnvironment(source, platform),
}
for (const key of [
@@ -132,19 +204,80 @@ function childEnvironment(agent, workdir, options, platform) {
openCodeSafetyEnv.MIMOCODE_PERMISSION = JSON.stringify(headlessPermissions)
}
const source = { ...process.env, ...options.env }
const controlled = Object.fromEntries(Object.entries(options.env || {}).filter(([key, value]) => (
typeof value === 'string'
&& (CONTROLLED_RUNTIME_ENV.test(key) || NETWORK_ENV_KEYS.includes(key))
&& !BLOCKED_RUNTIME_ENV.test(key)
)))
return {
...systemChildEnvironment(process.env, platform),
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 = {
@@ -154,4 +287,5 @@ module.exports = {
childEnvironment,
failedAgentProcessError,
invalidSessionFailure,
terminateWindowsProcessTree,
}
@@ -425,13 +425,16 @@ function acpPlanSummary(update) {
}
function createAcpRuntimeState() {
return { tools: new Map(), reasoning: 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'
@@ -468,6 +471,8 @@ function acpRuntimeEvents(update, state) {
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
@@ -477,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',
@@ -35,11 +70,17 @@ function redactChildSecrets(value, env) {
}
function childSecrets(env) {
const secrets = new Set()
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 (/(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
&& utf8ByteLength(value) >= 8) secrets.add(value)
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 {
@@ -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 }
+155 -15
View File
@@ -1,23 +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
const root = env.XDG_CONFIG_HOME || path.join(options.home || os.homedir(), '.config')
try {
const file = path.join(root, 'mimocode', 'mimocode.json')
if (fs.statSync(file).size > 2 * 1024 * 1024) return false
const config = JSON.parse(fs.readFileSync(file, 'utf8'))
if (typeof config.model !== 'string' || !config.model.includes('/')) return false
const provider = config.provider?.[config.model.split('/')[0]]
let key = provider?.options?.apiKey
if (typeof key !== 'string' || !key.trim()) return false
const reference = key.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)
if (reference) key = env[reference[1]]
else if (/^\{.*\}$/.test(key)) return false
return typeof key === 'string' && Boolean(key.trim())
} catch { return false }
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())
})
}
module.exports = { mimoConfiguredProvider }
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 }
@@ -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 }
@@ -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 -37
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 }
}
@@ -638,6 +688,7 @@ class CloudAgentBridgeStore {
cloud: true,
available: record.available && agent.available !== false,
credentialState: agent.credentialState,
availabilitySource: agent.executionSupported === false ? 'cloud-bridge-unsupported' : undefined,
serverLabel: record.label,
})))
}
@@ -659,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',
@@ -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
}
@@ -633,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,
}
}
@@ -671,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') {
@@ -737,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) {
@@ -745,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)
}
}
@@ -810,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,
@@ -822,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
@@ -849,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 -3
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
}
+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 }
@@ -101,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',
@@ -120,6 +125,7 @@ const AGENT_CATALOG = Object.freeze([
{ 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 },
])
@@ -33,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) {
@@ -62,8 +67,8 @@ function publicCompatibility(agent) {
: Boolean(resolvedVersion)
const compatible = compatibilityState === 'compatible'
const incompatibilityReason = compatibilityState === 'unknown'
&& agent.incompatibilityReason === 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT'
? 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT'
&& INCONCLUSIVE_REASONS.has(agent.incompatibilityReason)
? agent.incompatibilityReason
: compatibilityState === 'incompatible'
? (COMPATIBILITY_REASONS.has(agent.incompatibilityReason)
? agent.incompatibilityReason
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,
+25 -9
View File
@@ -1,4 +1,5 @@
const fs = require('node:fs')
const { Readable } = require('node:stream')
const {
assertImagePixelLimit,
@@ -118,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,
@@ -151,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
@@ -187,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,
}
}
@@ -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
@@ -279,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)
@@ -300,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,
}
}
@@ -517,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,
@@ -531,6 +556,7 @@ function createWorkReceipt(input = {}) {
collaborationReceipt,
resultHash,
artifacts,
...(dependencyWorkReceiptIds ? { dependencyWorkReceiptIds } : {}),
}
return {
...body,
@@ -542,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')
}
@@ -706,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)
@@ -743,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)
@@ -792,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 } : {}),
@@ -1561,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
@@ -1580,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) {
@@ -1601,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')
}
@@ -1618,12 +1687,15 @@ function validateV4Relations(orchestration, targetKinds) {
|| orchestration.coordinationPlan?.finalizerKind
|| orchestration.commitState.writerKind
|| orchestration.discussionWriterKind
if (writableSlots.length > 1 || writableSlots.some(slot => (
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
|| !['discussion', 'synthesis', 'human-gate'].includes(orchestration.phase)
|| (orchestration.phase === 'discussion' && !orchestration.discussionWriterKind)
))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
for (const assignment of orchestration.phase === 'discussion' ? orchestration.plan.assignments : []) {
)))) 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')
@@ -2047,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')
}
@@ -2240,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)
@@ -2257,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)
+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,
}
+19 -7
View File
@@ -3,7 +3,7 @@ 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'].includes(key))
|| 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
@@ -24,10 +24,13 @@ function parseTaskDecision(input) {
|| (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 } : {}),
}
}
@@ -49,23 +52,31 @@ function agentKindToken(value) {
// 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 without a separate file) used to fail the whole turn
// 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
const status = STATUS_SYNONYMS.get(String(input.status || '').trim().toLowerCase())
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 reason = typeof input.reason === 'string' && input.reason.trim()
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.'
let deliverables = textList(input.deliverables)
// A completed answer is itself the deliverable when nothing else was named.
if (status === 'completed' && !deliverables.length) deliverables = ['The answer in this message']
const deliverables = textList(input.deliverables)
const nextKinds = [...new Set(textList(input.nextKinds).map(agentKindToken).filter(Boolean))]
const handoffTo = agentKindToken(input.handoffTo)
return {
@@ -78,6 +89,7 @@ function normalizeTaskDecision(input, options = {}) {
...(input.nextKinds != null
? { nextKinds: status === 'continue' ? nextKinds.slice(0, 32) : [] }
: {}),
...(input.reviewedVersion != null ? { reviewedVersion: input.reviewedVersion } : {}),
}
}
@@ -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,
+5 -2
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'))) {
+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`)
}
}
+311 -20
View File
@@ -3,6 +3,10 @@ const { app, BrowserWindow, dialog, ipcMain, Menu, Notification, protocol, shell
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')
@@ -28,6 +32,10 @@ const {
} = 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')
@@ -43,8 +51,17 @@ 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,
@@ -75,7 +92,7 @@ const { LocalKnowledgeConnectors } = require('./knowledge/local-knowledge-connec
const { nativeOpenClawOptions } = require('./agents/cli/openclaw-runtime.cjs')
const { migrateLegacyProductData } = require('./product-data-migration.cjs')
const LOCAL_AGENT_KINDS = new Set([
'codex', 'hermes', 'openclaw', 'workbuddy', 'zcode', '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
@@ -98,10 +115,12 @@ function recordMainProcessFault(kind, detail) {
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 customAgentStore = null
let agentConnectors = null
@@ -241,17 +260,179 @@ function openExternalUrl(value) {
async function nativeShellEnvironment(options = {}) {
const home = app.getPath('home')
const resolved = await resolveNativeShellEnvironment({ home, refresh: options.refresh === true })
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
})
}
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 result
}
function workspaceStoragePath(userData = app.getPath('userData')) {
@@ -341,15 +522,43 @@ 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 shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment()
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
@@ -366,9 +575,10 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
const nativeEnv = connectorCredentialIsolation || agent.kind === 'openclaw'
? {}
: {
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env),
...configDirectoryEnvironment(selectedNativeEnv),
...nativeCredentialEnvironment(agent.kind, selectedNativeEnv, nativeKeys),
...(agent.kind === 'workbuddy' ? workBuddyEnvironment({
env: shellEnvironment.env, home: shellEnvironment.home,
env: selectedNativeEnv, home: shellEnvironment.home,
modelId: workspace?.state.agentPreferences.workbuddy?.modelId,
}) : {}),
...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}),
@@ -378,10 +588,15 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
: {}
return runAgent(agent, prompt, workdir, {
...runOptions,
...(shellEnvironment?.unsetKeys ? { nativeUnsetKeys: shellEnvironment.unsetKeys } : {}),
nativeCredentialKeys: nativeKeys,
...injected,
env: {
...networkEnvironment(shellEnvironment?.env),
...nativeEnv, ...callerEnv, ...injected.env,
...nativeEnv, ...callerEnv,
...(connectorCredentialIsolation ? {} : nativeConfigEnvironment(agent.kind, workspace?.state.agentPreferences)),
...injected.env,
...connectorConfigEnv,
},
})
}
@@ -454,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'),
})
@@ -520,6 +736,7 @@ function createWorkspace() {
}),
outcomeStore,
agentFitMatrix: FROZEN_AGENT_FIT_MATRIX,
resolveProjectEnvironmentIdentities,
runLedger: cloudAgentRuntime.workspaceLedger(),
detectAgents: async () => {
await cloudAgentBridges?.refresh()
@@ -538,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,
@@ -546,15 +766,23 @@ 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),
isKnownAgent: kind => cloudAgentBridges.catalogEntries().some(agent => agent.kind === kind),
connectorRuntime: agentConnectors,
naturalAgentResponses: true,
runAgent: async (agent, prompt, workdir, options = {}) => {
@@ -618,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),
@@ -655,7 +884,7 @@ function initializeWorkspace() {
return next
}
function createWindow() {
function createWindow(startupError = workspaceStartupError) {
mainWindow = new BrowserWindow({
width: 1440,
height: 920,
@@ -713,8 +942,11 @@ function createWindow() {
mainWindow.on('closed', () => {
mainWindow = null
})
loadFrontend()
flushPendingOpenGroup()
if (startupError) showOfflinePage(startupError)
else {
loadFrontend()
flushPendingOpenGroup()
}
return mainWindow
}
@@ -749,9 +981,21 @@ 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, modelId: workspace?.state.agentPreferences.workbuddy?.modelId })
return workBuddySettings({ ...shell,
env: nativeConfigEnvironment('workbuddy', workspace?.state.agentPreferences, shell.env),
modelId: workspace?.state.agentPreferences.workbuddy?.modelId })
},
installer,
isShutdownStarted: () => shutdownStarted,
@@ -765,6 +1009,9 @@ function registerIpc() {
getSkillTrustStore: () => skillTrustStore,
refreshLocalAgentState,
registerTrustedHandle,
resolveConnectorEnvironment: async names => (await resolveNativeShellEnvironment({
home: app.getPath('home'), extraEnvKeys: names, refresh: true,
})).env,
skillCatalog,
})
}
@@ -777,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) {
@@ -790,6 +1044,7 @@ 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'),
@@ -812,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()
@@ -826,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>
+6
View File
@@ -156,6 +156,11 @@ if (isLocalDocument) Object.assign(desktopApi, {
return () => ipcRenderer.removeListener('local-agent-installer:changed', listener)
},
}),
localAgentNativeConfig: Object.freeze({
get: kind => ipcRenderer.invoke('local-agent-native-config:get', kind),
choose: kind => ipcRenderer.invoke('local-agent-native-config:choose', kind),
clear: kind => ipcRenderer.invoke('local-agent-native-config:clear', kind),
}),
localSkillTrust: Object.freeze({
list: () => ipcRenderer.invoke('local-skill-trust:list'),
revoke: bindingId => ipcRenderer.invoke('local-skill-trust:revoke', bindingId),
@@ -173,6 +178,7 @@ if (isLocalDocument) Object.assign(desktopApi, {
approve: packageId => ipcRenderer.invoke('local-agent-connector:approve', packageId),
install: packageId => ipcRenderer.invoke('local-agent-connector:install', packageId),
configure: input => ipcRenderer.invoke('local-agent-connector:configure', input),
configureFromEnvironment: input => ipcRenderer.invoke('local-agent-connector:configure-from-environment', input),
test: instanceId => ipcRenderer.invoke('local-agent-connector:test', instanceId),
disable: packageId => ipcRenderer.invoke('local-agent-connector:disable', packageId),
revoke: packageId => ipcRenderer.invoke('local-agent-connector:revoke', packageId),
+2
View File
@@ -186,6 +186,8 @@ function classifiedCategory(error, code, status) {
}
function retryability(category, error) {
if (error?.[Symbol.for('meldwork.connector-nonretryable')] === true) return 'never'
if (failureCode(error) === 'LOCAL_AGENT_PROVIDER_QUOTA_EXHAUSTED') return 'never'
if (category === 'session') return 'refresh_session'
if (category === 'authentication') return 'never'
if (category === 'compatibility') return 'never'
+8 -3
View File
@@ -35,6 +35,7 @@ function createInvocationWatchdog({
let paused = false
let established = false
let toolActive = false
const activeToolIds = new Set()
let closed = false
let timedOut = false
let timeoutError = null
@@ -111,12 +112,15 @@ function createInvocationWatchdog({
established = true
rearm()
},
toolStarted() {
toolStarted(toolCallId = '') {
if (toolCallId) activeToolIds.add(String(toolCallId))
toolActive = true
progress()
},
toolFinished() {
toolActive = false
toolFinished(toolCallId = '') {
if (toolCallId) activeToolIds.delete(String(toolCallId))
else activeToolIds.clear()
toolActive = activeToolIds.size > 0
progress()
},
// Waiting for a person (Human Gate) is not the Agent being quiet.
@@ -127,6 +131,7 @@ function createInvocationWatchdog({
resume() {
paused = false
lastProgressAt = clock.now()
lastSubstantiveAt = lastProgressAt
arm()
},
close() {
+85 -7
View File
@@ -4,6 +4,7 @@ const path = require('node:path')
const { hasValidStoredRecordShape } = require('./run-ledger-records.cjs')
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
const { isLegacyMultiWriter, recoverLegacyRecord } = require('./run-ledger-legacy.cjs')
const JOURNAL_VERSION = 1
const DEFAULT_COMPACTION_BYTES = 4 * 1024 * 1024
@@ -99,6 +100,9 @@ class RunJournal {
this.hasBaseline = false
this.runs = new Map()
this.pending = new Map()
this.legacyRunIds = new Set()
this.uncertainCompaction = null
this.uncertainAppend = null
this.load()
}
@@ -117,6 +121,14 @@ class RunJournal {
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('RUN_JOURNAL_STORE_INVALID')
const buffer = this.repairTruncatedTail(fs.readFileSync(this.storagePath))
const lines = buffer.toString('utf8').split('\n').filter(Boolean)
// Detect legacy run IDs across the complete journal before replaying
// partial patches, so none can restore a previously unsafe policy.
for (const line of lines) {
const entry = JSON.parse(line)
for (const run of entry?.change?.upserts || []) {
if (isLegacyMultiWriter(run)) this.legacyRunIds.add(run.runId)
}
}
const committed = new Set()
for (const line of lines) {
const entry = JSON.parse(line)
@@ -130,6 +142,11 @@ class RunJournal {
}
this.sequence = entry.sequence
if (entry.phase === 'prepare') {
if (entry.change && Array.isArray(entry.change.upserts)) {
entry.change.upserts = entry.change.upserts.map(run => (
this.legacyRunIds.has(run?.runId) ? recoverLegacyRecord(run) : run
))
}
if (!validChange(entry.change) || this.pending.has(entry.transactionId)
|| committed.has(entry.transactionId)) {
throw new Error('RUN_JOURNAL_STORE_INVALID')
@@ -154,6 +171,41 @@ class RunJournal {
}
assertLoaded() {
if (this.loadError && this.uncertainAppend) {
const { originalSize, dev, ino, appended } = this.uncertainAppend
let fd
try {
fd = fs.openSync(this.storagePath, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0))
const stat = fs.fstatSync(fd)
if (!stat.isFile() || stat.dev !== dev || stat.ino !== ino
|| stat.size < originalSize || stat.size > originalSize + appended.length) {
throw new Error('RUN_JOURNAL_STORE_INVALID')
}
const tail = Buffer.alloc(stat.size - originalSize)
if (tail.length && (fs.readSync(fd, tail, 0, tail.length, originalSize) !== tail.length
|| !tail.equals(appended.subarray(0, tail.length)))) {
throw new Error('RUN_JOURNAL_STORE_INVALID')
}
fs.ftruncateSync(fd, originalSize)
fs.fsyncSync(fd)
this.uncertainAppend = null
this.loadError = null
} catch (error) { this.loadError = error }
finally { if (fd !== undefined) fs.closeSync(fd) }
}
if (this.loadError && this.uncertainCompaction) {
const { serialized, change } = this.uncertainCompaction
try {
atomicWritePrivateFile(this.storagePath, serialized)
this.sequence = 2
this.runs.clear()
this.pending.clear()
this.hasBaseline = false
this.apply(change)
this.uncertainCompaction = null
this.loadError = null
} catch (error) { this.loadError = error }
}
if (this.loadError) throw new Error('RUN_JOURNAL_LOAD_FAILED')
}
@@ -173,20 +225,28 @@ class RunJournal {
const directory = path.dirname(this.storagePath)
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
let originalSize = 0
if (fs.existsSync(this.storagePath)) {
let originalIdentity = null
const existed = fs.existsSync(this.storagePath)
if (existed) {
const stat = fs.lstatSync(this.storagePath)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('RUN_JOURNAL_WRITE_FAILED')
originalSize = stat.size
originalIdentity = { dev: stat.dev, ino: stat.ino }
}
const next = {
version: JOURNAL_VERSION,
sequence: this.sequence + 1,
...entry,
}
const appended = Buffer.from(`${JSON.stringify(next)}\n`, 'utf8')
let descriptor
try {
descriptor = fs.openSync(this.storagePath, 'a', 0o600)
fs.writeFileSync(descriptor, `${JSON.stringify(next)}\n`, 'utf8')
if (!originalIdentity) {
const stat = fs.fstatSync(descriptor)
originalIdentity = { dev: stat.dev, ino: stat.ino }
}
fs.writeFileSync(descriptor, appended)
fs.fsyncSync(descriptor)
fs.closeSync(descriptor)
descriptor = undefined
@@ -197,7 +257,16 @@ class RunJournal {
if (descriptor !== undefined) {
try { fs.closeSync(descriptor) } catch { /* already closed */ }
}
try { fs.truncateSync(this.storagePath, originalSize) } catch { /* append did not create a file */ }
try {
fs.truncateSync(this.storagePath, originalSize)
const rollback = fs.openSync(this.storagePath, 'r+')
try { fs.fsyncSync(rollback) } finally { fs.closeSync(rollback) }
} catch (rollbackError) {
if (existed || rollbackError.code !== 'ENOENT') {
this.loadError = rollbackError
if (originalIdentity) this.uncertainAppend = { originalSize, ...originalIdentity, appended }
}
}
throw error
}
}
@@ -262,10 +331,19 @@ class RunJournal {
phase: 'commit',
},
]
atomicWritePrivateFile(
this.storagePath,
`${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`,
)
const serialized = `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`
try {
atomicWritePrivateFile(
this.storagePath,
serialized,
)
} catch (error) {
// The rename may already have succeeded even when the parent sync failed.
// Do not append using the old sequence until this baseline is durable again.
this.loadError = error
this.uncertainCompaction = { serialized, change }
throw error
}
this.sequence = 2
this.runs.clear()
this.pending.clear()
+36
View File
@@ -0,0 +1,36 @@
const { parseOrchestrationV4 } = require('../collaboration/orchestration-v4-records.cjs')
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
// Older discussion runs admitted multiple writers. Never resume that policy.
function isLegacyMultiWriter(record) {
const value = record?.orchestration
if (value?.version !== 4 || value.workflow !== 'auto' || value.template !== 'discussion'
|| !Array.isArray(value.slots)
|| value.slots.filter(slot => slot?.permission === 'workspace-write').length < 2) return false
try { parseOrchestrationV4(value); return false } catch (error) {
return error?.message === 'ORCHESTRATION_V4_PERMISSION_INVALID'
}
}
function recoverLegacyRecord(record) {
const result = structuredClone(record)
result.orchestration = null
result.continuation = null
if (['preparing', 'queued', 'running', 'waiting', 'reconciling'].includes(result.status)) {
result.status = 'interrupted'
result.reason = 'Legacy discussion requires a new run.'
}
for (const agent of result.agentRuns || []) {
const decision = agent?.context?.taskDecision
if (!decision) continue
try { parseTaskDecision(decision) } catch {
if (Object.keys(decision).sort().join(',') !== 'deliverables,reason,reviewedVersion,status'
|| typeof decision.status !== 'string' || typeof decision.reason !== 'string'
|| typeof decision.reviewedVersion !== 'string' || !Array.isArray(decision.deliverables)) throw new Error('RUN_LEDGER_STORE_INVALID')
delete agent.context.taskDecision
}
}
return result
}
module.exports = { isLegacyMultiWriter, recoverLegacyRecord }
+71
View File
@@ -1,7 +1,10 @@
const fs = require('node:fs')
const { randomBytes } = require('node:crypto')
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
const { RunJournal } = require('./run-journal.cjs')
const { RunTerminalOutbox } = require('./run-terminal-outbox.cjs')
const { isLegacyMultiWriter, recoverLegacyRecord } = require('./run-ledger-legacy.cjs')
const {
DEFAULT_MAX_DURABLE_AGENT_RUNS,
RUN_STATUSES,
@@ -170,6 +173,69 @@ class RunLedger {
compactionBytes: options.journalCompactionBytes,
})
this.runs = this.load()
this.terminalOutbox = new RunTerminalOutbox(this.storagePath)
if (!this.loadError) {
try { this.recoverTerminalOutbox() } catch (error) { this.loadError = error }
}
if (!this.loadError && this.journal.legacyRunIds.size) this.backupLegacyFiles()
}
recordTerminalIntent(record) {
this.assertLoaded()
const existing = this.runs.find(item => item.runId === record?.runId)
if (!existing || !TERMINAL_STATUSES.has(record.status)
|| record.groupId !== existing.groupId || record.taskId !== existing.taskId
|| record.contextPackId !== existing.contextPackId) {
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
}
return this.terminalOutbox.prepare(record)
}
clearTerminalIntent(runId) {
this.terminalOutbox.remove(runId)
}
recoverTerminalOutbox() {
for (const record of this.terminalOutbox.list()) {
const existing = this.runs.find(item => item.runId === record.runId)
// An already committed terminal run can later be removed by history
// pruning or group deletion while an outbox cleanup is still pending.
if (!existing) {
this.terminalOutbox.remove(record.runId)
continue
}
if (existing.groupId !== record.groupId || existing.taskId !== record.taskId
|| existing.contextPackId !== record.contextPackId) {
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
}
if (TERMINAL_STATUSES.has(existing.status)) {
if (existing.status !== record.status) throw new Error('RUN_TERMINAL_OUTBOX_CONFLICT')
} else {
this.checkpoint(record)
this.finish(record.runId, record.status, record.reason || '')
}
this.terminalOutbox.remove(record.runId)
}
}
backupLegacyFiles() {
try {
const suffix = `.legacy-backup-${Date.now()}-${randomBytes(6).toString('hex')}`
for (const filename of [this.storagePath, this.journalPath]) {
if (!fs.existsSync(filename)) continue
const target = `${filename}${suffix}`
const descriptor = fs.openSync(target, 'wx', 0o600)
try {
fs.writeFileSync(descriptor, fs.readFileSync(filename))
fs.fsyncSync(descriptor)
} finally {
fs.closeSync(descriptor)
}
}
} catch (error) {
this.loadError = error
this.runs = []
}
}
timestamp() {
@@ -183,6 +249,11 @@ class RunLedger {
if (!isRecord(parsed) || parsed.version !== STORE_VERSION || !Array.isArray(parsed.runs)) {
throw new Error('RUN_LEDGER_STORE_INVALID')
}
for (const record of parsed.runs) {
if (isLegacyMultiWriter(record)) this.journal.legacyRunIds.add(record.runId)
}
parsed.runs = parsed.runs.map(record => this.journal.legacyRunIds.has(record.runId)
? recoverLegacyRecord(record) : record)
const runIds = new Set()
const agentRunIds = new Set()
for (const rawRecord of parsed.runs) {
+9 -1
View File
@@ -231,10 +231,18 @@ class RunScheduler {
}
},
})
let releaseDeferred = false
try {
return await operation(lease)
} catch (error) {
const pendingExit = error?.[Symbol.for('meldwork.pending-process-exit')]
if (error?.code === 'LOCAL_AGENT_TERMINATION_FAILED' && pendingExit instanceof Promise) {
releaseDeferred = true
pendingExit.then(() => lease.release(), () => {})
}
throw error
} finally {
lease.release()
if (!releaseDeferred) lease.release()
}
}
+99
View File
@@ -0,0 +1,99 @@
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { atomicWritePrivateFile, ensurePrivateDirectory } = require('../security/private-file.cjs')
const { TERMINAL_STATUSES, cleanId, isRecord } = require('./run-ledger-records.cjs')
function syncDirectory(directory) {
let fd
try {
fd = fs.openSync(directory, 'r')
fs.fsyncSync(fd)
} catch (error) {
const unsupported = ['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)
|| (process.platform === 'win32' && ['EPERM', 'EISDIR'].includes(error.code))
if (!unsupported) throw error
} finally {
if (fd !== undefined) fs.closeSync(fd)
}
}
class RunTerminalOutbox {
constructor(storagePath) {
this.root = `${storagePath}.terminal-outbox`
}
filename(runId) {
if (!cleanId(runId) || cleanId(runId) !== runId) throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
return path.join(this.root, `${crypto.createHash('sha256').update(runId).digest('hex')}.json`)
}
read(filename) {
const stat = fs.lstatSync(filename)
if (stat.isSymbolicLink() || !stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024 * 1024
|| (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600)) {
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
}
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0)
const fd = fs.openSync(filename, fs.constants.O_RDONLY | noFollow)
try {
const opened = fs.fstatSync(fd)
if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size
|| (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600)) {
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
}
const data = JSON.parse(fs.readFileSync(fd, 'utf8'))
if (!isRecord(data) || Object.keys(data).sort().join(',') !== 'record,version'
|| data.version !== 1 || !isRecord(data.record)
|| !TERMINAL_STATUSES.has(data.record.status)
|| this.filename(data.record.runId) !== filename) throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
return data.record
} finally { fs.closeSync(fd) }
}
prepare(record) {
if (!isRecord(record) || !TERMINAL_STATUSES.has(record.status)) throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
ensurePrivateDirectory(this.root)
syncDirectory(path.dirname(this.root))
const filename = this.filename(record.runId)
if (fs.existsSync(filename)) {
const previous = this.read(filename)
if (previous.status !== record.status || previous.groupId !== record.groupId
|| previous.taskId !== record.taskId || previous.contextPackId !== record.contextPackId) {
throw new Error('RUN_TERMINAL_OUTBOX_CONFLICT')
}
return previous
}
const serialized = `${JSON.stringify({ version: 1, record })}\n`
if (Buffer.byteLength(serialized) > 64 * 1024 * 1024) throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
atomicWritePrivateFile(filename, serialized)
return record
}
list() {
try { fs.lstatSync(this.root) } catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
ensurePrivateDirectory(this.root)
const names = fs.readdirSync(this.root).filter(name => name.endsWith('.json'))
if (names.some(name => !/^[a-f0-9]{64}\.json$/.test(name))) {
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
}
return names.sort().map(name => this.read(path.join(this.root, name)))
}
remove(runId) {
try { fs.lstatSync(this.root) } catch (error) {
if (error.code === 'ENOENT') return
throw error
}
ensurePrivateDirectory(this.root)
try { fs.unlinkSync(this.filename(runId)) } catch (error) {
if (error.code !== 'ENOENT') throw error
}
syncDirectory(this.root)
}
}
module.exports = { RunTerminalOutbox }
+9
View File
@@ -0,0 +1,9 @@
const SESSION_REF = /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,255}$/
const SECRET_LIKE_SESSION_REF = /^(?:sk|rk|pk|ghp|github_pat|xox[baprs]?)[_-][A-Za-z0-9_-]{12,}$/i
function normalizeSessionRef(value) {
const sessionRef = String(value || '')
return SESSION_REF.test(sessionRef) && !SECRET_LIKE_SESSION_REF.test(sessionRef) ? sessionRef : ''
}
module.exports = { normalizeSessionRef }
+29 -1
View File
@@ -2,6 +2,23 @@ const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
function ensurePrivateDirectory(directory) {
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
const stat = fs.lstatSync(directory)
if (stat.isSymbolicLink() || !stat.isDirectory()) {
throw new Error('PRIVATE_DIRECTORY_INVALID')
}
if (process.platform === 'win32') return
const flags = fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0)
const descriptor = fs.openSync(directory, flags)
try {
if (!fs.fstatSync(descriptor).isDirectory()) throw new Error('PRIVATE_DIRECTORY_INVALID')
fs.fchmodSync(descriptor, 0o700)
} finally {
fs.closeSync(descriptor)
}
}
function atomicWritePrivateFile(filename, contents) {
const directory = path.dirname(filename)
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
@@ -18,6 +35,17 @@ function atomicWritePrivateFile(filename, contents) {
descriptor = undefined
fs.chmodSync(temporaryPath, 0o600)
fs.renameSync(temporaryPath, filename)
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 (descriptor !== undefined) {
try { fs.closeSync(descriptor) } catch { /* already closed */ }
@@ -27,4 +55,4 @@ function atomicWritePrivateFile(filename, contents) {
}
}
module.exports = { atomicWritePrivateFile }
module.exports = { atomicWritePrivateFile, ensurePrivateDirectory }
+165 -5
View File
@@ -1,8 +1,14 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { ADOPTION_ACTION_STATUSES } = require('../collaboration/outcome-records.cjs')
const { statusForHumanGateOption } = require('../gates/human-gate-coordinator.cjs')
const { ATTACHMENT_FILE_EXTENSIONS } = require('../attachments/attachment-records.cjs')
const { registerDeliveryIpc } = require('./delivery-ipc.cjs')
const { loadedWorkPreferences, parseWorkPreferences } = require('../workspace/work-preferences.cjs')
const { NATIVE_CONFIG_DIRECTORIES, validateNativeConfigDirectory } = require('../agents/native-config-directory.cjs')
const { nativeSecretEnvKeys } = require('../agents/cli/native-secret-environment.cjs')
const { switchNativeSessionProfile } = require('../workspace/native-session-profiles.cjs')
const LOCAL_GROUP_IDENTIFIER = /^[^\u0000-\u001f\u007f]{1,100}$/u
const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
@@ -178,6 +184,7 @@ function registerDesktopIpc(options) {
getCloudAgentBridges,
getKnowledgeConnectors,
getMainWindow,
getNativeConfigSummary,
getOutcomeStore,
getSkillTrustStore,
getWorkspace,
@@ -191,6 +198,7 @@ function registerDesktopIpc(options) {
openExternalUrl,
refreshLocalAgentState,
registerTrustedHandle,
resolveConnectorEnvironment,
skillCatalog,
} = options
@@ -298,7 +306,7 @@ function registerDesktopIpc(options) {
const optionId = typeof decision?.optionId === 'string' ? decision.optionId : ''
const hasResponse = Object.hasOwn(decision || {}, 'response')
const response = hasResponse && typeof decision.response === 'string'
? decision.response.trim()
? decision.response
: ''
const hasStatus = Object.hasOwn(decision || {}, 'status')
const requestedStatus = hasStatus ? decision.status : ''
@@ -306,7 +314,7 @@ function registerDesktopIpc(options) {
|| !HUMAN_GATE_OPTION_IDENTIFIER.test(optionId)
|| !isPlainObject(decision)
|| (hasStatus && !['approved', 'rejected'].includes(requestedStatus))
|| (hasResponse && (!response || response.length > 32 * 1024))
|| (hasResponse && (!response.trim() || response.length > 32 * 1024))
|| Reflect.ownKeys(decision).some(key => !['status', 'optionId', 'response'].includes(key))) {
throw new Error('HUMAN_GATE_DECISION_INVALID')
}
@@ -421,6 +429,93 @@ function registerDesktopIpc(options) {
})
registerTrustedHandle('local-workspace:default-directory', () => app.getPath('documents'))
registerTrustedHandle('local-agent-installer:catalog', async () => localAgentCatalog())
registerTrustedHandle('local-agent-native-config:get', async (...args) => {
if (args.length !== 1 || typeof args[0] !== 'string' || !Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, args[0])) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
const workspace = getWorkspace()
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
const directory = workspace.state.agentPreferences[args[0]]?.nativeConfigDirectory || ''
const summary = typeof getNativeConfigSummary === 'function' ? await getNativeConfigSummary(args[0], workspace) : null
if (getWorkspace() !== workspace || (workspace.state.agentPreferences[args[0]]?.nativeConfigDirectory || '') !== directory) {
throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
}
let directoryMissing = false
let configurationFiles = null
if (directory) {
try {
validateNativeConfigDirectory(args[0], directory)
const spec = NATIVE_CONFIG_DIRECTORIES[args[0]]
const entries = [...spec.files.map(name => [name, false]), ...(spec.directories || []).map(name => [name, true])]
const present = []
const missing = []
for (const [name, isDirectory] of entries) {
let exists = false
try {
const stat = fs.statSync(path.join(directory, name))
exists = isDirectory ? stat.isDirectory() : stat.isFile()
} catch { /* The entry is absent or unreadable. */ }
if (exists) present.push(isDirectory ? `${name}/` : name)
else missing.push(isDirectory ? `${name}/` : name)
}
configurationFiles = { present, missing }
if (!present.length) directoryMissing = true
} catch { directoryMissing = true }
}
return { directory, ...(directoryMissing ? { directoryMissing: true } : {}),
...(configurationFiles ? { configurationFiles } : {}),
...(summary && Object.hasOwn(summary, 'provider') ? { provider: summary.provider || '' } : {}),
...(summary && Object.hasOwn(summary, 'authType') ? { authType: summary.authType || '' } : {}) }
})
for (const action of ['choose', 'clear']) {
registerTrustedHandle(`local-agent-native-config:${action}`, async (...args) => {
if (args.length !== 1 || typeof args[0] !== 'string' || !Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, args[0])) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
const kind = args[0]
const workspace = getWorkspace()
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
if (workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
const originalDirectory = workspace.state.agentPreferences[kind]?.nativeConfigDirectory || ''
let directory = ''
if (action === 'choose') {
const selected = await dialog.showOpenDialog(getMainWindow(), { properties: ['openDirectory'] })
if (selected.canceled) return { canceled: true }
directory = validateNativeConfigDirectory(kind, selected.filePaths[0])
}
if (getWorkspace() !== workspace || workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
if ((workspace.state.agentPreferences[kind]?.nativeConfigDirectory || '') !== originalDirectory) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
if (directory !== originalDirectory) {
try {
workspace.runLedger?.assertLoaded?.()
const records = workspace.runLedger?.list?.() || []
if (workspace.pendingV4AutoRecoveries?.size || records.some(record =>
['preparing', 'queued', 'running', 'waiting', 'reconciling'].includes(record.status)
|| ['pending', 'ready', 'resuming'].includes(record.continuation?.state))) {
throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
}
} catch { throw new Error('LOCAL_WORK_PREFERENCES_BUSY') }
}
const previous = workspace.state.agentPreferences[kind]
const previousRuntime = workspace.state.agentRuntime[kind]
const previousSessions = workspace.state.sessions
const previousSessionMeta = workspace.state.sessionMeta
const previousProfiles = workspace.state.nativeSessionProfiles
switchNativeSessionProfile(workspace.state, kind, originalDirectory, directory)
workspace.state.agentPreferences[kind] = { ...previous, nativeConfigDirectory: directory }
delete workspace.state.agentRuntime[kind]
try { workspace.save() } catch (error) {
if (previous) workspace.state.agentPreferences[kind] = previous
else delete workspace.state.agentPreferences[kind]
if (previousRuntime) workspace.state.agentRuntime[kind] = previousRuntime
workspace.state.sessions = previousSessions
workspace.state.sessionMeta = previousSessionMeta
if (previousProfiles === undefined) delete workspace.state.nativeSessionProfiles
else workspace.state.nativeSessionProfiles = previousProfiles
throw error
}
installer.invalidateDetectionCache()
skillCatalog?.invalidate(kind)
await refreshLocalAgentState()
return { canceled: false, directory }
})
}
registerTrustedHandle('local-workbuddy:settings', () => getWorkBuddySettings())
registerTrustedHandle('local-work-preferences:get', () => loadedWorkPreferences(getWorkspace()?.state.workPreferences))
registerTrustedHandle('local-work-preferences:save', (...args) => {
@@ -577,6 +672,7 @@ function registerDesktopIpc(options) {
const connectors = getAgentConnectors?.()
if (!connectors) throw new Error('AGENT_CONNECTOR_RUNTIME_UNAVAILABLE')
const inspected = connectors.inspectPackage(args[0])
const auditEventId = connectors.auditPackage(args[0]).at(-1)?.eventId
const manifest = inspected.manifest
const locale = String(app.getLocale?.() || '').toLowerCase()
const slots = manifest.credentials.slots.map(slot => (
@@ -622,6 +718,10 @@ function registerDesktopIpc(options) {
noLink: true,
})
if (decision.response !== 0) return { canceled: true }
if (!auditEventId || getAgentConnectors?.() !== connectors
|| connectors.auditPackage(args[0]).at(-1)?.eventId !== auditEventId) {
throw new Error('AGENT_CONNECTOR_PACKAGE_CHANGED')
}
return { canceled: false, package: connectors.approvePackage(args[0]) }
})
for (const [channel, method] of [
@@ -645,7 +745,8 @@ function registerDesktopIpc(options) {
registerTrustedHandle('local-agent-connector:configure', async (...args) => {
if (args.length !== 1 || !args[0] || typeof args[0] !== 'object'
|| Array.isArray(args[0])
|| Reflect.ownKeys(args[0]).sort().join(',') !== 'credentials,label,manifestId') {
|| Reflect.ownKeys(args[0]).sort().join(',') !== 'credentials,label,manifestId'
|| args[0].credentials !== null) {
throw new Error('AGENT_CONNECTOR_REQUEST_INVALID')
}
const connectors = getAgentConnectors?.()
@@ -655,11 +756,65 @@ function registerDesktopIpc(options) {
const result = connectors.configure({
manifestId: input.manifestId,
label: input.label,
credentials: input.credentials === null ? null : input.credentials,
credentials: null,
})
await refreshLocalAgentState()
return result
})
registerTrustedHandle('local-agent-connector:configure-from-environment', async (...args) => {
if (args.length !== 1 || !isPlainObject(args[0])
|| Reflect.ownKeys(args[0]).sort().join(',') !== 'environmentKeys,label,packageId'
|| typeof args[0].packageId !== 'string' || typeof args[0].label !== 'string'
|| !isPlainObject(args[0].environmentKeys)) throw new Error('AGENT_CONNECTOR_REQUEST_INVALID')
const connectors = getAgentConnectors?.()
if (!connectors || typeof resolveConnectorEnvironment !== 'function') throw new Error('AGENT_CONNECTOR_RUNTIME_UNAVAILABLE')
const inspected = connectors.inspectPackage(args[0].packageId)
if (inspected.state !== 'installed') throw new Error('AGENT_CONNECTOR_PACKAGE_NOT_INSTALLED')
const auditEventId = connectors.auditPackage(args[0].packageId).at(-1)?.eventId
const assertPackageUnchanged = () => {
try {
if (auditEventId && getAgentConnectors?.() === connectors
&& connectors.auditPackage(args[0].packageId).at(-1)?.eventId === auditEventId
&& connectors.inspectPackage(args[0].packageId).state === 'installed') return
} catch { /* A removed or unreadable package is no longer authorized. */ }
throw new Error('AGENT_CONNECTOR_PACKAGE_CHANGED')
}
const slots = inspected.manifest.credentials.slots
const entries = Object.entries(args[0].environmentKeys)
if (!slots.length || entries.length > slots.length
|| entries.some(([slot, name]) => !slots.some(item => item.slotId === slot)
|| typeof name !== 'string' || nativeSecretEnvKeys([name])[0] !== name)
|| slots.some(slot => slot.required && !Object.hasOwn(args[0].environmentKeys, slot.slotId))) {
throw new Error('AGENT_CONNECTOR_CREDENTIAL_INVALID')
}
const locale = String(app.getLocale?.() || 'en').toLowerCase()
const names = [...new Set(entries.map(([, name]) => name))]
const decision = await dialog.showMessageBox(getMainWindow(), {
type: 'warning',
title: locale.startsWith('zh') ? '配置 Connector 凭据' : 'Configure Connector credentials',
message: locale.startsWith('zh') ? '允许此 Connector 使用以下环境变量?' : 'Allow this Connector to use these environment variables?',
detail: `${inspected.manifest.label}\n${entries.map(([slot, name]) => `${slot}: ${name}`).join('\n')}\n${inspected.manifest.outboundDestinations.join(', ')}`,
buttons: locale.startsWith('zh') ? ['允许', '取消'] : ['Allow', 'Cancel'],
defaultId: 1, cancelId: 1, noLink: true,
})
if (decision.response !== 0) return { canceled: true }
if (isShutdownStarted()) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN')
assertPackageUnchanged()
const env = await resolveConnectorEnvironment(names)
if (isShutdownStarted()) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN')
assertPackageUnchanged()
const credentials = Object.fromEntries(entries.map(([slot, name]) => [slot, env?.[name]]))
if (Object.values(credentials).some(value => typeof value !== 'string' || !value.trim())) {
throw new Error('AGENT_CONNECTOR_CREDENTIAL_ENV_MISSING')
}
const detectedAgents = await installer.detectedAgents()
if (isShutdownStarted()) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN')
assertPackageUnchanged()
connectors.refresh(detectedAgents)
const instance = connectors.configure({ manifestId: inspected.manifest.manifestId, label: args[0].label, credentials })
await refreshLocalAgentState()
return { canceled: false, instance }
})
registerTrustedHandle('local-agent-connector:delete', async (...args) => {
if (args.length !== 1 || typeof args[0] !== 'string') {
throw new Error('AGENT_CONNECTOR_REQUEST_INVALID')
@@ -683,7 +838,12 @@ function registerDesktopIpc(options) {
const connectors = getAgentConnectors?.()
if (!connectors) throw new Error('AGENT_CONNECTOR_RUNTIME_UNAVAILABLE')
connectors.refresh(await installer.detectedAgents())
return connectors.test(args[0], app.getPath('userData'))
const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'meldwork-connector-test-'))
try {
return await connectors.test(args[0], workdir)
} finally {
await fs.promises.rm(workdir, { recursive: true, force: true })
}
})
registerTrustedHandle('local-attachments:pick', async (remainingCapacity) => {
attachments.availableStore()
+45 -16
View File
@@ -19,6 +19,7 @@ const AGENT_SKILL_ROOTS = Object.freeze({
['.agents', 'skills'],
],
hermes: [['.hermes', 'skills']],
pi: [['.pi', 'agent', 'skills']],
openclaw: [
['.openclaw', 'workspace', 'skills'],
['.openclaw', 'skills'],
@@ -28,6 +29,10 @@ const AGENT_SKILL_ROOTS = Object.freeze({
['.kimi-code', 'skills'],
['.kimi', 'skills'],
],
deepseek: [
['.dsh', 'skills'],
['.agents', 'skills'],
],
claude: [
['.claude', 'skills'],
['.agents', 'skills'],
@@ -159,12 +164,32 @@ function directoryEntryKind(entry, filename) {
return ''
}
function globalRoots(kind, home) {
return (AGENT_SKILL_ROOTS[kind] || []).map(parts => ({
directory: path.join(home, ...parts),
boundary: path.join(home, ...parts),
namespace: 'global',
}))
function globalRoots(kind, home, configDirectory = '', sharedDirectory = '') {
const roots = (AGENT_SKILL_ROOTS[kind] || []).map(parts => {
if (kind === 'deepseek' && parts[0] === '.agents' && sharedDirectory) {
const directory = path.join(sharedDirectory, ...parts.slice(1))
return { directory, boundary: directory, namespace: 'global' }
}
const nativeParts = kind === 'pi' && parts[0] === '.pi' && parts[1] === 'agent'
? parts.slice(2)
: (['codex', 'hermes', 'claude', 'workbuddy'].includes(kind) && parts[0] === `.${kind}`)
|| (kind === 'deepseek' && parts[0] === '.dsh')
|| (kind === 'kimi' && parts[0] === '.kimi-code') ? parts.slice(1) : null
const directory = sharedDirectory && kind === 'opencode'
? path.join(sharedDirectory, ...parts.slice(1))
: configDirectory && ['gemini', 'qwen'].includes(kind)
? path.join(configDirectory, ...parts)
: configDirectory && nativeParts
? path.join(configDirectory, ...nativeParts) : path.join(home, ...parts)
return { directory, boundary: directory, namespace: 'global' }
})
if (kind === 'opencode' && configDirectory) {
for (const name of ['skills', 'skill']) {
const directory = path.join(configDirectory, name)
roots.push({ directory, boundary: directory, namespace: 'global' })
}
}
return roots
}
function safePluginRoot(installLocation, ...parts) {
@@ -176,8 +201,8 @@ function safePluginRoot(installLocation, ...parts) {
return candidate
}
function workBuddyPluginRoots(home) {
const workBuddy = path.join(home, '.workbuddy')
function workBuddyPluginRoots(home, configDirectory = '') {
const workBuddy = configDirectory || path.join(home, '.workbuddy')
const settings = readJson(path.join(workBuddy, 'settings.json'))
const marketplaces = readJson(path.join(workBuddy, 'plugins', 'known_marketplaces.json'))
const enabled = settings?.enabledPlugins
@@ -207,8 +232,8 @@ function workBuddyPluginRoots(home) {
return roots
}
function codexPluginRoots(home) {
const cacheRoot = path.join(home, '.codex', 'plugins', 'cache')
function codexPluginRoots(home, configDirectory = '') {
const cacheRoot = path.join(configDirectory || path.join(home, '.codex'), 'plugins', 'cache')
const roots = []
const visited = new Set()
let directoryCount = 0
@@ -256,10 +281,10 @@ function codexPluginRoots(home) {
))
}
function skillRoots(kind, home) {
const roots = globalRoots(kind, home)
if (kind === 'codex') roots.push(...codexPluginRoots(home))
if (kind === 'workbuddy') roots.push(...workBuddyPluginRoots(home))
function skillRoots(kind, home, configDirectory = '', sharedDirectory = '') {
const roots = globalRoots(kind, home, configDirectory, sharedDirectory)
if (kind === 'codex') roots.push(...codexPluginRoots(home, configDirectory))
if (kind === 'workbuddy') roots.push(...workBuddyPluginRoots(home, configDirectory))
return roots
}
@@ -348,10 +373,12 @@ function copyResult(result) {
}
class LocalSkillCatalog {
constructor({ home = os.homedir(), now = Date.now, cacheTtlMs = DEFAULT_CACHE_TTL_MS } = {}) {
constructor({ home = os.homedir(), now = Date.now, cacheTtlMs = DEFAULT_CACHE_TTL_MS, configDirectoryForKind = () => '', sharedDirectoryForKind = () => '' } = {}) {
this.home = path.resolve(String(home || os.homedir()))
this.now = now
this.cacheTtlMs = cacheTtlMs
this.configDirectoryForKind = configDirectoryForKind
this.sharedDirectoryForKind = sharedDirectoryForKind
this.cache = new Map()
}
@@ -364,7 +391,9 @@ class LocalSkillCatalog {
const cached = this.cache.get(targetKind)
if (cached && now < cached.expiresAt) return cached.result
const skills = collectSkills(targetKind, skillRoots(targetKind, this.home)).sort(compareSkills)
const configDirectory = this.configDirectoryForKind(targetKind)
const skills = collectSkills(targetKind, skillRoots(targetKind, this.home, configDirectory,
this.sharedDirectoryForKind(targetKind))).sort(compareSkills)
const result = {
supported: true,
total: skills.length,
@@ -273,8 +273,9 @@ class LocalSkillTrustStore {
body.previousEventId,
)
try { writeLine(this.storagePath, `${canonicalJson(event)}\n`) } catch (error) {
if (error?.code?.startsWith('LOCAL_SKILL_')) throw error
fail('LOCAL_SKILL_TRUST_STORE_UNAVAILABLE')
this.unavailableCode = error?.code?.startsWith('LOCAL_SKILL_')
? error.code : 'LOCAL_SKILL_TRUST_STORE_UNAVAILABLE'
fail(this.unavailableCode)
}
this.events.push(event)
this.states.set(binding.bindingId, {
@@ -286,6 +287,7 @@ class LocalSkillTrustStore {
}
decision(input) {
this.requireAvailable()
const binding = asBinding(input)
const state = this.states.get(binding.bindingId)
if (state?.state !== 'approved') return null
@@ -0,0 +1,133 @@
const { createHash, randomBytes } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { npmShimTarget } = require('../agents/cli/cli-discovery.cjs')
const { credentialLiteral } = require('../agents/cli/native-secret-environment.cjs')
const { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_ENVIRONMENT_IDENTITY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS } = require('../agents/native-config-directory.cjs')
function fileIdentity(filename) {
try {
const stat = fs.statSync(filename)
return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]
} catch (error) {
return error?.code === 'ENOENT' ? null : ['unreadable', randomBytes(32).toString('hex')]
}
}
function selectedConfigurationIdentity(agent, preferences) {
const directory = preferences?.[agent.kind]?.nativeConfigDirectory || agent[NATIVE_CONFIG_IDENTITY_DIRECTORY]
const spec = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, agent.kind) ? NATIVE_CONFIG_DIRECTORIES[agent.kind] : null
if (typeof directory !== 'string' || !path.isAbsolute(directory) || !spec) return null
const entries = [['directory', directory], ...[...spec.files, ...(spec.identityFiles || [])]
.map(name => [name, fileIdentity(path.join(directory, name))])]
for (const name of spec.directories || []) {
const root = path.join(directory, name)
let children = []
try {
children = fs.readdirSync(root).sort()
if (children.length > 256) return ['unbounded-directory', randomBytes(32).toString('hex')]
} catch (error) {
if (error.code !== 'ENOENT') return ['unreadable-directory', randomBytes(32).toString('hex')]
}
entries.push([name, fileIdentity(root), children.map(child => {
const childPath = path.join(root, child)
const nested = agent.kind === 'deepseek' && name === 'profiles'
? ['cordis.yml', 'cordis.patch.yml', '.credentials.yaml']
.map(file => [file, fileIdentity(path.join(childPath, file))]) : null
return [child, fileIdentity(childPath), nested]
})])
}
return entries
}
function geminiAccessTokenExpired(agent, preferences, now) {
const directory = preferences?.gemini?.nativeConfigDirectory || agent[NATIVE_CONFIG_IDENTITY_DIRECTORY]
if (typeof directory !== 'string' || !path.isAbsolute(directory)) return false
try {
const filename = path.join(directory, 'oauth_creds.json')
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > 1024 * 1024) return false
const value = JSON.parse(fs.readFileSync(filename, 'utf8'))
if (credentialLiteral(value?.refresh_token)) return false
return credentialLiteral(value?.access_token)
&& typeof value.expiry_date === 'number' && Number.isFinite(value.expiry_date)
&& value.expiry_date > 0 && value.expiry_date <= now + 5 * 60 * 1000
} catch { return false }
}
function agentVerificationIdentity(agent, preferences = {}, options = {}) {
if (!agent?.kind) return ''
let executableStat = null
if (agent.executable) {
try {
const stat = fs.statSync(agent.executable)
executableStat = [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]
} catch { /* unavailable paths are still bound to their detected identity */ }
}
const identity = [
agent.kind, agent.executable || '', agent.version || '', executableStat,
agent.connectorId || '', agent.connectorVersion || '', agent.upstreamVersion || '',
preferences?.[agent.kind]?.nativeConfigDirectory || '',
]
if ((options.platform || process.platform) === 'win32' && /\.(?:cmd|bat)$/i.test(agent.executable || '')) {
try {
const target = npmShimTarget(agent.executable, options)
if (!target) throw new Error('invalid shim')
const stat = (options.statFn || fs.statSync)(target)
identity.push(['shim-target', target, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs])
} catch {
identity.push(['unavailable-shim-target', randomBytes(32).toString('hex')])
}
}
const configuration = selectedConfigurationIdentity(agent, preferences)
if (configuration !== null) identity.push(configuration)
if (agent.kind === 'gemini' && geminiAccessTokenExpired(agent, preferences, options.now ?? Date.now())) {
identity.push(['oauth-access-token-expired'])
}
if (Array.isArray(agent[NATIVE_AUTH_IDENTITY_FILES])) {
identity.push(agent[NATIVE_AUTH_IDENTITY_FILES].filter(filename => typeof filename === 'string' && path.isAbsolute(filename))
.map(filename => [filename, fileIdentity(filename)]))
}
if (agent[NATIVE_ENVIRONMENT_IDENTITY]) identity.push(agent[NATIVE_ENVIRONMENT_IDENTITY])
return createHash('sha256').update(JSON.stringify(identity)).digest('hex')
}
function projectVerificationIdentity(kind, workdir, agent = null, environmentIdentity = '') {
const files = {
codex: ['.codex/config.toml'],
claude: ['.claude/settings.json', '.claude/settings.local.json'],
zcode: ['zcode.json', '.zcode/config.json'],
opencode: ['opencode.json', 'opencode.jsonc', '.opencode/opencode.json', '.opencode/opencode.jsonc'],
mimo: ['mimocode.json', 'mimocode.jsonc', '.mimocode/mimocode.json', '.mimocode/mimocode.jsonc'],
gemini: ['.gemini/settings.json', '.gemini/.env', '.env'],
qwen: ['.qwen/settings.json', '.qwen/.env', '.env'],
deepseek: ['.env'],
pi: [],
}[kind]
if (!files && !environmentIdentity && !agent?.[NATIVE_PROJECT_CONFIG_PATHS]?.length) return ''
if (kind === 'pi' && !environmentIdentity && !agent?.[NATIVE_PROJECT_CONFIG_PATHS]?.length) return ''
if (typeof workdir !== 'string' || !path.isAbsolute(workdir)) return null
const root = path.resolve(workdir)
if (root.length > 32767) return null
try { if (!fs.statSync(root).isDirectory()) return null } catch { return null }
const entries = [kind, root]
if (environmentIdentity) entries.push(['native-environment', environmentIdentity])
for (const item of agent?.[NATIVE_PROJECT_CONFIG_PATHS] || []) {
if (!item || typeof item.value !== 'string' || path.isAbsolute(item.value)) continue
const target = path.resolve(root, item.value)
const names = kind === 'opencode' ? ['opencode.json', 'opencode.jsonc'] : ['mimocode.json', 'mimocode.jsonc']
entries.push([target, item.directory ? names.map(name => [name, fileIdentity(path.join(target, name))]) : fileIdentity(target)])
}
let current = root
for (;;) {
entries.push([current, ...(files || []).map(file => [file, fileIdentity(path.join(current, file))])])
if (kind === 'deepseek') break
if (kind !== 'qwen' && kind !== 'gemini' && fs.existsSync(path.join(current, '.git'))) break
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
return createHash('sha256').update(JSON.stringify(entries)).digest('hex')
}
module.exports = { agentVerificationIdentity, projectVerificationIdentity }
+4 -1
View File
@@ -47,7 +47,10 @@ function resolveInvocationStop({
const agentCancelled = agentController.signal.aborted && !parentStopped && !watchdog.timedOut
let error = caughtError
let status = 'failed'
if (parentTimedOut) {
if (caughtError?.code === 'LOCAL_AGENT_TERMINATION_FAILED'
|| caughtError?.message === 'LOCAL_AGENT_TERMINATION_FAILED') {
// A cancellation request is not proof that the process exited.
} else if (parentTimedOut) {
error = new Error('LOCAL_AGENT_TIMEOUT')
status = 'timeout'
} else if (parentStopped) {
@@ -1,24 +1,38 @@
const { agentVerificationIdentity, projectVerificationIdentity } = require('./agent-verification-identity.cjs')
const { NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS } = require('../agents/native-config-directory.cjs')
const {
agentRuntimeCapabilities,
} = require('../agents/agent-runtime-contract.cjs')
const RECENT_VERIFICATION_MS = 24 * 60 * 60 * 1000
const AUTH_FAILURE_RETRY_MS = 30 * 1000
const MAX_PROJECT_AUTH_RECORDS = 64
const PROJECT_NATIVE_ENVIRONMENT_KINDS = new Set([
'codex', 'hermes', 'openclaw', 'workbuddy', 'zcode', 'pi', 'kimi', 'mimo', 'deepseek',
'claude', 'gemini', 'opencode', 'qwen', 'opencodereview',
])
// A readiness probe that times out or crashes says nothing about whether the
// Agent works: it is not a "not signed in" answer. An Agent that was usable
// stays usable through such results for this long; a definitive answer
// (signed out, incompatible, a recorded auth failure) applies immediately.
const INDETERMINATE_PROBE_GRACE_MS = 10 * 60 * 1000
const INDETERMINATE_PROBE_SOURCES = new Set(['native-runtime-unavailable', 'native-probe-failed'])
const INDETERMINATE_PROBE_SOURCES = new Set(['native-probe-failed'])
function expiredAuthFailure(runtime, now) {
if (runtime?.credentialState !== 'missing') return false
const checkedAt = Date.parse(String(runtime.checkedAt || ''))
if (runtime?.credentialState !== 'missing' && !runtime?.projectFailureIdentity) return false
const checkedAt = Date.parse(String(runtime.projectFailureCheckedAt || runtime.checkedAt || ''))
const current = Date.parse(String(now || ''))
return !Number.isFinite(checkedAt) || (Number.isFinite(current)
&& (current < checkedAt || current - checkedAt >= AUTH_FAILURE_RETRY_MS))
}
function clearProjectFailure(runtime) {
if ((runtime?.verificationSource !== 'successful-run' || runtime.credentialState !== 'ready')
&& !runtime?.projectVerifications?.length && !runtime?.projectFailures?.length) return undefined
const { projectFailureIdentity, projectFailureCheckedAt, failureIdentity, ...retained } = runtime
return retained
}
function agentVersionIdentified(agent) {
if (agent?.custom === true) return true
if (typeof agent?.versionIdentified === 'boolean') return agent.versionIdentified
@@ -34,6 +48,17 @@ function agentCompatible(agent) {
|| typeof agent?.compatibilityState === 'undefined'
}
function sidebarVisible(agent, preferred) {
if (typeof preferred === 'boolean') return preferred
return !(agent.custom === true && agent.task && agent.task !== 'general')
}
function agentInvocable({ installed, versionIdentified, compatible, configured, authenticated, canAttempt, runtimePrerequisitesReady, unavailable }) {
return installed !== false && versionIdentified && compatible
&& ((configured && authenticated) || canAttempt === true)
&& runtimePrerequisitesReady !== false && !unavailable
}
function recentlyVerified(runtime, now) {
if (runtime?.credentialState !== 'ready') return false
const checkedAt = Date.parse(String(runtime.checkedAt || ''))
@@ -42,13 +67,22 @@ function recentlyVerified(runtime, now) {
&& current >= checkedAt && current - checkedAt <= RECENT_VERIFICATION_MS
}
function matchesRecentVerification(runtime, agent, now, preferences) {
return runtime?.verificationSource === 'successful-run' && recentlyVerified(runtime, now)
&& Boolean(runtime.verificationIdentity) && runtime.verificationIdentity === agentVerificationIdentity(agent, preferences)
}
class LocalWorkspaceAgentCatalog {
constructor(options) {
this.state = options.state
this.detectedAgents = options.detectedAgents
this.setDetectedAgents = options.setDetectedAgents
this.detectAgents = options.detectAgents
this.isKnownAgent = options.isKnownAgent || (() => false)
this.credentialState = options.credentialState
this.resolveProjectEnvironmentIdentities = options.resolveProjectEnvironmentIdentities || null
this.projectEnvironmentIdentities = new Map()
this.projectEnvironmentGeneration = 0
this.attachmentSupport = options.attachmentSupport || (() => ({}))
this.save = options.save
this.emitChanged = options.emitChanged
@@ -57,6 +91,67 @@ class LocalWorkspaceAgentCatalog {
this.refreshTask = null
this.refreshAgain = false
this.indeterminateSince = new Map()
this.detectedIdentities = new Map(this.detectedAgents().map(agent => [agent.kind, agentVerificationIdentity(agent, this.state().agentPreferences)]))
}
async refreshProjectEnvironment(workdir, kinds) {
if (!this.resolveProjectEnvironmentIdentities || typeof workdir !== 'string') return
const selected = [...new Set((kinds || []).filter(kind => PROJECT_NATIVE_ENVIRONMENT_KINDS.has(kind)))]
if (!selected.length) return
const generation = ++this.projectEnvironmentGeneration
const keys = selected.map(kind => JSON.stringify([kind, workdir]))
for (const key of keys) this.projectEnvironmentIdentities.set(key, { generation, value: null })
let identities = null
try { identities = await this.resolveProjectEnvironmentIdentities(workdir, selected) } catch { /* Fail closed for routing. */ }
for (let index = 0; index < selected.length; index += 1) {
const current = this.projectEnvironmentIdentities.get(keys[index])
if (current?.generation !== generation) continue
const value = identities?.[selected[index]]
current.value = typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) ? value : null
}
while (this.projectEnvironmentIdentities.size > 256) {
this.projectEnvironmentIdentities.delete(this.projectEnvironmentIdentities.keys().next().value)
}
}
projectIdentity(kind, workdir, agent) {
let environmentIdentity = ''
if (this.resolveProjectEnvironmentIdentities && PROJECT_NATIVE_ENVIRONMENT_KINDS.has(kind)) {
environmentIdentity = this.projectEnvironmentIdentities.get(JSON.stringify([kind, workdir]))?.value
if (!environmentIdentity) return null
}
return projectVerificationIdentity(kind, workdir, agent, environmentIdentity)
}
isRecentlyVerified(kind, workdir) {
const runtime = this.state().agentRuntime[kind]
const agent = this.detectedAgents().find(item => item.kind === kind)
if (workdir === undefined) return matchesRecentVerification(runtime, agent, this.now(), this.state().agentPreferences)
if (this.isProjectAuthBlocked(kind, workdir)) return false
const projectIdentity = this.projectIdentity(kind, workdir, agent)
if (projectIdentity === '') return matchesRecentVerification(runtime, agent, this.now(), this.state().agentPreferences)
if (!projectIdentity || runtime?.credentialState === 'missing') return false
const identity = agentVerificationIdentity(agent, this.state().agentPreferences)
const record = Array.isArray(runtime?.projectVerifications)
? runtime.projectVerifications.find(item => item?.projectIdentity === projectIdentity && item.verificationIdentity === identity)
: null
if (record) return recentlyVerified({ credentialState: 'ready', checkedAt: record.checkedAt }, this.now())
return matchesRecentVerification(runtime, agent, this.now(), this.state().agentPreferences)
&& runtime.projectVerificationIdentity === projectIdentity
}
isProjectAuthBlocked(kind, workdir) {
const runtime = this.state().agentRuntime[kind]
const agent = this.detectedAgents().find(item => item.kind === kind)
const identity = this.projectIdentity(kind, workdir, agent)
const agentIdentity = agentVerificationIdentity(agent, this.state().agentPreferences)
const failure = Array.isArray(runtime?.projectFailures)
? runtime.projectFailures.find(item => item?.projectIdentity === identity && item.verificationIdentity === agentIdentity)
: null
if (failure) return !expiredAuthFailure({ projectFailureIdentity: identity, projectFailureCheckedAt: failure.checkedAt }, this.now())
return Boolean(identity && runtime?.projectFailureIdentity === identity
&& runtime.failureIdentity === agentIdentity
&& !expiredAuthFailure(runtime, this.now()))
}
async refresh() {
@@ -98,19 +193,30 @@ class LocalWorkspaceAgentCatalog {
const nativeState = ['ready', 'missing'].includes(native?.state) ? native.state : 'unknown'
const workBuddyUnavailable = ['workbuddy-desktop-locked', 'workbuddy-model-unavailable'].includes(native?.source)
const authoritativeNativeState = native?.source === 'native-auth-status'
// Give corrected native credentials a retry after the cooldown, never erase a newer failure.
if (expiredAuthFailure(runtime, this.now())
&& !(authoritativeNativeState && nativeState === 'ready')
if ((runtime?.credentialState === 'missing' || runtime?.projectFailureIdentity) && runtime.failureIdentity
&& runtime.failureIdentity !== agentVerificationIdentity(agent, state.agentPreferences)
&& runtime === runtimeAtRefreshStart.get(agent.kind)) {
delete state.agentRuntime[agent.kind]
runtime = undefined
recoveredRuntimeCredential = true
}
// Give corrected native credentials a retry after the cooldown, never erase a newer failure.
if (expiredAuthFailure(runtime, this.now())
&& (runtime?.projectFailureIdentity || !(authoritativeNativeState && nativeState === 'ready'))
&& runtime === runtimeAtRefreshStart.get(agent.kind)) {
const retained = clearProjectFailure(runtime)
if (retained) state.agentRuntime[agent.kind] = retained
else delete state.agentRuntime[agent.kind]
runtime = retained
recoveredRuntimeCredential = true
}
// A probe that started before a newer runtime failure cannot clear that failure.
if (authoritativeNativeState && nativeState === 'ready'
&& ['missing', 'unknown'].includes(runtime?.credentialState)
&& !runtime?.projectFailureIdentity
&& runtime === runtimeAtRefreshStart.get(agent.kind)) {
runtime = {
...runtime,
credentialState: 'ready',
checkedAt: this.now(),
}
@@ -118,10 +224,7 @@ class LocalWorkspaceAgentCatalog {
recoveredRuntimeCredential = true
}
const runtimeMissing = runtime?.credentialState === 'missing'
const verifiedReady = runtime?.credentialState === 'ready'
|| state.messages.some(message => (
message.role === 'agent' && message.agentKind === agent.kind
))
const verifiedReady = matchesRecentVerification(runtime, agent, this.now(), state.agentPreferences)
const nativeReadySource = nativeState === 'ready'
? (native.source || 'native-credential')
: ''
@@ -142,24 +245,26 @@ class LocalWorkspaceAgentCatalog {
&& !workBuddyUnavailable
&& nativeState === 'unknown'
&& native?.source !== 'native-runtime-unavailable'
&& native?.source !== 'native-secret-unresolved'
&& native?.source !== 'native-probe-failed'
const configured = retained !== null
const configured = retained?.configured === true
|| nativeState === 'ready'
|| native?.source === 'native-auth-status'
|| native?.source === 'native-runtime-unavailable'
|| verifiedReady
|| runtimeMissing
|| nativeConfigurationUnknown
const authenticated = retained !== null || (!runtimeMissing && nativeState !== 'missing' && (
nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown
const authenticated = retained?.authenticated === true || (!runtimeMissing && nativeState !== 'missing' && (
verifiedReady || (nativeState === 'ready' && native?.source === 'native-auth-status')
))
const runtimePrerequisitesReady = retained !== null
|| (native?.source !== 'native-runtime-unavailable'
&& (native?.source !== 'native-probe-failed' || recentlyVerified(runtime, this.now())))
const invocable = installed && versionIdentified && compatible && configured
&& authenticated && runtimePrerequisitesReady && !workBuddyUnavailable
const verifiedRecently = (authoritativeNativeState && nativeState === 'ready')
|| recentlyVerified(runtime, this.now())
&& native?.source !== 'native-secret-unresolved'
&& (native?.source !== 'native-probe-failed' || verifiedReady))
const invocable = agentInvocable({ installed, versionIdentified, compatible, configured,
authenticated, canAttempt: retained !== null || nativeConfigurationUnknown
|| (!runtimeMissing && nativeState === 'ready'),
runtimePrerequisitesReady, unavailable: workBuddyUnavailable })
const verifiedRecently = matchesRecentVerification(runtime, agent, this.now(), state.agentPreferences)
const available = invocable
const preferred = state.agentPreferences[agent.kind]?.showInSidebar
const capabilities = agentRuntimeCapabilities(agent.kind, {
@@ -171,18 +276,23 @@ class LocalWorkspaceAgentCatalog {
else if (runtimeMissing) availabilitySource = 'runtime-auth-failure'
else if (nativeState === 'missing') availabilitySource = native.source || 'none'
else if (native?.source === 'native-probe-failed') availabilitySource = 'native-probe-failed'
else if (native?.source === 'native-runtime-unavailable') availabilitySource = 'native-runtime-unavailable'
else if (native?.source === 'native-secret-unresolved') availabilitySource = 'native-secret-unresolved'
else if (nativeReadySource) availabilitySource = nativeReadySource
else if (verifiedReady) availabilitySource = 'verified-run'
else if (nativeConfigurationUnknown) availabilitySource = 'local-cli'
if (retained) availabilitySource = retained.availabilitySource
if (workBuddyUnavailable) availabilitySource = native.source
return {
const result = {
...agent,
installed,
versionIdentified,
compatible,
configured,
authenticated,
runtimePrerequisitesReady,
runtimePrerequisitesSource: runtimePrerequisitesReady ? '' : native?.source || '',
runtimeBlockSource: workBuddyUnavailable ? native.source : '',
invocable,
recentlyVerified: verifiedRecently,
credentialState,
@@ -191,10 +301,21 @@ class LocalWorkspaceAgentCatalog {
task: capabilities.task,
resumable: capabilities.resumable,
capabilities,
showInSidebar: typeof preferred === 'boolean' ? preferred : true,
showInSidebar: sidebarVisible({ ...agent, task: capabilities.task }, preferred),
}
if (agent[NATIVE_CONFIG_IDENTITY_DIRECTORY]) {
Object.defineProperty(result, NATIVE_CONFIG_IDENTITY_DIRECTORY, { value: agent[NATIVE_CONFIG_IDENTITY_DIRECTORY] })
}
if (agent[NATIVE_AUTH_IDENTITY_FILES]) {
Object.defineProperty(result, NATIVE_AUTH_IDENTITY_FILES, { value: agent[NATIVE_AUTH_IDENTITY_FILES] })
}
if (agent[NATIVE_PROJECT_CONFIG_PATHS]) {
Object.defineProperty(result, NATIVE_PROJECT_CONFIG_PATHS, { value: agent[NATIVE_PROJECT_CONFIG_PATHS] })
}
return result
})
this.setDetectedAgents(agents)
this.detectedIdentities = new Map(agents.map(agent => [agent.kind, agentVerificationIdentity(agent, state.agentPreferences)]))
if (recoveredRuntimeCredential) this.save()
this.emitChanged()
return this.snapshot()
@@ -213,52 +334,103 @@ class LocalWorkspaceAgentCatalog {
}
const since = this.indeterminateSince.get(agent.kind)
if (previous?.available !== true || runtimeMissing || !Number.isFinite(nowMs)
|| !Number.isFinite(since) || nowMs - since >= INDETERMINATE_PROBE_GRACE_MS
|| !Number.isFinite(since) || nowMs < since || nowMs - since >= INDETERMINATE_PROBE_GRACE_MS
|| this.detectedIdentities.get(agent.kind) !== agentVerificationIdentity(agent, this.state().agentPreferences)
|| !agentVersionIdentified(agent) || !agentCompatible(agent)) return null
return {
credentialState: previous.credentialState,
availabilitySource: previous.availabilitySource || 'native-probe-failed',
configured: previous.configured === true,
authenticated: previous.authenticated === true,
}
}
setSidebarVisibility(kind, visible) {
const agent = this.detectedAgents().find(item => item.kind === kind)
if (!agent) throw new Error('LOCAL_AGENT_NOT_INSTALLED')
if (!agent && !this.isKnownAgent(kind)) throw new Error('LOCAL_AGENT_NOT_INSTALLED')
const state = this.state()
state.agentPreferences[kind] = { ...state.agentPreferences[kind], showInSidebar: Boolean(visible) }
agent.showInSidebar = Boolean(visible)
if (agent) agent.showInSidebar = Boolean(visible)
this.save()
this.emitChanged()
return this.snapshot()
}
markRuntimeCredential(kind, credentialState) {
markRuntimeCredential(kind, credentialState, { verifiedRun = true, verificationIdentity = '', workdir, projectIdentity } = {}) {
const state = this.state()
const agent = this.detectedAgents().find(item => item.kind === kind)
if (projectIdentity === null) return
if (verificationIdentity && (!agent || verificationIdentity !== agentVerificationIdentity(agent, state.agentPreferences))) return
if (projectIdentity !== undefined && projectIdentity !== this.projectIdentity(kind, workdir, agent)) return
const identity = verificationIdentity || agentVerificationIdentity(agent, state.agentPreferences)
const projectFailure = credentialState === 'missing' && typeof projectIdentity === 'string' && Boolean(projectIdentity)
const previous = state.agentRuntime[kind]
const preserveSuccess = projectFailure && previous?.credentialState === 'ready'
&& previous.verificationSource === 'successful-run' && previous.verificationIdentity === identity
&& previous.projectVerificationIdentity !== projectIdentity
const preserveFailure = credentialState === 'ready' && previous?.projectFailureIdentity
&& previous.projectFailureIdentity !== projectIdentity && previous.failureIdentity === identity
&& !expiredAuthFailure(previous, this.now())
const effectiveState = projectFailure ? (preserveSuccess ? 'ready' : 'unknown') : credentialState
state.agentRuntime[kind] = {
credentialState,
checkedAt: this.now(),
...(preserveSuccess ? previous : {}),
credentialState: effectiveState,
checkedAt: preserveSuccess ? previous.checkedAt : this.now(),
...(projectFailure ? { projectFailureIdentity: projectIdentity, projectFailureCheckedAt: this.now() } : {}),
...(preserveFailure ? { projectFailureIdentity: previous.projectFailureIdentity,
projectFailureCheckedAt: previous.projectFailureCheckedAt || previous.checkedAt,
failureIdentity: previous.failureIdentity } : {}),
...(credentialState === 'missing' && verificationIdentity ? { failureIdentity: verificationIdentity } : {}),
...(credentialState === 'ready' && verifiedRun && identity
? { verificationSource: 'successful-run', verificationIdentity: identity,
...(projectIdentity ? { projectVerificationIdentity: projectIdentity } : {}) } : {}),
}
if (projectIdentity && (credentialState === 'missing' || (credentialState === 'ready' && verifiedRun))) {
const keep = records => (Array.isArray(records) ? records : [])
.filter(item => item?.verificationIdentity === identity && item.projectIdentity !== projectIdentity)
.slice(-(MAX_PROJECT_AUTH_RECORDS - 1))
const projectVerifications = keep(previous?.projectVerifications)
const projectFailures = keep(previous?.projectFailures)
if (previous?.verificationSource === 'successful-run' && previous.verificationIdentity === identity
&& previous.projectVerificationIdentity && previous.projectVerificationIdentity !== projectIdentity
&& !projectVerifications.some(item => item.projectIdentity === previous.projectVerificationIdentity)) {
projectVerifications.push({ projectIdentity: previous.projectVerificationIdentity,
verificationIdentity: identity, checkedAt: previous.checkedAt })
}
if (previous?.failureIdentity === identity && previous.projectFailureIdentity
&& previous.projectFailureIdentity !== projectIdentity
&& !projectFailures.some(item => item.projectIdentity === previous.projectFailureIdentity)) {
projectFailures.push({ projectIdentity: previous.projectFailureIdentity,
verificationIdentity: identity, checkedAt: previous.projectFailureCheckedAt || previous.checkedAt })
}
const record = { projectIdentity, verificationIdentity: identity, checkedAt: this.now() }
if (projectFailure) projectFailures.push(record)
else projectVerifications.push(record)
state.agentRuntime[kind].projectVerifications = projectVerifications.slice(-MAX_PROJECT_AUTH_RECORDS)
state.agentRuntime[kind].projectFailures = projectFailures.slice(-MAX_PROJECT_AUTH_RECORDS)
}
if (credentialState === 'ready') this.indeterminateSince.delete(kind)
const agent = this.detectedAgents().find(item => item.kind === kind)
if (agent && !(credentialState === 'unknown' && agent.authenticated === true)) {
agent.credentialState = credentialState
if (agent && !(effectiveState === 'unknown' && agent.authenticated === true)) {
agent.credentialState = effectiveState
agent.versionIdentified = agentVersionIdentified(agent)
agent.compatible = agentCompatible(agent)
agent.configured = credentialState !== 'unknown'
agent.authenticated = credentialState === 'ready'
agent.invocable = agent.versionIdentified && agent.compatible
&& agent.configured && agent.authenticated
agent.recentlyVerified = credentialState === 'ready'
agent.configured = effectiveState !== 'unknown'
agent.authenticated = effectiveState === 'ready'
agent.invocable = agentInvocable({ ...agent, canAttempt: effectiveState === 'unknown' && agent.available === true,
unavailable: Boolean(agent.runtimeBlockSource) })
agent.recentlyVerified = this.isRecentlyVerified(kind)
agent.available = agent.invocable
agent.availabilitySource = !agent.compatible
? 'incompatible'
: credentialState === 'ready'
? 'verified-run'
: credentialState === 'missing'
? 'runtime-auth-failure'
: 'unverified'
: effectiveState === 'missing'
? 'runtime-auth-failure'
: agent.runtimeBlockSource
? agent.runtimeBlockSource
: agent.runtimePrerequisitesReady === false
? agent.runtimePrerequisitesSource || 'native-runtime-unavailable'
: effectiveState === 'ready' ? (agent.recentlyVerified ? 'verified-run' : 'native-credential') : 'unverified'
const preferred = state.agentPreferences[kind]?.showInSidebar
agent.showInSidebar = typeof preferred === 'boolean' ? preferred : true
agent.showInSidebar = sidebarVisible(agent, preferred)
}
this.save()
this.emitChanged()
@@ -268,8 +440,13 @@ class LocalWorkspaceAgentCatalog {
const state = this.state()
let changed = false
for (const [kind, runtime] of Object.entries(state.agentRuntime)) {
if (runtime?.credentialState !== 'missing') continue
delete state.agentRuntime[kind]
if (runtime?.credentialState !== 'missing' && !runtime?.projectFailureIdentity && !runtime?.projectFailures?.length) continue
const retained = clearProjectFailure(runtime)
if (retained) {
delete retained.projectFailures
state.agentRuntime[kind] = retained
}
else delete state.agentRuntime[kind]
changed = true
}
if (changed) this.save()
@@ -1,4 +1,7 @@
const { createHash, randomUUID } = require('node:crypto')
const { taskPermissionGroup } = require('./run-permission-cap.cjs')
const { agentVerificationIdentity, projectVerificationIdentity } = require('./agent-verification-identity.cjs')
const { NATIVE_PROJECT_CONFIG_PATHS } = require('../agents/native-config-directory.cjs')
const { workPreferencesPrompt } = require('./work-preferences.cjs')
const {
assertResumedPermissionBinding,
@@ -158,9 +161,28 @@ function assertRequiredContextFits(packedContext) {
throw error
}
async function awaitProjectEnvironment(refresh, workdir, kind, signals) {
const activeSignals = [...new Set(signals.filter(Boolean))]
if (activeSignals.some(signal => signal.aborted)) throw agentStoppedError()
const pending = Promise.resolve().then(() => {
if (activeSignals.some(signal => signal.aborted)) throw agentStoppedError()
return refresh(workdir, [kind])
})
if (!activeSignals.length) return pending
let abort
const stopped = new Promise((_, reject) => { abort = () => reject(agentStoppedError()) })
for (const signal of activeSignals) {
if (signal.aborted) abort()
else signal.addEventListener('abort', abort, { once: true })
}
try { return await Promise.race([pending, stopped]) }
finally { for (const signal of activeSignals) signal.removeEventListener('abort', abort) }
}
class LocalWorkspaceAgentInvocation {
constructor(options) {
this.state = options.state
this.getContextPack = options.getContextPack
this.detectedAgents = options.detectedAgents
this.activeRuns = options.activeRuns
this.runAgentTimeoutMs = options.runAgentTimeoutMs
@@ -199,6 +221,9 @@ class LocalWorkspaceAgentInvocation {
this.createAttemptContextPack = options.createAttemptContextPack
this.recordContextDelivery = options.recordContextDelivery
this.markRuntimeCredential = options.markRuntimeCredential
this.isProjectAuthBlocked = options.isProjectAuthBlocked || (() => false)
this.refreshProjectEnvironment = options.refreshProjectEnvironment || (async () => {})
this.projectIdentity = options.projectIdentity || projectVerificationIdentity
this.addMessage = options.addMessage
this.runScheduler = options.runScheduler || processRunScheduler
this.registerAgentController = options.registerAgentController
@@ -207,6 +232,7 @@ class LocalWorkspaceAgentInvocation {
this.completeHumanGateContinuation = options.completeHumanGateContinuation
this.connectorRuntime = options.connectorRuntime || null
this.attachmentSupport = options.attachmentSupport || (() => ({}))
this.validateSkillSelections = options.validateSkillSelections || (async (_kind, selections) => selections)
this.activeAgentSnapshots = new Map()
}
@@ -240,13 +266,19 @@ class LocalWorkspaceAgentInvocation {
const activeRun = this.activeRuns.get(group.id)
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
if (!taskId) throw new Error('LOCAL_RUN_TASK_INVALID')
group = taskPermissionGroup(group, this.state(), taskId,
activeRun?.contextPackId || context.contextPackId, this.getContextPack)
// Every conversation, group or direct, is its own execution surface. The
// writer lease is scoped to the conversation, so a writable chat never
// makes an Agent in another conversation wait, including when both use the
// same folder (most chats keep the default one). Writers inside one
// conversation still take turns.
// Independent opening drafts retain parallel execution when the user
// enabled native write tools. Later shared deliverable turns serialize.
const proposalScope = context.independentProposal && context.phase === 'proposal'
? `\u0000proposal\u0000${kind}` : ''
const workspaceKey = createHash('sha256')
.update(`conversation\u0000${group.id}`)
.update(`conversation\u0000${group.id}${proposalScope}`)
.digest('hex')
const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80)
const permissionMode = group.allowWrite === true
@@ -299,25 +331,57 @@ class LocalWorkspaceAgentInvocation {
async invokeLeased(
group, kind, mode, signal, threadRootId = '', context = {}, invocation = {},
) {
await awaitProjectEnvironment(this.refreshProjectEnvironment, group.workdir, kind,
[signal, invocation.agentController?.signal])
const detected = this.detectedAgents()
const activeRun = this.activeRuns.get(group.id)
const candidate = detected.find(item => item.kind === kind)
const snapshotKey = `${group.id}:${activeRun?.runId || 'active'}:${kind}`
if (candidate) this.activeAgentSnapshots.set(snapshotKey, { ...candidate })
const runtime = this.state()?.agentRuntime?.[kind]
const projectIdentity = this.projectIdentity(kind, group.workdir, candidate || this.activeAgentSnapshots.get(snapshotKey))
const runtimeBlocked = runtime?.credentialState === 'missing'
|| this.isProjectAuthBlocked(kind, group.workdir)
const usable = item => item.installed !== false && item.invocable !== false
&& item.versionIdentified !== false && item.compatible !== false
&& item.runtimePrerequisitesReady !== false && !item.runtimeBlockSource
&& item.compatibilityState !== 'incompatible' && item.credentialState !== 'missing'
&& !['runtime-auth-failure', 'none'].includes(item.availabilitySource)
if (!runtimeBlocked && candidate && usable(candidate)) {
const snapshot = { ...candidate }
if (candidate[NATIVE_PROJECT_CONFIG_PATHS]) {
Object.defineProperty(snapshot, NATIVE_PROJECT_CONFIG_PATHS, { value: candidate[NATIVE_PROJECT_CONFIG_PATHS] })
}
this.activeAgentSnapshots.set(snapshotKey, snapshot)
}
else if (candidate || runtimeBlocked) this.activeAgentSnapshots.delete(snapshotKey)
const snapshot = this.activeAgentSnapshots.get(snapshotKey)
const agent = detected.find(item => item.kind === kind && item.available)
const agent = !runtimeBlocked && (detected.find(item => item.kind === kind && item.available && usable(item))
|| (activeRun?.targetKinds?.includes(kind)
&& candidate
&& candidate.installed !== false
&& candidate.compatibilityState !== 'incompatible'
&& candidate.credentialState !== 'missing'
&& candidate.availabilitySource !== 'runtime-auth-failure'
&& candidate.availabilitySource !== 'none'
&& usable(candidate)
? candidate
: activeRun?.targetKinds?.includes(kind) && !candidate && snapshot
: activeRun?.targetKinds?.includes(kind) && !candidate && snapshot && usable(snapshot)
? snapshot
: null)
: null))
if (!agent) throw new Error('LOCAL_AGENT_UNAVAILABLE')
const verificationIdentity = agentVerificationIdentity(agent, this.state()?.agentPreferences)
const revalidateApprovedSkills = async () => {
if (!context.skillHints?.some(skill => skill?.approvedSkillManifest)) return
const previous = context.skillHints
const validated = await this.validateSkillSelections(kind, previous)
if (!Array.isArray(validated) || validated.length !== previous.length
|| validated.some((skill, index) => !skill?.approvedSkillManifest
|| ['targetKind', 'namespace', 'slug', 'snapshotId', 'manifestHash']
.some(field => skill[field] !== previous[index]?.[field])
|| skill.entryPath !== previous[index]?.entryPath)) {
throw new Error('LOCAL_SKILL_SELECTION_INVALID')
}
if (signal?.aborted || invocation.agentController?.signal.aborted) {
throw new Error('LOCAL_AGENT_EXECUTION_STOPPED')
}
context = { ...context, skillHints: validated }
}
await revalidateApprovedSkills()
const reviewOnly = isCodeReviewAgentKind(kind)
const invocationContext = isolatedInvocationContext(context)
const isolated = invocationContext?.policy === 'isolated'
@@ -640,11 +704,11 @@ class LocalWorkspaceAgentInvocation {
const eventStatus = String(event.status || '').toLowerCase()
if (['tool_start', 'tool_update'].includes(eventType)
&& ['queued', 'running', 'waiting', 'in_progress', 'streaming'].includes(eventStatus)) {
watchdog.toolStarted()
watchdog.toolStarted(event.id)
} else if (eventType === 'tool_result_summary'
|| (eventType === 'status' && ['completed', 'failed', 'stopped', 'timeout', 'interrupted']
.includes(eventStatus))) {
watchdog.toolFinished()
watchdog.toolFinished(event.id)
}
this.emitRunEvent(event)
this.armAgentSilence(activeRun, kind, round, harnessRun.agentRunId)
@@ -703,6 +767,7 @@ class LocalWorkspaceAgentInvocation {
: {
status: resumedConnectorGate.status,
optionId: resumedConnectorGate.optionId,
permission: request.permission,
}),
}
}
@@ -887,6 +952,8 @@ class LocalWorkspaceAgentInvocation {
return prompt
}
const runOptions = {
turnCompletion: context.v4 === true
&& (context.allowMissingProposalReceipt === true || context.allowMissingV4Receipt === true),
sessionRef: session.ref,
...(Number.isInteger(context.executionSequence)
? { executionSequence: context.executionSequence }
@@ -915,6 +982,10 @@ class LocalWorkspaceAgentInvocation {
watchdog.liveness()
}
},
onSubstantiveActivity: () => {
watchdog.establish()
watchdog.progress()
},
onProgress,
onEvent: emitRuntimeEvent,
onOutboundPayload: recordOutboundPayload,
@@ -974,8 +1045,8 @@ class LocalWorkspaceAgentInvocation {
return pending
},
sessionTransport: session.transport,
...(kind === 'workbuddy' && Number.isInteger(context.maxInternalTurns)
? { maxTurns: context.maxInternalTurns }
...(kind === 'workbuddy' && (mode === 'auto' || Number.isInteger(context.maxInternalTurns))
? { maxTurns: mode === 'auto' ? 0 : context.maxInternalTurns }
: {}),
attachments: isolated ? [] : (stagedInputs?.nativeImagePaths || []),
// ACP runtimes are shared by the stable group/task session key. OpenClaw
@@ -986,7 +1057,10 @@ class LocalWorkspaceAgentInvocation {
? { hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false }
: {}),
}
const runCurrentSession = () => {
const runCurrentSession = async () => {
await revalidateApprovedSkills()
operationStarted = true
if (allowWrite) sideEffectsStarted = true
const currentRunOptions = {
...runOptions, sessionRef: session.ref, sessionTransport: session.transport,
}
@@ -1066,8 +1140,6 @@ class LocalWorkspaceAgentInvocation {
const executeCurrentSession = async () => {
const reusedSessionRef = session.ref
try {
operationStarted = true
if (allowWrite) sideEffectsStarted = true
return await runCurrentSession()
} catch (error) {
if (agentController.signal.aborted || !reusedSessionRef
@@ -1078,8 +1150,6 @@ class LocalWorkspaceAgentInvocation {
resumedPermission = null
}
rebuildFreshSession()
operationStarted = true
if (allowWrite) sideEffectsStarted = true
return await runCurrentSession()
}
}
@@ -1167,7 +1237,7 @@ class LocalWorkspaceAgentInvocation {
...binding,
...(gateType === 'input'
? { response: decision.response }
: { status: decision.status, optionId: decision.optionId }),
: { status: decision.status, optionId: decision.optionId, permission: request.permission }),
}
result = await raceCurrentSession()
}
@@ -1175,15 +1245,22 @@ class LocalWorkspaceAgentInvocation {
await Promise.allSettled([...pendingPermissionCallbacks])
throw new Error('LOCAL_AGENT_PERMISSION_TERMINAL_RACE')
}
terminalFailureKnown = ['failed', 'cancelled']
.includes(String(result?.outcome || ''))
terminalFailureKnown = result?.outcome === 'cancelled'
|| (result?.outcome === 'failed'
&& !['unknown', 'network', 'timeout'].includes(result?.failure?.category))
if (agentController.signal.aborted) throw agentStoppedError()
if (resumedPermissionError) throw resumedPermissionError
if (resumedPermission && !resumedPermissionUsed) {
throw new Error('LOCAL_RUN_PERMISSION_REQUEST_NOT_REISSUED')
}
result = requireTerminalAgentResult(result)
this.markRuntimeCredential(kind, 'ready')
try {
result = requireTerminalAgentResult(result)
} catch (error) {
if (agent.connectorInstanceId && result?.outcome === 'failed' && result.failure?.retryable === false) {
Object.defineProperty(error, Symbol.for('meldwork.connector-nonretryable'), { value: true })
}
throw error
}
const collaborationReply = parseV4CollaborationReceipt(
result,
@@ -1247,6 +1324,10 @@ class LocalWorkspaceAgentInvocation {
capturedOutcomeRefs,
requestedOutcomeRefs,
])
// The Agent has returned; shell revalidation is not Agent inactivity.
watchdog.pause()
await awaitProjectEnvironment(this.refreshProjectEnvironment, group.workdir, kind, [signal, agentController.signal])
if (signal?.aborted || agentController.signal.aborted) throw agentStoppedError()
const finalStatus = result.outcome
const trace = finishHarness(finalStatus, reply.text, {
promptChars: prompt.length,
@@ -1296,6 +1377,8 @@ class LocalWorkspaceAgentInvocation {
lastDeliveredMessageId: packedContext.latestMessageId || session.meta.lastDeliveredMessageId,
})
}
this.markRuntimeCredential(kind, 'ready', { verifiedRun: result.outcome === 'completed', verificationIdentity,
workdir: group.workdir, projectIdentity })
return {
message,
pendingMessage,
@@ -1310,26 +1393,67 @@ class LocalWorkspaceAgentInvocation {
} : {}),
}
} catch (caughtError) {
const stop = resolveInvocationStop({
let stop = resolveInvocationStop({
caughtError, signal, parentAbortObserved, activeRun, agentController, watchdog,
})
if (stop.settleOutstanding) {
const cleanupPromises = [runPromise, ...outputs.pending()].filter(Boolean)
let runSettled = !runPromise
let terminationFailure = null
const observedRun = runPromise?.then(() => { runSettled = true }, error => {
runSettled = true
if (error?.code === 'LOCAL_AGENT_TERMINATION_FAILED'
|| error?.message === 'LOCAL_AGENT_TERMINATION_FAILED') terminationFailure = error
})
const cleanupPromises = [observedRun, ...outputs.pending()].filter(Boolean)
if (cleanupPromises.length) {
await settleWithin(Promise.allSettled(cleanupPromises), this.runAbortGraceMs)
const graceMs = process.platform === 'win32' ? Math.max(this.runAbortGraceMs, 2500) : this.runAbortGraceMs
await settleWithin(Promise.allSettled(cleanupPromises), graceMs)
}
if (terminationFailure || (process.platform === 'win32' && !runSettled)) {
let failure = terminationFailure
if (!failure) {
failure = Object.assign(new Error('LOCAL_AGENT_TERMINATION_FAILED'), {
code: 'LOCAL_AGENT_TERMINATION_FAILED',
})
const pendingExit = runPromise.then(() => undefined, lateError => {
if (lateError?.code !== 'LOCAL_AGENT_TERMINATION_FAILED'
&& lateError?.message !== 'LOCAL_AGENT_TERMINATION_FAILED') return undefined
return lateError[Symbol.for('meldwork.pending-process-exit')] || new Promise(() => {})
})
Object.defineProperty(failure, Symbol.for('meldwork.pending-process-exit'), { value: pendingExit })
}
stop = resolveInvocationStop({
caughtError: failure,
signal, parentAbortObserved, activeRun, agentController, watchdog,
})
}
}
const { error } = stop
let { error } = stop
if (activeRun && error?.message === 'LOCAL_AGENT_TERMINATION_FAILED') {
activeRun.stopReason = 'termination_failed'
}
// Only an explicit sign-in failure changes readiness. Network, quota and
// provider refusals stay run-scoped so a healthy Agent remains usable.
if (error?.code === 'LOCAL_AGENT_AUTH_REQUIRED'
&& context.deferCredentialFailure !== true) {
this.markRuntimeCredential(kind, 'missing')
if (error?.code === 'LOCAL_AGENT_AUTH_REQUIRED') {
watchdog.pause()
try {
await awaitProjectEnvironment(this.refreshProjectEnvironment, group.workdir, kind, [signal, agentController.signal])
if (context.deferCredentialFailure !== true) {
this.markRuntimeCredential(kind, 'missing', { verificationIdentity, workdir: group.workdir, projectIdentity })
}
} catch (refreshError) {
stop = resolveInvocationStop({
caughtError: refreshError, signal, parentAbortObserved, activeRun, agentController, watchdog,
})
error = stop.error
}
}
// Failure and stop notices take the turn's slot in the timeline.
attachErrorMetadata(error, {
runTrace: finishHarness(stop.status),
turnSeq: context.turnSeq,
verificationIdentity,
projectIdentity,
})
throw attachInvocationFailure(error, {
outcomeCertainty: terminalFailureKnown
@@ -103,7 +103,10 @@ const collaborationMethods = {
? new Set(options.selectedEntryIds)
: null
const selectedEntries = selectedEntryIds
? collaboration.entries.filter(entry => selectedEntryIds.has(entry.entryId))
? collaboration.entries.filter(entry => selectedEntryIds.has(entry.entryId)
&& entry.lifecycle.state === 'active'
&& (entry.audience.roles.includes(destination.role)
|| entry.audience.agentKinds.includes(destination.agentKind)))
: visibleBlackboardEntries(collaboration, destination)
const objective = this.collaborationObjective(group, controller, threadRootId)
const expectedOutput = options.expectedOutput
@@ -181,6 +184,7 @@ const collaborationMethods = {
const entryType = options.entryType || 'claim'
const subject = options.subject || `task:${controller.taskId}:conclusion`
const entryRefs = Array.isArray(options.refs) ? options.refs : []
const audience = options.audience || this.collaborationAudience(role)
const duplicateConclusion = conclusion && collaboration.entries.some(entry => (
entry.entryType === entryType
&& entry.lifecycle.state === 'active'
@@ -188,6 +192,8 @@ const collaborationMethods = {
&& entry.value === conclusionValue
&& entry.owner.type === 'agent'
&& entry.owner.agentKind === kind
&& (!options.deduplicateByAgentRun || (entry.provenance.runId === provenance.runId
&& entry.provenance.agentRunId === provenance.agentRunId))
))
if (conclusion && !duplicateConclusion) {
collaboration = appendBlackboardEntry(collaboration, {
@@ -196,7 +202,7 @@ const collaborationMethods = {
statement: conclusion,
value: entryType === 'claim' ? conclusionValue : '',
owner: { type: 'agent', agentKind: kind, role },
audience: this.collaborationAudience(role),
audience,
lifecycle: {
state: 'active', sequence, recordedAt, supersedesEntryId: null,
},
@@ -205,14 +211,14 @@ const collaborationMethods = {
})
sequence = collaboration.entries.length + 1
}
for (const artifactId of duplicateConclusion ? [] : provenance.artifactIds.slice(0, 1)) {
for (const artifactId of duplicateConclusion ? [] : options.allOutcomeRefs ? provenance.artifactIds : provenance.artifactIds.slice(0, 1)) {
collaboration = appendBlackboardEntry(collaboration, {
entryType: 'artifact-ref',
subject: `task:${controller.taskId}:artifact`,
statement: 'Durable Artifact produced by the Agent.',
value: '',
owner: { type: 'agent', agentKind: kind, role },
audience: this.collaborationAudience(role),
audience,
lifecycle: {
state: 'active', sequence, recordedAt, supersedesEntryId: null,
},
@@ -221,14 +227,14 @@ const collaborationMethods = {
})
sequence = collaboration.entries.length + 1
}
for (const evidenceId of duplicateConclusion ? [] : provenance.evidenceIds.slice(0, 1)) {
for (const evidenceId of duplicateConclusion ? [] : options.allOutcomeRefs ? provenance.evidenceIds : provenance.evidenceIds.slice(0, 1)) {
collaboration = appendBlackboardEntry(collaboration, {
entryType: 'evidence-ref',
subject: `task:${controller.taskId}:evidence`,
statement: 'Durable Evidence recorded for the Agent conclusion.',
value: '',
owner: { type: 'agent', agentKind: kind, role },
audience: this.collaborationAudience(role),
audience,
lifecycle: {
state: 'active', sequence, recordedAt, supersedesEntryId: null,
},
@@ -0,0 +1,166 @@
const { hashValue } = require('../collaboration/orchestration-v4-records.cjs')
const { createHash } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { explicitTaskFailure, explicitReviewRejection } = require('./local-workspace-auto-runner-natural-decisions.cjs')
const { parseLenientTaskDecision } = require('../collaboration/task-decision.cjs')
// Rebuild consensus from committed, operation-bound messages. No in-memory vote
// can survive a crash without its corresponding durable Agent response.
const naturalConsensusMethods = {
v4NaturalConsensus(group, controller, threadRootId) {
const messages = this.state().messages.filter(message => (
message.groupId === group.id && message.threadRootId === threadRootId
&& message.role === 'agent'
&& this.v4NaturalMessageMatchesBinding(message, controller)
)).sort((a, b) => (a.turnSeq || 0) - (b.turnSeq || 0))
let candidate = null
const files = new Map()
for (const message of messages) {
const artifactIds = message.trace?.context?.outcomeRefs?.artifactIds || []
for (const artifactId of artifactIds) {
let identity
try { identity = this.v4ArtifactIdentity(artifactId) } catch { return null }
const { artifact, contentHash } = identity
if (artifact.producedBy?.runId !== message.trace?.runId
|| artifact.producedBy?.agentRunId !== message.trace?.agentRunId
|| artifact.producedBy?.agentKind !== message.agentKind
|| !message.trace?.runId || !message.trace?.agentRunId) return null
if (artifact.locationRef?.kind === 'workspace-relative') {
if (candidate && !candidate.files.some(file => file.path === artifact.locationRef.path
&& file.contentHash === contentHash)) candidate = null
files.set(artifact.locationRef.path, { path: artifact.locationRef.path,
artifactId, contentHash, size: artifact.contentRef.size })
}
}
if (message.trace?.phase !== 'discussion') continue
const receipt = message.trace?.context?.taskDecision
let decision
if (receipt) {
try { decision = parseLenientTaskDecision(receipt) } catch { candidate = null; continue }
}
const contribution = this.v4NaturalMessageContent(message)
if (contribution.partial || !contribution.text.trim() || explicitTaskFailure(contribution.text)
|| explicitTaskFailure(decision?.reason)) {
candidate = null
continue
}
// A later unclassified contribution may withdraw or revise an earlier
// approval. It cannot safely inherit the previous candidate's votes.
if (!decision) {
candidate = null
continue
}
if (decision.status !== 'completed') {
candidate = null
continue
}
if (decision.reviewedVersion) {
if (explicitReviewRejection(contribution.text) || explicitReviewRejection(decision.reason)) {
candidate = null
continue
}
if (candidate && decision.reviewedVersion === candidate.version) {
candidate.approvedKinds.add(message.agentKind)
} else if (candidate?.approvedKinds.has(message.agentKind)) {
if (candidate.message.agentKind === message.agentKind) candidate = null
else candidate.approvedKinds.delete(message.agentKind)
}
continue
}
// An unbound confirmation cannot replace the version being reviewed.
// Revisions must first withdraw it with a non-completed decision.
if (candidate) {
if (candidate.approvedKinds.has(message.agentKind)) {
if (candidate.message.agentKind === message.agentKind) candidate = null
else candidate.approvedKinds.delete(message.agentKind)
}
continue
}
const candidateFiles = [...files.values()]
candidate = {
version: hashValue({ messageId: message.id, text: contribution.text, artifactIds, files: candidateFiles }),
message,
text: contribution.text,
artifactIds,
files: candidateFiles,
approvedKinds: new Set([message.agentKind]),
}
}
// Votes refer to captured bytes, not just a filename. Workspace edits made
// outside the discussion must not inherit approval of the previous bytes.
if (candidate?.files.length) {
try {
const root = fs.realpathSync(group.workdir)
for (const file of candidate.files) {
const filename = fs.realpathSync(path.resolve(root, file.path))
const relative = path.relative(root, filename)
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`)
|| path.isAbsolute(relative) || fs.statSync(filename).size !== file.size
|| createHash('sha256').update(fs.readFileSync(filename)).digest('hex') !== file.contentHash) return null
}
} catch { return null }
}
return candidate
},
v4NaturalConsensusPrompt(group, controller, threadRootId) {
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
if (!candidate) return ''
return [
`Candidate version: ${candidate.version}`,
`Proposed by @${candidate.message.agentKind}. Review this exact complete candidate, including its deliverable files, before accepting.`,
`Candidate deliverables: ${JSON.stringify(candidate.message.trace.context.taskDecision.deliverables)}`,
`Immutable artifact references: ${JSON.stringify(candidate.artifactIds)}`,
`Deliverable file versions (read each file and verify its contents): ${JSON.stringify(candidate.files)}`,
'Complete candidate answer (not a transcript excerpt):',
candidate.text,
`To accept this version, use status completed and reviewedVersion "${candidate.version}" in your receipt. Do not rewrite or replace the candidate while accepting it.`,
'If anything needs changing, use status continue, explain the defect and request the peer who should fix it. This withdraws the candidate and all its approvals; the revised result needs fresh approval from everyone.',
'Do not modify this candidate while accepting it. Missing, stale or implicit approval never counts as acceptance.',
].join('\n\n')
},
async v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds) {
const messages = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
if (candidate) {
const missing = controller.targetKinds.filter(kind => !candidate.approvedKinds.has(kind))
if (!missing.length) return { status: 'completed' }
const available = missing.filter(kind => activeKinds.includes(kind))
if (available.length) return { nextKinds: available }
return { status: 'partial' }
}
const decisionMessage = messages.at(-1)
const decision = decisionMessage?.trace?.context?.taskDecision
const route = this.v4NaturalRouteDecision(decision, activeKinds)
if (route.dropped.length) {
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoRouteIgnored',
'Requests for unavailable participants were skipped.',
{ agents: route.dropped.map(kind => this.agentLabel(kind)).join(', ') })
}
if (decision?.status === 'needs-human' && this.requestTaskDecision) {
const response = await this.requestTaskDecision(group, controller, decisionMessage)
if (response?.status === 'approved') return { nextKinds: [decisionMessage.agentKind] }
if (response?.status === 'rejected') {
controller.stopReason = 'human_gate_rejected'
return { status: 'stopped' }
}
}
if (['blocked', 'needs-human'].includes(decision?.status)) {
this.addMessage(group.id, 'system', decision.reason, '', threadRootId, {
key: decision.status === 'needs-human' ? 'system.autoTaskNeedsHuman' : 'system.autoTaskBlocked',
params: { reason: decision.reason },
})
return { status: 'partial' }
}
const nextKinds = this.v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds)
if (nextKinds.length) return { nextKinds }
// A missing receipt is not approval. Ask the current speaker to finish
// their handoff or propose a concrete candidate instead of inventing votes.
return { nextKinds: [], fallbackKind: activeKinds.includes(decisionMessage?.agentKind)
? decisionMessage.agentKind : activeKinds[0] }
},
}
module.exports = { naturalConsensusMethods }
@@ -1,5 +1,35 @@
const { parseLenientTaskDecision } = require('../collaboration/task-decision.cjs')
function explicitReviewRejection(text) {
let inCodeBlock = false
return String(text || '').split(/\r?\n/).some(rawLine => {
const line = rawLine.trim()
if (/^(?:```|~~~)/.test(line)) { inCodeBlock = !inCodeBlock; return false }
if (inCodeBlock || line.startsWith('>')) return false
return /(?:^|[.!?;;。!?])\s*(?:(?:i|we)\s+)?(?:do not|don't|cannot|can't)\s+approve\b/i.test(line)
|| /(?:^|[.!?;;。!?])\s*(?:我|我们)?(?:不|不要|不能|不应|不予|无法)批准/u.test(line)
})
}
function explicitTaskFailure(text) {
let inCodeBlock = false
return String(text || '').split(/\r?\n/).some(rawLine => {
const line = rawLine.trim()
if (/^(?:```|~~~)/.test(line)) { inCodeBlock = !inCodeBlock; return false }
if (inCodeBlock || line.startsWith('>')) return false
const statement = line.replace(/^(?:[-*+]\s+|\d+[.)]\s+)/, '')
.replace(/^(?:sorry|unfortunately)[,:,]\s*/i, '')
.replace(/^(?:抱歉|很抱歉|对不起)[,,::]\s*/u, '')
.replace(/^(?:由于|因为)(?:权限不足|访问被拒绝)[,,::]\s*/u, '')
.replace(/^(?:error|fatal):\s*/i, '')
return /^(?:permission denied|access denied)[.!。!\s]*$/i.test(statement)
|| /^(?:permission denied|access denied)\s*[::]\s*\S/i.test(statement)
|| /^(?:权限不足|访问被拒绝)[.!。!\s]*$/u.test(statement)
|| /^(?:(?:permission denied|access denied)[.:;,,\s]+)?(?:i|we)\s+(?:cannot|can't|could not|couldn't|am unable to|are unable to|was unable to|were unable to|failed to)\s+(?:access|open|read|execute|perform|complete|finish)\b/i.test(statement)
|| /^(?:(?:权限不足|访问被拒绝)[,,::;;\s]+)?(?:我|我们)?(?:无法|不能|没法|未能)(?:访问|打开|读取|执行|完成)/u.test(statement)
})
}
const v4NaturalDecisionMethods = {
v4NaturalRouteDecision(decision, activeKinds) {
const participants = Array.isArray(activeKinds) ? activeKinds : []
@@ -11,7 +41,7 @@ const v4NaturalDecisionMethods = {
// A request for an absent or paused participant is dropped on its own;
// it used to stop the whole discussion with a misleading message.
const dropped = requested.filter(kind => !participants.includes(kind))
const kinds = participants.filter(kind => requested.includes(kind))
const kinds = requested.filter(kind => participants.includes(kind))
return { status: kinds.length ? 'valid' : 'none', kinds, dropped }
},
@@ -54,7 +84,10 @@ const v4NaturalDecisionMethods = {
const peerBlocker = messages.find(message => {
if (message.agentKind === owner) return false
const status = message.trace?.context?.taskDecision?.status
const contribution = this.v4NaturalMessageContent(message)
return status === 'blocked' || status === 'needs-human'
|| contribution.partial || explicitTaskFailure(contribution.text)
|| explicitTaskFailure(message.trace?.context?.taskDecision?.reason)
})
const ownerSawBlocker = peerBlocker && ownerMessage
&& controller.discussionStyle === 'sequential'
@@ -63,12 +96,13 @@ const v4NaturalDecisionMethods = {
const peerDecision = peerBlocker.trace?.context?.taskDecision
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoPeerBlockerForwarded',
`${this.agentLabel(peerBlocker.agentKind)} reported a blocker; the delivery owner will decide how to proceed.`,
{ agent: this.agentLabel(peerBlocker.agentKind), reason: peerDecision.reason })
{ agent: this.agentLabel(peerBlocker.agentKind), reason: peerDecision?.reason || '' })
return { nextKinds: [owner], ownerReview: true }
}
const nextKinds = this.v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds)
if (nextKinds.length) return { nextKinds, ownerReview: false }
const decision = ownerMessage?.trace?.context?.taskDecision
let decision
try { decision = parseLenientTaskDecision(ownerMessage?.trace?.context?.taskDecision) } catch { /* ask for a valid receipt */ }
if (decision?.handoffTo && !activeKinds.includes(decision.handoffTo)) {
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoHandoffIgnored',
`The handoff to ${this.agentLabel(decision.handoffTo)} was ignored because that Agent is not active; ${this.agentLabel(owner)} remains the delivery owner.`,
@@ -92,6 +126,16 @@ const v4NaturalDecisionMethods = {
return { status: 'partial' }
}
if (decision.status === 'completed') {
const contribution = this.v4NaturalMessageContent(ownerMessage)
if (contribution.partial || !contribution.text.trim() || explicitTaskFailure(contribution.text)
|| explicitTaskFailure(decision.reason)) {
return { nextKinds: [owner], ownerReview: true }
}
const unresolved = this.v4NaturalUnresolvedBlockers(group, controller, threadRootId, round)
if (unresolved.length) {
const available = unresolved.filter(kind => activeKinds.includes(kind))
return available.length ? { nextKinds: available, ownerReview: true } : { status: 'partial' }
}
// The owner may not close the task before selected peers had one discussion
// round of their own; otherwise a single full round collapses into an
// owner-only review and peers never get to challenge the result.
@@ -127,6 +171,37 @@ const v4NaturalDecisionMethods = {
return { status: 'partial' }
},
v4NaturalUnresolvedBlockers(group, controller, threadRootId, round) {
const unresolved = new Set()
const messages = this.state().messages.filter(message => (
message.groupId === group.id && message.threadRootId === threadRootId
&& message.role === 'agent' && message.trace?.phase === 'discussion'
&& Number(message.trace?.round) <= round
&& this.v4NaturalMessageMatchesBinding(message, controller)
)).map((message, index) => ({ message, index })).sort((left, right) => (
Number(left.message.trace.round) - Number(right.message.trace.round)
|| (Number(left.message.turnSeq) || 0) - (Number(right.message.turnSeq) || 0)
|| left.index - right.index
))
for (const { message } of messages) {
const contribution = this.v4NaturalMessageContent(message)
if (contribution.partial || explicitTaskFailure(contribution.text)
|| explicitTaskFailure(message.trace?.context?.taskDecision?.reason)) {
unresolved.add(message.agentKind)
continue
}
let status
try { status = parseLenientTaskDecision(message.trace?.context?.taskDecision).status } catch { continue }
if (status === 'blocked' || status === 'needs-human') unresolved.add(message.agentKind)
else if (status === 'completed') {
if (!contribution.partial && contribution.text.trim()) {
unresolved.delete(message.agentKind)
}
}
}
return [...unresolved]
},
v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds) {
const current = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
const selected = new Set(current.flatMap(message => (
@@ -134,7 +209,7 @@ const v4NaturalDecisionMethods = {
.filter(kind => kind !== message.agentKind)
)))
if (!selected.size) return []
return activeKinds.filter(kind => selected.has(kind))
return [...selected]
},
v4NaturalPriorPeerDiscussion(group, controller, threadRootId, round, owner) {
@@ -162,7 +237,8 @@ const v4NaturalDecisionMethods = {
&& this.v4NaturalMessageMatchesBinding(message, controller)
))
const previous = earlier.at(-1)
return Boolean(previous && !previous.trace?.context?.taskDecision)
if (!previous) return false
try { parseLenientTaskDecision(previous.trace?.context?.taskDecision); return false } catch { return true }
},
// With activeKinds, an owner who failed or was paused hands ownership to
@@ -200,9 +276,19 @@ const v4NaturalDecisionMethods = {
|| messageRound > round || !this.v4NaturalMessageMatchesBinding(message, controller)) continue
const contribution = this.v4NaturalMessageContent(message)
if (contribution.partial) return false
const content = contribution.text
const text = contribution.text
.replace(/@[A-Za-z0-9][A-Za-z0-9_-]*/gu, '')
.replace(/\s+/gu, ' ').trim()
const files = []
for (const artifactId of message.trace?.context?.outcomeRefs?.artifactIds || []) {
try {
const { artifact, contentHash } = this.v4ArtifactIdentity(artifactId)
if (artifact.locationRef?.kind === 'workspace-relative') {
files.push([artifact.locationRef.path, contentHash])
}
} catch { return false }
}
const content = JSON.stringify([text, message.trace?.context?.taskDecision?.reviewedVersion || '', files.sort()])
if (previous.has(message.agentKind) && previous.get(message.agentKind) !== content) return false
if (messageRound >= firstRound) {
observedRounds.add(messageRound)
@@ -226,4 +312,4 @@ const v4NaturalDecisionMethods = {
},
}
module.exports = { v4NaturalDecisionMethods }
module.exports = { v4NaturalDecisionMethods, explicitTaskFailure, explicitReviewRejection }
@@ -27,6 +27,9 @@ const naturalDiscussionMethods = {
v4NaturalMessageContent(message) {
const text = String(message.content || '')
if (message.trace?.status && message.trace.status !== 'completed') {
return { text, partial: true }
}
if (text.length < MAX_MESSAGE_TEXT_CHARS && message.trace?.truncated !== true) {
return { text, partial: false }
}
@@ -87,6 +90,12 @@ const naturalDiscussionMethods = {
return packNaturalDiscussionTranscript(this.v4NaturalThreadEntries(group, threadRootId, options))
},
v4NaturalFailureStatus(group, controller, threadRootId, result) {
const hasEarlierResult = this.v4NaturalThreadEntries(group, threadRootId, { controller })
.some(entry => !entry.partial && entry.text.trim())
return result.phasePendingMessages?.length || hasEarlierResult ? 'partial' : 'failed'
},
v4NaturalDeliveryPrompt(group, controller, threadRootId, kind, slot, binding, promptBuilder) {
const entries = this.v4NaturalThreadEntries(group, threadRootId, { controller })
const previousEntries = binding.hasSession && !binding.sessionRotated
@@ -104,7 +113,12 @@ const naturalDiscussionMethods = {
const unseen = entries.filter(entry => !seen.has([
entry.agentKind, entry.phase, entry.id, entry.hash,
].join('\u0000')))
const packed = packNaturalDiscussionTranscript(unseen, {
const fullOpening = controller.discussionStyle === 'agent-led' && controller.currentRound === 2
? entries.filter(entry => entry.phase === 'proposal') : null
const packed = fullOpening ? {
text: fullOpening.map(entry => `Round 1 - @${entry.agentKind} [${entry.id}]\n${entry.text}`).join('\n\n'),
completeMessageIds: fullOpening.filter(entry => !entry.partial).map(entry => entry.id),
} : packNaturalDiscussionTranscript(unseen, {
withEntries: true,
prefix: unseen.length < entries.length
? 'Previously delivered turns are not repeated here. They were supplied to this native session; ask peers to restate missing evidence if your current context is incomplete. Their omission does not imply agreement or task completion.'
@@ -237,9 +251,10 @@ const naturalDiscussionMethods = {
activeKinds,
transcript: String(options.transcript || ''),
humanResponsesText: humanResponses.length ? packNaturalDiscussionTranscript(humanResponses) : '',
writerKind: options.writerKind,
writerKind: group.allowWrite === true ? kind : '',
ownerKind: options.ownerKind,
allowRouting: options.allowRouting === true,
consensusText: options.consensusText || '',
})
},
@@ -336,7 +351,7 @@ const naturalDiscussionMethods = {
const failedKinds = removePhaseFailures(dispatch)
activeKinds = activeKinds.filter(agentKind => !failedKinds.includes(agentKind))
if (!activeKinds.length) {
return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
return this.v4NaturalFailureStatus(group, controller, threadRootId, dispatch)
}
}
pendingKinds = remainingKinds.filter(agentKind => activeKinds.includes(agentKind))
@@ -356,7 +371,7 @@ const naturalDiscussionMethods = {
async runV4AgentLedDiscussion(group, controller, threadRootId, context, input) {
const {
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
targetKinds, activeKinds: initialActiveKinds, batchId,
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
latestFor, runPhase, checkpointPhase, removePhaseFailures, addRoundLimitNotice,
} = input
@@ -372,7 +387,7 @@ const naturalDiscussionMethods = {
const failedKinds = removePhaseFailures(proposal)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
checkpointPhase('proposal', [], slots, null)
if (!activeKinds.length) return proposal.phasePendingMessages?.length ? 'partial' : 'failed'
if (!activeKinds.length) return this.v4NaturalFailureStatus(group, controller, threadRootId, proposal)
}
}
const proposalComplete = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
@@ -393,9 +408,9 @@ const naturalDiscussionMethods = {
if (this.v4NaturalStopRepeatingDiscussion(
group, controller, threadRootId, round, activeKinds,
)) return 'partial'
const outcome = await this.v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds)
const outcome = await this.v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds)
if (outcome.status) return outcome.status
nextKinds = outcome.nextKinds
nextKinds = outcome.nextKinds.length ? outcome.nextKinds : [outcome.fallbackKind]
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
@@ -404,20 +419,22 @@ const naturalDiscussionMethods = {
}
} else {
round = 2
// The first discussion round is a full participant round: every selected
// Agent reacts to the proposal set before the delivery owner reviews and
// judges completion, so a group run cannot collapse to owner-only turns.
nextKinds = [...activeKinds]
nextKinds = [activeKinds[0]]
}
while (!controller.signal.aborted) {
controller.currentRound = round
const kind = nextKinds[0]
const remainingKinds = nextKinds.slice(1)
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
const turnWriterKind = !candidate && group.allowWrite === true && this.canWriteWorkspace(kind)
? kind : ''
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
phase: 'discussion',
activeKinds: nextKinds,
activeKinds: [kind],
targetKinds,
participantKinds: activeKinds,
writerKind,
writerKind: turnWriterKind,
batchId,
snapshot,
snapshotRecord,
@@ -428,10 +445,10 @@ const naturalDiscussionMethods = {
coordinationPlan: null,
workReceipts: [],
operationIds: this.v4NaturalDiscussionOperationIds(
controller, slots, nextKinds, round,
controller, slots, [kind], round,
),
pendingKinds: nextKinds,
remainingKinds: [],
remainingKinds,
sessionPolicy: null,
resumedGate: input.resumedGate,
disableDeliveryPrompt: true,
@@ -442,8 +459,8 @@ const naturalDiscussionMethods = {
skillHints: context.rootSkillsByKind.get(kind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
allowRouting: true,
ownerKind: this.v4NaturalOwner(group, controller, threadRootId, activeKinds),
writerKind,
writerKind: turnWriterKind,
consensusText: this.v4NaturalConsensusPrompt(group, controller, threadRootId),
},
),
})
@@ -452,14 +469,20 @@ const naturalDiscussionMethods = {
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const failedKinds = removePhaseFailures(dispatch)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
if (!activeKinds.length) return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
if (!activeKinds.length) return this.v4NaturalFailureStatus(group, controller, threadRootId, dispatch)
}
if (this.v4NaturalStopRepeatingDiscussion(
group, controller, threadRootId, round, activeKinds,
)) return 'partial'
const outcome = await this.v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds)
const outcome = await this.v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds)
if (outcome.status) return outcome.status
nextKinds = outcome.nextKinds
// A new candidate starts its own confirmation queue. An objection
// cancels pending approvals; otherwise preserve outstanding invitations.
nextKinds = this.v4NaturalConsensus(group, controller, threadRootId) || candidate
? outcome.nextKinds
: [...new Set([...outcome.nextKinds, ...remainingKinds])]
nextKinds = nextKinds.filter(agentKind => activeKinds.includes(agentKind))
if (!nextKinds.length) nextKinds = [outcome.fallbackKind || activeKinds[0]]
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
@@ -471,19 +494,20 @@ const naturalDiscussionMethods = {
},
async runV4NaturalDiscussion(group, controller, threadRootId, context, input) {
if (input.resume && controller.orchestration?.phase === 'discussion') {
if (input.resume && ['proposal', 'discussion'].includes(controller.orchestration?.phase)) {
for (const slot of input.slots) {
if (slot.permission !== 'workspace-write' || !slot.agentRunId
|| slot.commitStatus === 'committed'
|| !['running', 'waiting', 'stopped', 'failed'].includes(slot.status)) continue
if (this.v4NaturalRecoveredDiscussionResult(group, controller, threadRootId, slot.agentKind, slot)) continue
if (this.v4NaturalRecoveredPhaseResult(group, controller, threadRootId,
controller.orchestration.phase, slot.agentKind, slot)) continue
if (input.resumedGate?.operationId === slot.operationId
&& input.resumedGate.agentKind === slot.agentKind
&& input.resumedGate.status === 'approved') continue
if (!this.requestHumanGate) throw new Error('LOCAL_RUN_RETRY_GATE_INVALID')
const binding = {
agentRunId: slot.agentRunId, agentKind: slot.agentKind,
round: controller.currentRound, phase: 'discussion', slotId: slot.slotId,
round: controller.currentRound, phase: controller.orchestration.phase, slotId: slot.slotId,
operationId: slot.operationId, snapshotHash: input.snapshotHash,
}
const request = {
@@ -494,7 +518,7 @@ const naturalDiscussionMethods = {
const gate = await this.requestHumanGate({
type: 'retry', runId: controller.runId, agentRunId: slot.agentRunId,
agentKind: slot.agentKind,
summary: 'The interrupted discussion writer may already have changed the workspace.',
summary: 'The interrupted Agent may already have changed the workspace.',
options: [
{ optionId: 'retry-once', name: 'Retry once', kind: 'allow_once' },
{ optionId: 'cancel-retry', name: 'Do not retry', kind: 'reject_once' },
@@ -512,8 +536,10 @@ const naturalDiscussionMethods = {
return 'stopped'
}
if (gate.optionId !== 'retry-once') throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
if (this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'completed') !== true
&& this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
input.resumedGate = { ...binding, gateId: gate.gateId, type: 'retry',
status: gate.status, optionId: gate.optionId, request, used: false }
status: gate.status, optionId: gate.optionId, request, used: true }
}
}
if (input.resumedGate?.type === 'retry'
@@ -525,7 +551,7 @@ const naturalDiscussionMethods = {
|| slot.operationId !== gate.operationId
|| slot.snapshotHash !== gate.snapshotHash
|| slot.attempt !== gate.request.attempt) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
if (this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'completed') !== true
if (!gate.used && this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'completed') !== true
&& this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
const style = controller.orchestration?.discussionStyle
@@ -420,7 +420,8 @@ const recoveryMethods = {
// Only explicit sign-in failures reach this point. Mark the Agent as
// needing a fresh login; the catalog clears it on the next refresh
// after the retry window or as soon as native credentials verify.
this.markRuntimeCredential(kind, 'missing')
this.markRuntimeCredential(kind, 'missing', { verificationIdentity: error.verificationIdentity,
workdir: group.workdir, projectIdentity: error.projectIdentity })
this.recordAttempt(group, controller, {
agentKind: kind,
phase,
@@ -22,7 +22,9 @@ const roundsMethods = {
let totalSuccesses = cursor?.totalSuccesses || 0
let consensusReached = false
const reportedFailures = new Set()
const firstRound = resume ? Math.max(0, controller.currentRound - 1) : 0
const currentRound = Number.isSafeInteger(controller.currentRound) && controller.currentRound > 0
? controller.currentRound : 1
const firstRound = resume ? currentRound - 1 : 0
for (
let round = firstRound;
@@ -1,12 +1,15 @@
// V3 task-graph runs. Kept so older runs can be resumed; new runs do not use it.
const { createHash } = require('node:crypto')
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
const {
appendBlackboardEntry,
emptyCollaborationState,
publicCollaborationText,
} = require('../collaboration/collaboration-records.cjs')
const { unlimitedReviewContract } = require('./local-workspace-context.cjs')
const { explicitTaskFailure, explicitReviewRejection } = require('./local-workspace-auto-runner-natural-decisions.cjs')
const {
assertTaskGraphDependencies,
parseTaskGraphCursor,
readyTaskGraphNodes,
terminalTaskGraphState,
@@ -19,6 +22,113 @@ const {
} = require('./local-workspace-auto-runner-support.cjs')
const taskGraphMethods = {
validateTaskGraphRecoveredResults(controller) {
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
const entries = controller.orchestration.collaboration?.entries || []
for (const state of cursor.nodeStates) {
const node = cursor.graph.nodes.find(candidate => candidate.nodeId === state.nodeId)
if (state.status !== 'accepted' && !(node.role === 'human' && state.status === 'rejected')) continue
if (node.role === 'human') {
const option = node.decisionOptions.find(candidate => candidate.optionId === state.decisionOptionId)
const decision = state.entryIds.length === 1
? entries.find(entry => entry.entryId === state.entryIds[0]) : null
if (!option || option.kind !== (state.status === 'accepted' ? 'allow_once' : 'reject_once')
|| decision?.entryType !== 'decision' || decision.lifecycle?.state !== 'active'
|| (state.status === 'rejected' && decision.refs?.length !== 0)
|| decision.owner?.type !== 'harness' || decision.provenance?.runId !== controller.runId
|| decision.provenance.taskId !== controller.taskId
|| decision.provenance.agentRunId !== this.taskGraphDecisionAgentRunId(cursor.graph.graphId, node.nodeId)
|| decision.subject !== `task:${controller.taskId}:node:${node.nodeId}`
|| decision.statement !== `Human selected: ${option.name}`
|| decision.refs.some(id => !this.taskGraphHumanResolvableConflicts(controller, node).some(entry => entry.entryId === id))
|| (node.terminal && state.status === 'accepted'
&& this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration).length > 0)
|| (decision.value !== option.optionId && !(decision.value === ''
&& node.decisionOptions.filter(candidate => candidate.name === option.name).length === 1))) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
continue
}
if (!state.entryIds.length && !node.acceptance.requireConclusion
&& !node.acceptance.minArtifactRefs && !node.acceptance.minEvidenceRefs) continue
const selected = state.entryIds.map(id => entries.find(entry => entry.entryId === id))
if (selected.some(entry => !entry || entry.lifecycle?.state !== 'active')) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
const claims = selected.filter(entry => ['claim', 'decision'].includes(entry.entryType)
&& entry.owner?.type === 'agent' && entry.owner.agentKind === node.agentKind
&& entry.provenance?.runId === controller.runId && entry.provenance.taskId === controller.taskId)
if (claims.length > 1 || (node.acceptance.requireConclusion && claims.length !== 1)) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
const claim = claims[0]
const anchor = claim || selected.find(entry => ['artifact-ref', 'evidence-ref'].includes(entry.entryType)
&& entry.owner?.type === 'agent' && entry.owner.agentKind === node.agentKind
&& entry.provenance?.runId === controller.runId && entry.provenance.taskId === controller.taskId)
if (!anchor?.provenance.agentRunId) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
if (selected.some(entry => entry.provenance?.runId !== controller.runId
|| entry.provenance?.taskId !== controller.taskId
|| entry.provenance?.agentRunId !== anchor.provenance.agentRunId
|| (entry.entryType === 'conflict' ? entry.owner?.type !== 'harness'
: !['claim', 'decision', 'artifact-ref', 'evidence-ref'].includes(entry.entryType)
|| entry.owner?.type !== 'agent' || entry.owner.agentKind !== node.agentKind
|| entry.owner.role !== node.role))) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
const subject = node.role === 'primary'
? (node.claimKey ? `task:${controller.taskId}:claim:${node.claimKey}` : `task:${controller.taskId}:conclusion`)
: `task:${controller.taskId}:node:${node.nodeId}`
const conclusionHash = claim?.statement ? createHash('sha256').update(claim.statement).digest('hex') : ''
if (anchor.owner.role !== node.role || (claim && (claim.subject !== subject
|| claim.entryType !== (node.role === 'arbiter' ? 'decision' : 'claim')))
|| state.conclusionHash !== conclusionHash
|| canonicalJson(state.artifactIds) !== canonicalJson(anchor.provenance.artifactIds)
|| canonicalJson(state.evidenceIds) !== canonicalJson(anchor.provenance.evidenceIds)) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
for (const [type, ids] of [['artifact-ref', state.artifactIds], ['evidence-ref', state.evidenceIds]]) {
const references = selected.filter(entry => entry.entryType === type)
if (references.length > ids.length || references.some((entry, index) => (
entry.subject !== `task:${controller.taskId}:${type === 'artifact-ref' ? 'artifact' : 'evidence'}`
|| entry.refs.length !== 1 || entry.refs[0] !== ids[index]
|| canonicalJson(entry.provenance.artifactIds) !== canonicalJson(state.artifactIds)
|| canonicalJson(entry.provenance.evidenceIds) !== canonicalJson(state.evidenceIds)
))) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
const result = { message: { content: claim?.statement || '', trace: {
status: 'completed', agentRunId: anchor.provenance.agentRunId,
} }, outcomeRefs: { artifactIds: state.artifactIds, evidenceIds: state.evidenceIds } }
const refs = this.taskGraphVerifiedOutcomeRefs(node, result, controller)
if (canonicalJson(refs.artifactIds) !== canonicalJson(state.artifactIds)
|| canonicalJson(refs.evidenceIds) !== canonicalJson(state.evidenceIds)
|| !this.taskGraphResultAccepted(node, result, controller, refs)) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
}
return true
},
restoreTaskGraphOutcomeReferences(group, controller) {
this.validateTaskGraphRecoveredResults(controller)
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
for (const state of cursor.nodeStates) {
if (state.status !== 'accepted') continue
const node = cursor.graph.nodes.find(candidate => candidate.nodeId === state.nodeId)
if (node.role === 'human') continue
const entries = state.entryIds.map(id => controller.orchestration.collaboration.entries.find(entry => entry.entryId === id))
const artifacts = entries.filter(entry => entry.entryType === 'artifact-ref')
const evidence = entries.filter(entry => entry.entryType === 'evidence-ref')
if (artifacts.length === state.artifactIds.length && evidence.length === state.evidenceIds.length) continue
const claim = entries.find(entry => ['claim', 'decision'].includes(entry.entryType))
const anchor = claim || artifacts[0] || evidence[0]
if (!anchor?.provenance?.agentRunId) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
this.applyTaskGraphAgentResult(group, controller, node, {
message: { content: claim?.statement || '', trace: { status: 'completed', agentRunId: anchor.provenance.agentRunId } },
outcomeRefs: { artifactIds: state.artifactIds, evidenceIds: state.evidenceIds },
})
}
},
unresolvedTaskGraphConflicts(collaborationInput = null) {
const collaboration = collaborationInput || emptyCollaborationState()
const resolved = new Set(collaboration.entries
@@ -48,9 +158,17 @@ const taskGraphMethods = {
const states = new Map(cursor.nodeStates.map(state => [state.nodeId, state]))
const selected = node.inputNodeIds.flatMap(nodeId => states.get(nodeId)?.entryIds || [])
if (['reviewer', 'arbiter'].includes(node.role)) {
selected.push(...this.unresolvedTaskGraphConflicts(
controller.orchestration.collaboration,
).map(entry => entry.entryId))
for (const conflict of this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)) {
selected.push(conflict.entryId, ...conflict.refs)
for (const state of cursor.nodeStates) {
if (state.status === 'accepted' && state.entryIds.some(id => conflict.refs.includes(id))) {
const outcomes = controller.orchestration.collaboration.entries.filter(entry => (
state.entryIds.includes(entry.entryId) && ['artifact-ref', 'evidence-ref'].includes(entry.entryType)
))
selected.push(...outcomes.map(entry => entry.entryId))
}
}
}
}
return [...new Set(selected)]
},
@@ -65,10 +183,40 @@ const taskGraphMethods = {
`Acceptance criteria: ${this.taskGraphAcceptanceCriteria(node).join(' | ')}`,
unlimitedReviewContract(unlimitedRounds),
collaborationPackage.text,
'Meldwork records your completed response as an Artifact and associated Evidence. Do not search for, invent, or output internal Artifact or Evidence IDs.',
'Return the requested result directly. Completion is evaluated from durable typed records; do not emit a consensus marker.',
].join('\n')
},
taskGraphReviewOutcomeText(entries) {
const lines = []
let remaining = 24000
for (const entry of entries) {
if (!['artifact-ref', 'evidence-ref'].includes(entry.entryType)) continue
const id = entry.refs?.[0]
let label
let raw
if (entry.entryType === 'artifact-ref') {
const identity = this.v4ArtifactIdentity(id)
label = `Artifact ${id}`
raw = identity.artifact.contentRef?.mediaType === 'text/plain'
? identity.content : '[Non-text artifact; content unavailable in this handoff.]'
} else {
const evidence = this.v4EvidenceRecord(id)
label = `Evidence ${id} (${evidence.kind}, ${evidence.level})`
raw = evidence.summary
}
const limit = Math.max(0, Math.min(6000, remaining))
if (!limit) { lines.push('[Additional outcome content truncated.]'); break }
const content = publicCollaborationText(raw || '', limit + 1)
const truncated = content.length > limit
const shown = content.slice(0, limit)
lines.push(`${label}: ${shown}${truncated ? ' [Content truncated.]' : ''}`)
remaining -= shown.length
}
return lines.length ? `Verified outcome content:\n${lines.join('\n')}\nDo not assume truncated or unavailable content was reviewed.` : ''
},
checkpointTaskGraph(group, controller, cursorInput, options = {}) {
let taskGraph = parseTaskGraphCursor(cursorInput)
taskGraph = parseTaskGraphCursor({
@@ -96,36 +244,150 @@ const taskGraphMethods = {
return taskGraph
},
taskGraphResultAccepted(node, result) {
taskGraphVerifiedOutcomeRefs(node, result, controller) {
const artifacts = new Map()
const evidence = new Set()
for (const id of new Set(result?.outcomeRefs?.artifactIds || [])) {
try {
const identity = this.v4ArtifactIdentity(id)
const producer = identity.artifact.producedBy
if (producer.runId === controller?.runId && producer.agentRunId === result?.message?.trace?.agentRunId
&& producer.agentKind === node.agentKind) artifacts.set(id, identity)
} catch { /* Missing or invalid outcomes cannot satisfy acceptance. */ }
}
for (const id of new Set(result?.outcomeRefs?.evidenceIds || [])) {
try {
const record = this.v4EvidenceRecord(id)
const recorder = record.recordedBy || {}
const authorized = recorder.kind === 'system' ? recorder.actorId === 'meldwork-main'
: recorder.kind === 'agent' && recorder.runId === controller?.runId
&& recorder.agentRunId === result?.message?.trace?.agentRunId
&& recorder.agentKind === node.agentKind
const bound = [...artifacts].some(([artifactId, identity]) => (
(record.subject?.type === 'artifact' && record.subject.artifactId === artifactId
|| record.refs.some(reference => reference.type === 'artifact' && reference.artifactId === artifactId))
&& record.refs.some(reference => reference.type === 'blob'
&& reference.contentHash === identity.contentHash
&& canonicalJson(reference.contentRef) === canonicalJson(identity.artifact.contentRef))
))
if (authorized && bound) {
evidence.add(id)
}
} catch { /* Missing or invalid evidence cannot satisfy acceptance. */ }
}
return { artifactIds: [...artifacts.keys()], evidenceIds: [...evidence] }
},
taskGraphResultAccepted(node, result, controller, verifiedRefs = null) {
const conclusion = publicCollaborationText(result?.message?.content || '')
const artifactIds = result?.outcomeRefs?.artifactIds || []
const evidenceIds = result?.outcomeRefs?.evidenceIds || []
return (!node.acceptance.requireConclusion || Boolean(conclusion))
&& artifactIds.length >= node.acceptance.minArtifactRefs
&& evidenceIds.length >= node.acceptance.minEvidenceRefs
const refs = verifiedRefs || this.taskGraphVerifiedOutcomeRefs(node, result, controller)
return result?.message?.trace?.status === 'completed'
&& !explicitTaskFailure(result?.message?.content)
&& (!['reviewer', 'arbiter'].includes(node.role) || !explicitReviewRejection(result?.message?.content))
&& (!node.acceptance.requireConclusion
|| (Boolean(conclusion) && result.message.trace.truncated !== true))
&& refs.artifactIds.length >= node.acceptance.minArtifactRefs
&& refs.evidenceIds.length >= node.acceptance.minEvidenceRefs
},
applyTaskGraphAgentResult(group, controller, node, result) {
const conflicts = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
const entryIds = this.recordCollaborationResult(
const verifiedRefs = this.taskGraphVerifiedOutcomeRefs(node, result, controller)
const verifiedResult = { ...result, outcomeRefs: verifiedRefs }
const conclusion = publicCollaborationText(result?.message?.content || '')
let accepted = this.taskGraphResultAccepted(node, result, controller, verifiedRefs)
if (node.role === 'arbiter' && conflicts.length && !conclusion) accepted = false
const acceptedDecision = node.role === 'arbiter' && accepted
const subject = node.role === 'primary'
? (node.claimKey ? `task:${controller.taskId}:claim:${node.claimKey}` : `task:${controller.taskId}:conclusion`)
: `task:${controller.taskId}:node:${node.nodeId}`
const agentRunId = result?.message?.trace?.agentRunId
const previous = agentRunId ? controller.orchestration.collaboration.entries.filter(entry => (
entry.provenance.runId === controller.runId && entry.provenance.agentRunId === agentRunId
)) : []
if (previous.length) {
const primary = previous.filter(entry => ['claim', 'decision'].includes(entry.entryType))
const artifactEntries = previous.filter(entry => entry.entryType === 'artifact-ref')
const evidenceEntries = previous.filter(entry => entry.entryType === 'evidence-ref')
const conflictEntries = previous.filter(entry => entry.entryType === 'conflict')
const matchesRefs = (entries, ids) => (entries.length === ids.length || (entries.length === 1 && ids.length > 1))
&& entries.every((entry, index) => entry.refs.length === 1 && entry.refs[0] === ids[index])
const validConflicts = conflictEntries.every(entry => {
const claim = primary[0]
const otherId = entry.refs.find(id => id !== claim?.entryId)
const other = controller.orchestration.collaboration.entries.find(candidate => candidate.entryId === otherId)
return primary.length === 1 && claim.entryType === 'claim' && entry.owner.type === 'harness'
&& entry.lifecycle.state === 'active' && entry.subject === subject && entry.refs.length === 2
&& entry.refs.includes(claim.entryId) && other?.entryType === 'claim'
&& other.subject === subject && other.value !== claim.value
&& other.owner.type === 'agent' && other.owner.agentKind !== node.agentKind
&& entry.provenance.taskId === controller.taskId
&& canonicalJson(entry.provenance.artifactIds) === canonicalJson(verifiedRefs.artifactIds)
&& canonicalJson(entry.provenance.evidenceIds) === canonicalJson(verifiedRefs.evidenceIds)
})
const validReferences = previous.length === primary.length + artifactEntries.length + evidenceEntries.length + conflictEntries.length
&& validConflicts
&& matchesRefs(artifactEntries, verifiedRefs.artifactIds)
&& matchesRefs(evidenceEntries, verifiedRefs.evidenceIds)
&& [...artifactEntries, ...evidenceEntries].every(entry => entry.lifecycle.state === 'active'
&& entry.owner.type === 'agent' && entry.owner.agentKind === node.agentKind && entry.owner.role === node.role
&& entry.provenance.taskId === controller.taskId
&& canonicalJson(entry.provenance.artifactIds) === canonicalJson(verifiedRefs.artifactIds)
&& canonicalJson(entry.provenance.evidenceIds) === canonicalJson(verifiedRefs.evidenceIds)
&& entry.subject === `task:${controller.taskId}:${entry.entryType === 'artifact-ref' ? 'artifact' : 'evidence'}`)
const artifactOnly = !conclusion && !node.acceptance.requireConclusion && primary.length === 0
if (!validReferences || (!artifactOnly && (primary.length !== 1 || primary[0].entryType !== (acceptedDecision ? 'decision' : 'claim')
|| primary[0].subject !== subject || primary[0].statement !== conclusion
|| primary[0].owner.type !== 'agent' || primary[0].owner.agentKind !== node.agentKind
|| primary[0].provenance.taskId !== controller.taskId
|| canonicalJson(primary[0].provenance.artifactIds) !== canonicalJson(verifiedRefs.artifactIds)
|| canonicalJson(primary[0].provenance.evidenceIds) !== canonicalJson(verifiedRefs.evidenceIds)))) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
if (artifactEntries.length < verifiedRefs.artifactIds.length
|| evidenceEntries.length < verifiedRefs.evidenceIds.length) {
let collaboration = controller.orchestration.collaboration
for (const [entries, ids] of [[artifactEntries, verifiedRefs.artifactIds], [evidenceEntries, verifiedRefs.evidenceIds]]) {
for (const id of ids.slice(entries.length)) {
const { entryId, version, recordType, ...template } = entries[0]
collaboration = appendBlackboardEntry(collaboration, {
...template,
refs: [id],
lifecycle: { ...template.lifecycle, sequence: (collaboration.entries.at(-1)?.lifecycle.sequence || 0) + 1,
recordedAt: Date.now(), supersedesEntryId: null },
})
previous.push(collaboration.entries.at(-1))
}
}
this.checkpointOrchestration(group, controller, { collaboration })
}
}
const entryIds = previous.length ? previous.map(entry => entry.entryId) : this.recordCollaborationResult(
group,
controller,
controller.orchestration.activeKinds,
node.agentKind,
result,
verifiedResult,
{
role: node.role,
entryType: node.role === 'arbiter' ? 'decision' : 'claim',
subject: node.role === 'primary'
? `task:${controller.taskId}:conclusion`
: `task:${controller.taskId}:node:${node.nodeId}`,
refs: node.role === 'arbiter' ? conflicts.map(entry => entry.entryId) : [],
audience: {
...this.collaborationAudience(node.role),
agentKinds: [...new Set(controller.orchestration.taskGraph.graph.nodes
.filter(candidate => candidate.inputNodeIds.includes(node.nodeId) && candidate.agentKind)
.map(candidate => candidate.agentKind))],
},
deduplicateByAgentRun: true,
allOutcomeRefs: true,
entryType: acceptedDecision ? 'decision' : 'claim',
subject,
refs: acceptedDecision ? conflicts.map(entry => entry.entryId) : [],
},
)
const accepted = this.taskGraphResultAccepted(node, result)
const conclusion = publicCollaborationText(result?.message?.content || '')
const artifactIds = result?.outcomeRefs?.artifactIds || []
const evidenceIds = result?.outcomeRefs?.evidenceIds || []
if (accepted && node.terminal && node.role !== 'arbiter'
&& this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration).length) {
accepted = false
}
const { artifactIds, evidenceIds } = verifiedRefs
let cursor = updateTaskGraphCursor(
controller.orchestration.taskGraph,
node.nodeId,
@@ -163,17 +425,72 @@ const taskGraphMethods = {
.find(Boolean) || controller.targetKinds[0]
},
taskGraphHumanResolvableConflicts(controller, node) {
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
const selected = new Set(cursor.nodeStates.filter(state => node.inputNodeIds.includes(state.nodeId))
.flatMap(state => state.entryIds))
return (controller.orchestration.collaboration?.entries || []).filter(entry => (
entry.entryType === 'conflict' && entry.lifecycle.state === 'active'
&& entry.refs.every(id => selected.has(id))
))
},
taskGraphHumanContextFingerprint(controller, node) {
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
const selected = new Set(cursor.nodeStates.filter(state => node.inputNodeIds.includes(state.nodeId))
.flatMap(state => state.entryIds))
const entries = controller.orchestration.collaboration?.entries || []
return createHash('sha256').update(canonicalJson({
graphId: cursor.graph.graphId,
nodeId: node.nodeId,
inputNodeIds: node.inputNodeIds,
entries: entries.filter(entry => selected.has(entry.entryId)),
conflicts: this.taskGraphHumanResolvableConflicts(controller, node),
})).digest('hex')
},
validateTaskGraphHumanDecisionContext(controller, node, gateId, decision) {
if (decision.status !== 'approved' || !this.getHumanGateRequest) return
const request = this.getHumanGateRequest(gateId)
// Pending gates created by older versions did not contain a fingerprint.
if (request.contextFingerprint === undefined) return
if (request.contextFingerprint !== this.taskGraphHumanContextFingerprint(controller, node)) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
},
applyTaskGraphHumanDecision(group, controller, node, decision) {
const option = node.decisionOptions.find(candidate => candidate.optionId === decision.optionId)
if (!option) throw new Error('LOCAL_WORKFLOW_DECISION_INVALID')
const accepted = decision.status === 'approved'
const conflicts = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
if (decision.status !== (option.kind === 'allow_once' ? 'approved' : 'rejected')) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
const resolvable = this.taskGraphHumanResolvableConflicts(controller, node)
const resolvableIds = new Set(resolvable.map(entry => entry.entryId))
const unresolved = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
const conflicts = unresolved.filter(entry => resolvableIds.has(entry.entryId))
if (accepted && node.terminal && unresolved.some(entry => !resolvableIds.has(entry.entryId))) {
throw new Error('LOCAL_WORKFLOW_INPUT_UNAVAILABLE')
}
let collaboration = controller.orchestration.collaboration
collaboration = appendBlackboardEntry(collaboration, {
const agentRunId = this.taskGraphDecisionAgentRunId(controller.orchestration.taskGraph.graph.graphId, node.nodeId)
const subject = `task:${controller.taskId}:node:${node.nodeId}`
const existing = collaboration.entries.filter(entry => entry.entryType === 'decision'
&& entry.provenance.agentRunId === agentRunId)
const prior = existing[0]
if (existing.length > 1 || (prior && (prior.provenance.runId !== controller.runId
|| prior.provenance.taskId !== controller.taskId || prior.subject !== subject || prior.owner.type !== 'harness'
|| prior.lifecycle.state !== 'active' || prior.statement !== `Human selected: ${option.name}`
|| (prior.value !== option.optionId && !(prior.value === ''
&& node.decisionOptions.filter(candidate => candidate.name === option.name).length === 1))
|| (!accepted && prior.refs.length)
|| prior.refs.some(id => !resolvableIds.has(id))))) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
if (!prior) collaboration = appendBlackboardEntry(collaboration, {
entryType: 'decision',
subject: `task:${controller.taskId}:node:${node.nodeId}`,
subject,
statement: `Human selected: ${option.name}`,
value: '',
value: option.optionId,
owner: { type: 'harness' },
audience: { roles: ['primary', 'reviewer', 'arbiter'], agentKinds: [] },
lifecycle: {
@@ -186,17 +503,14 @@ const taskGraphMethods = {
runId: controller.runId,
taskId: controller.taskId,
round: controller.currentRound,
agentRunId: this.taskGraphDecisionAgentRunId(
controller.orchestration.taskGraph.graph.graphId,
node.nodeId,
),
agentRunId,
artifactIds: [],
evidenceIds: [],
},
refs: conflicts.map(entry => entry.entryId),
refs: accepted ? conflicts.map(entry => entry.entryId) : [],
})
const decisionEntryId = collaboration.entries.at(-1).entryId
this.checkpointOrchestration(group, controller, { collaboration })
const decisionEntryId = prior?.entryId || collaboration.entries.at(-1).entryId
if (!prior) this.checkpointOrchestration(group, controller, { collaboration })
let cursor = updateTaskGraphCursor(
controller.orchestration.taskGraph,
node.nodeId,
@@ -227,7 +541,14 @@ const taskGraphMethods = {
acceptanceCriteria: this.taskGraphAcceptanceCriteria(node),
},
)
if (collaborationPackage.entries.length !== selectedEntryIds.length) {
throw new Error('LOCAL_WORKFLOW_INPUT_UNAVAILABLE')
}
const isolated = ['reviewer', 'arbiter'].includes(node.role)
if (isolated) {
const outcomes = this.taskGraphReviewOutcomeText(collaborationPackage.entries)
if (outcomes) collaborationPackage.text += `\n${outcomes}`
}
const attachments = node.role === 'primary'
&& !controller.orchestration.attachmentRecipients.includes(node.agentKind)
? context.rootAttachments.map(attachment => attachment.path)
@@ -249,6 +570,7 @@ const taskGraphMethods = {
runtimeInstruction: isolated ? '' : [
`Execute task-graph node ${node.nodeId} as ${node.role}.`,
`Expected output: ${node.expectedOutput}`,
'Meldwork records your completed response as an Artifact and associated Evidence. Do not search for, invent, or output internal Artifact or Evidence IDs.',
'Do not emit a consensus marker.',
].join('\n'),
...(isolated ? {
@@ -269,11 +591,29 @@ const taskGraphMethods = {
async executeTaskGraphHumanNode(group, controller, node) {
if (!this.requestHumanGate) throw new Error('LOCAL_WORKFLOW_DECISION_UNAVAILABLE')
if (node.terminal) {
const resolvable = new Set(this.taskGraphHumanResolvableConflicts(controller, node).map(entry => entry.entryId))
if (this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration).some(entry => !resolvable.has(entry.entryId))) {
throw new Error('LOCAL_WORKFLOW_INPUT_UNAVAILABLE')
}
}
const anchorKind = this.taskGraphDecisionAnchor(controller, node)
const agentRunId = this.taskGraphDecisionAgentRunId(
controller.orchestration.taskGraph.graph.graphId,
node.nodeId,
)
const graph = parseTaskGraphCursor(controller.orchestration.taskGraph)
const selected = new Set(graph.nodeStates.filter(state => node.inputNodeIds.includes(state.nodeId))
.flatMap(state => state.entryIds))
const decisionContext = controller.orchestration.collaboration.entries.filter(entry => (
selected.has(entry.entryId) && entry.entryType === 'claim' && entry.lifecycle.state === 'active'
&& entry.owner.type === 'agent'
)).map(entry => {
const full = publicCollaborationText(entry.statement)
let text = full.slice(0, 2000).trim()
if (/[\uD800-\uDBFF]$/.test(text)) text = text.slice(0, -1).trimEnd()
return { agentKind: entry.owner.agentKind, text, truncated: text.length < full.length }
})
let cursor = updateTaskGraphCursor(
controller.orchestration.taskGraph,
node.nodeId,
@@ -291,6 +631,8 @@ const taskGraphMethods = {
graphId: cursor.graph.graphId,
nodeId: node.nodeId,
inputNodeIds: node.inputNodeIds,
decisionContext,
contextFingerprint: this.taskGraphHumanContextFingerprint(controller, node),
},
}, {
signal: controller.signal,
@@ -302,6 +644,7 @@ const taskGraphMethods = {
round: controller.currentRound,
},
})
this.validateTaskGraphHumanDecisionContext(controller, node, gate.gateId, gate)
const accepted = this.applyTaskGraphHumanDecision(group, controller, node, gate)
if (this.completeHumanGateContinuation?.(
controller.runId,
@@ -314,22 +657,30 @@ const taskGraphMethods = {
},
taskGraphBatch(group, readyNodes) {
// A terminal node must not finish the graph while other ready work can
// still fail; independent nonterminal nodes may continue in parallel.
const nonterminal = readyNodes.filter(node => !node.terminal)
const candidates = nonterminal.length ? nonterminal : readyNodes
const parallel = []
const kinds = new Set()
if (group.allowWrite !== true) {
for (const node of readyNodes) {
for (const node of candidates) {
if (!node.parallel || node.role === 'human' || kinds.has(node.agentKind)) continue
parallel.push(node)
kinds.add(node.agentKind)
}
}
return parallel.length > 1 ? parallel : readyNodes.slice(0, 1)
return parallel.length > 1 ? parallel : candidates.slice(0, 1)
},
async runTaskGraph(group, controller, threadRootId, context) {
while (!controller.signal.aborted) {
let cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
this.restoreTaskGraphOutcomeReferences(group, controller)
let cursor = assertTaskGraphDependencies(controller.orchestration.taskGraph)
const terminalState = terminalTaskGraphState(cursor)
if (cursor.terminalState !== terminalState) {
cursor = this.checkpointTaskGraph(group, controller, cursor)
}
if (terminalState !== 'running') {
cursor = this.checkpointTaskGraph(group, controller, cursor)
break
@@ -341,6 +692,11 @@ const taskGraphMethods = {
this.checkpointTaskGraph(group, controller, cursor)
break
}
if (readyNodes.every(node => node.terminal)
&& cursor.graph.nodes.some(node => !node.terminal
&& cursor.nodeStates.find(state => state.nodeId === node.nodeId)?.status !== 'accepted')) {
throw new Error('LOCAL_WORKFLOW_INVALID')
}
const batch = this.taskGraphBatch(group, readyNodes)
controller.currentRound += 1
if (batch.length === 1 && batch[0].role === 'human') {
@@ -360,45 +716,46 @@ const taskGraphMethods = {
const currentKind = batch.length === 1 ? batch[0].agentKind : ''
cursor = this.checkpointTaskGraph(group, controller, cursor, { currentKind })
this.emitChanged()
const settled = await Promise.allSettled(batch.map(node => (
this.executeTaskGraphAgentNode(
group, controller, threadRootId, context, node, batch.length > 1,
)
)))
for (let index = 0; index < batch.length; index += 1) {
const node = batch[index]
const outcome = settled[index]
if (outcome.status === 'fulfilled' && outcome.value?.result) {
if (outcome.value.result.outcomeRefs?.artifactIds?.length
&& !controller.orchestration.attachmentRecipients.includes(node.agentKind)) {
this.checkpointOrchestration(group, controller, {
attachmentRecipients: [...controller.orchestration.attachmentRecipients, node.agentKind],
})
const settled = await Promise.allSettled(batch.map(async node => {
let outcome
try {
outcome = await this.executeTaskGraphAgentNode(
group, controller, threadRootId, context, node, batch.length > 1,
)
} catch (error) { outcome = { error } }
try {
if (outcome?.result) {
if (outcome.result.outcomeRefs?.artifactIds?.length
&& !controller.orchestration.attachmentRecipients.includes(node.agentKind)) {
this.checkpointOrchestration(group, controller, {
attachmentRecipients: [...controller.orchestration.attachmentRecipients, node.agentKind],
})
}
this.applyTaskGraphAgentResult(group, controller, node, outcome.result)
this.emitChanged()
} else if (!controller.signal.aborted) {
const error = outcome?.error || new Error('LOCAL_AGENT_UNKNOWN_FAILURE')
this.recordAgentFailure(group.id, node.agentKind, error, threadRootId, new Set())
const failed = updateTaskGraphCursor(controller.orchestration.taskGraph, node.nodeId, { status: 'failed' })
this.checkpointTaskGraph(group, controller, failed)
if (!controller.failedKinds.includes(node.agentKind)) controller.failedKinds.push(node.agentKind)
if (circuitBreakerFailure(error)) {
controller.stopReason = 'circuit_breaker'
controller.abort()
} else if (hardBudgetFailure(error)) {
controller.stopReason = 'hard_budget'
controller.abort()
}
this.emitChanged()
}
this.applyTaskGraphAgentResult(group, controller, node, outcome.value.result)
continue
}
const error = outcome.status === 'rejected'
? outcome.reason
: (outcome.value?.error || new Error('LOCAL_AGENT_UNKNOWN_FAILURE'))
if (controller.signal.aborted) continue
this.recordAgentFailure(group.id, node.agentKind, error, threadRootId, new Set())
cursor = updateTaskGraphCursor(
controller.orchestration.taskGraph,
node.nodeId,
{ status: 'failed' },
)
this.checkpointTaskGraph(group, controller, cursor)
if (!controller.failedKinds.includes(node.agentKind)) {
controller.failedKinds.push(node.agentKind)
}
if (circuitBreakerFailure(error)) {
controller.stopReason = 'circuit_breaker'
controller.abort()
} else if (hardBudgetFailure(error)) {
controller.stopReason = 'hard_budget'
controller.abort()
} catch (error) {
return { checkpointError: error }
}
return outcome
}))
for (const outcome of settled) {
if (outcome.status === 'fulfilled' && outcome.value?.checkpointError) throw outcome.value.checkpointError
if (outcome.status === 'rejected') throw outcome.reason
}
controller.currentKind = ''
controller.progress = []
@@ -57,8 +57,13 @@ const v4DeliveryMethods = {
for (const record of receiptRecords) {
const receipt = record?.receipt
if (receipt?.phase !== 'work' || !dependencies.has(receipt.workItemId)) continue
const source = coordinationPlan.assignments.find(item => item.taskId === receipt.workItemId)
if (!source || source.ownerKind !== receipt.agentKind) throw new Error('LOCAL_RUN_V4_REFERENCE_INVALID')
latestDependencies.set(receipt.workItemId, record)
}
if ([...dependencies].some(id => !latestDependencies.has(id))) {
throw new Error('LOCAL_RUN_V4_REFERENCE_INVALID')
}
const dependencyArtifacts = [...latestDependencies.values()]
.sort((left, right) => String(left.receipt.workItemId)
.localeCompare(String(right.receipt.workItemId)))
@@ -103,6 +108,8 @@ const v4DeliveryMethods = {
`Input refs: ${assignment.inputRefs.length ? assignment.inputRefs.join(', ') : '(none)'}`,
`Artifact refs: ${assignment.artifactIds.length ? assignment.artifactIds.join(', ') : '(none)'}`,
`Dependencies: ${assignment.dependsOn.length ? assignment.dependsOn.join(', ') : '(none)'}`,
...(assignment.acceptance ? [`Required Artifact refs: ${assignment.acceptance.minArtifactRefs}; Evidence refs: ${assignment.acceptance.minEvidenceRefs}`] : []),
...(assignment.acceptance?.requiredEvidenceKinds?.length ? [`Required Evidence kinds: ${assignment.acceptance.requiredEvidenceKinds.join(', ')}`] : []),
]
if (inputReferences.length) {
lines.push('MELDWORK_V4_FROZEN_INPUT_REFS_V1')
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more