openyida 2026.10.13-1 → 2026.10.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +26 -1
  2. package/bin/yida.js +32 -2
  3. package/lib/agent/PROTOCOL.md +201 -0
  4. package/lib/agent/ask-human-help.js +19 -0
  5. package/lib/agent/ask-human.js +222 -0
  6. package/lib/agent/cmd.js +405 -0
  7. package/lib/agent/command-receipt.js +84 -0
  8. package/lib/agent/connection-store.js +148 -0
  9. package/lib/agent/diagnostic-log.js +133 -0
  10. package/lib/agent/frozen-bundle.js +298 -0
  11. package/lib/agent/managed-command-map.js +47 -0
  12. package/lib/agent/managed-context.js +40 -0
  13. package/lib/agent/managed-run.js +84 -0
  14. package/lib/agent/mutation-receipt.js +82 -0
  15. package/lib/agent/private-directory.js +63 -0
  16. package/lib/agent/provider-preflight.js +257 -0
  17. package/lib/agent/providers.js +4 -0
  18. package/lib/agent/runtime-catalog.json +42 -0
  19. package/lib/agent/runtime-manifest.js +96 -0
  20. package/lib/agent/runtime-package.js +155 -0
  21. package/lib/agent/stdio.js +321 -0
  22. package/lib/app/check-prd-completeness.js +15 -4
  23. package/lib/app/create-form/batch.js +30 -9
  24. package/lib/app/create-form.js +7 -2
  25. package/lib/app/custom-theme.js +13 -3
  26. package/lib/app/get-schema.js +6 -1
  27. package/lib/app/update-app.js +13 -1
  28. package/lib/app/update-form-config.js +71 -80
  29. package/lib/asset/asset-plan.js +6 -4
  30. package/lib/auth/profile-lock.js +89 -0
  31. package/lib/auth/token-auth.js +57 -6
  32. package/lib/auth/token-store.js +26 -2
  33. package/lib/core/command-manifest.js +195 -118
  34. package/lib/core/locales/en.js +47 -3
  35. package/lib/core/locales/zh.js +47 -3
  36. package/lib/core/managed-policy.js +88 -0
  37. package/lib/core/update.js +3 -1
  38. package/lib/core/utils.js +34 -1
  39. package/lib/design-plan/materialize.js +4 -21
  40. package/lib/design-plan/render-html.js +299 -0
  41. package/lib/page-config/save-share-config.js +16 -5
  42. package/package.json +10 -1
  43. package/yida-skills/SKILL.md +30 -0
  44. package/yida-skills/references/local-agent-diagnostics.md +154 -0
  45. package/yida-skills/references/local-agent-preflight.md +56 -0
  46. package/yida-skills/skills/yida-app/workflow/plan/step-4-deliver.md +11 -3
  47. package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +2 -0
  48. package/yida-skills/skills/yida-document-markdown/SKILL.md +6 -0
  49. package/yida-skills/skills/yida-image-assets/SKILL.md +1 -1
  50. package/yida-skills/skills/yida-tingji/SKILL.md +4 -0
package/README.md CHANGED
@@ -158,7 +158,6 @@ The agent can then call OpenYida commands to create the application, generate so
158
158
  | [Codex](https://openai.com/codex/) | Full support |
159
159
  | [Claude Code](https://claude.ai/code) | Full support |
160
160
  | [MuleRun](https://mulerun.com) | Full support |
161
- | [OpenCode](https://opencode.ai) | Full support |
162
161
  | [Cursor](https://cursor.com/) | Full support |
163
162
  | [Visual Studio Code](https://code.visualstudio.com/) | Full support |
164
163
  | QwenWork(千问办公) | Full support |
@@ -565,6 +564,7 @@ Run `openyida --help` or `openyida <command> --help` for detailed usage.
565
564
  |---------|-------------|
566
565
  | `openyida commands [--json]` | Output machine-readable command manifest |
567
566
  | `openyida agent-capabilities [--json] [--summary-json\|--compact]` | Output one-shot agent capability snapshot |
567
+ | `openyida agent <doctor\|diagnose\|status\|connect\|run\|disconnect\|logout\|logs> [options]` | Connect a local Agent (Go runtime, development preview) |
568
568
  | `openyida a2a <serve\|agent-card> [options]` | Start local read-only A2A adapter or print Agent Card |
569
569
  | `openyida bridge start [--token <pair-token>] [--port 6736] [--origin https://demo.aliwork.com] [--open\|--no-open]` | Start OpenYida local web bridge service |
570
570
  | `openyida copy [--force]` | Copy project working directory |
@@ -633,6 +633,31 @@ Form field definitions can include `alias` or `componentAlias` to populate Yida
633
633
 
634
634
  `openyida integration create` supports form events (`insert`, `update`, `delete`, `comment`) and approval events (`processFinish`, `activityTask`; aliases: `approval`, `approvalNode`). Approval events require `--approval-actions agree,disagree,terminated`; `activityTask` also requires `--approval-node-ids <nodeId,...>`. Passing `--process-code` selects a full graph replacement and now requires explicit `--replace`; it is not a safe update. `integration update` currently performs capability detection only: because full platform `processJson` + `viewJson` readback is unproven, it writes a redacted local probe artifact and returns `PLATFORM_PROBE_REQUIRED` before authentication, spec reading, or remote writes. It does not edit a flow.
635
635
 
636
+ ### Local Agent development preview
637
+
638
+ `openyida agent` is a thin Node launcher for a separately supplied Go runtime. Node does not connect to the agent service or manage provider sessions. Four signed platform packages support macOS/Windows x64 and ARM64; npm installs the package matching the current platform. Node does not discover a Go executable on `PATH` or install provider CLIs.
639
+
640
+ The Local Agent Beta production allowlist contains macOS and Windows only. Linux remains supported by the ordinary OpenYida CLI and by the explicit development-runtime workflow below, but Linux Local Agent packages are not published and production `connect`/`run` are rejected until native acceptance is complete.
641
+
642
+ ```bash
643
+ openyida agent doctor --json
644
+ openyida agent doctor --development-runtime --runtime-path /absolute/path/openyida-runtime --runtime-manifest /absolute/path/runtime-manifest.json
645
+ openyida agent connect --development-runtime --runtime-path /absolute/path/openyida-runtime --runtime-manifest /absolute/path/runtime-manifest.json --endpoint https://agent.example.test --endpoint-id configured-platform-id
646
+ openyida agent run --development-runtime --runtime-path /absolute/path/openyida-runtime --runtime-manifest /absolute/path/runtime-manifest.json --endpoint https://agent.example.test --endpoint-id configured-platform-id --provider qoder --provider-path /absolute/path/qodercli
647
+ ```
648
+
649
+ Development manifest v1 specifies runtime version, protocol `1`, Node-style OS/architecture names, and `binary: {file, sha256}`; it requires the explicit `--development-runtime` flag. This is an integrity check, **not a publisher signature**. The production loader requires a strictly validated v2 manifest, exact approved platform-package version/digest, and an Ed25519 signature over the original manifest bytes using trust roots bundled in the Node host. The bundled catalog pins the approved runtime version, manifest digest and signing public key. See the [launcher contract](lib/agent/PROTOCOL.md).
650
+
651
+ `connect` supports two equivalent enrollment entries. CLI-first opens the Yida browser approval page and returns through a state/PKCE-protected loopback callback. Web-first copies one five-minute command containing `--enroll`; the token is single-use and carries no business authority. Neither path reads or uploads an ordinary OpenYida login profile.
652
+
653
+ One installation may connect to multiple organizations. Node keeps only a shared Installation ID at the installation root; each organization authorization gets an isolated private Connection state directory, device credentials, journals, control socket, and frozen bundle. `agent connect` and `agent run` start an independent background daemon by default and return once it is ready. `status`, `logs`, `disconnect`, and `logout` enumerate valid Connections. Background mode is not boot-time autostart. Each Connection registers all locally discovered Qoder and Codex Runtimes, and the web UI selects computer, CLI, and model.
654
+
655
+ For each attempt, Go injects `OPENYIDA_MANAGED_RUN=1`, the cloud user/corp/app/run/attempt context, and a fixed-value Tianshu task grant with an initial 60-minute lease. The CLI uses that grant instead of local auth and sends scope-correlation headers. While the authenticated device connection and server-side Run remain active, yida-agent can renew the same bearer server-to-server; the CLI cannot renew it and never receives a task refresh token. Tianshu independently checks the grant against the requested app and rejects application creation, identity/environment changes, cross-application requests and unsupported organization-wide operations; declared application-bound batches are supported. `formUuid` remains optional per-turn page-focus context, so one app-bound session can work across pages in that application. Without `OPENYIDA_MANAGED_RUN`, existing login, token refresh, and command behavior are unchanged.
656
+
657
+ Before `connect` or `run`, Node now freezes the verified runtime bytes, CLI, full production dependency closure, and packaged Skills into a private content-addressed bundle. Source changes during copying fail closed. Existing bundles are verified, never silently repaired or garbage-collected by Node, and survive replacement/removal of the original npm directory. Go owns active/session references and version activation; the external Node executable and provider installations are not copied or managed by npm. A separate parent-watch pipe and a 15-second stop grace prevent normal config EOF from being mistaken for parent death; an unconfirmed forced stop remains unknown.
658
+
659
+ This is not an OS sandbox or an authorization boundary against arbitrary shell code. Production signing/release infrastructure, full Go/provider recovery and upgrade acceptance, and OS/architecture-specific provider E2E are not claimed by these fake-process tests. Auto-update is disabled for `agent` and managed invocations; ordinary CLI commands neither spawn nor download the runtime.
660
+
636
661
  ## Agent Skills
637
662
 
638
663
  The `yida-skills/` directory is the source skill library used by OpenYida during development and distributed with the npm package.
package/bin/yida.js CHANGED
@@ -24,6 +24,8 @@ const { COMMAND_GROUPS, buildCommandManifest, findCommandSuggestion } = require(
24
24
  const command = process.argv[2];
25
25
  const args = process.argv.slice(3);
26
26
  const rawArgs = process.argv.slice(3);
27
+ const { beginCommandReceipt } = require('../lib/agent/command-receipt');
28
+ let commandReceipt;
27
29
 
28
30
  function shouldUseEnvManagement(argsList) {
29
31
  const subCommand = argsList[0];
@@ -571,6 +573,26 @@ function assertNoUnsupportedLegacyLoginFlags(...argLists) {
571
573
  }
572
574
 
573
575
  async function main() {
576
+ const { assertManagedInvocation, resolveManagedHelp } = require('../lib/agent/managed-run');
577
+ const { readManagedContext } = require('../lib/agent/managed-context');
578
+ if (readManagedContext() && [command, ...rawArgs].some(arg => arg === '--help' || arg === '-h')) {
579
+ assertManagedInvocation(command, rawArgs);
580
+ const entries = resolveManagedHelp(command, rawArgs, { t, version: currentVersion });
581
+ // Managed help is static and local: no Receipt, auth, update, first-run
582
+ // guide or business command module may run on this path.
583
+ commandReceipt = { finish() {} };
584
+ if (rawArgs.includes('--json')) {
585
+ console.log(JSON.stringify({ name: 'openyida', version: currentVersion, commands: entries }, null, 2));
586
+ } else {
587
+ printCommandUsage(...entries.flatMap(entry => [entry.usage, entry.description, ...(entry.examples || [])]));
588
+ }
589
+ return;
590
+ }
591
+ commandReceipt = beginCommandReceipt({ command, args: rawArgs });
592
+ process.once('exit', code => {
593
+ try {commandReceipt.finish(code);} catch {process.exitCode = 1;}
594
+ });
595
+ assertManagedInvocation(command, rawArgs);
574
596
  applyQuietFlag();
575
597
  applyGlobalEnvironmentFlags();
576
598
 
@@ -619,6 +641,12 @@ async function main() {
619
641
  break;
620
642
  }
621
643
 
644
+ case 'agent': {
645
+ const { run } = require('../lib/agent/cmd');
646
+ await run(args);
647
+ break;
648
+ }
649
+
622
650
  case 'mcp': {
623
651
  const { runStdioServer } = require('../lib/mcp/server');
624
652
  runStdioServer();
@@ -894,8 +922,8 @@ async function main() {
894
922
  if (args.length < 4) {
895
923
  throwCliUsage(t('cli.form_config_usage'), t('cli.form_config_example'));
896
924
  }
897
- process.argv = [process.argv[0], process.argv[1], ...args];
898
- require('../lib/app/update-form-config');
925
+ const { run: runUpdateFormConfig } = require('../lib/app/update-form-config');
926
+ await runUpdateFormConfig(args);
899
927
  break;
900
928
  }
901
929
 
@@ -1291,7 +1319,9 @@ async function main() {
1291
1319
  }
1292
1320
 
1293
1321
  main()
1322
+ .then(() => { commandReceipt.finish(process.exitCode || 0); })
1294
1323
  .catch((err) => {
1324
+ try {commandReceipt?.finish(err && err.exitCode ? err.exitCode : 1);} catch { /* Intent remains recoverable. */ }
1295
1325
  if (shouldUseStructuredErrorOutput(err, args)) {
1296
1326
  console.error(JSON.stringify(toErrorPayload(err), null, 2));
1297
1327
  } else if (isCliError(err)) {
@@ -0,0 +1,201 @@
1
+ # Local runtime launcher contract
2
+
3
+ This Node package ships no Go source. The packaged catalog and exact optional
4
+ platform dependencies select signed runtime binaries for macOS and Windows,
5
+ x64 and ARM64. npm installs the package matching the actual OS/CPU. Ordinary
6
+ commands do not resolve or spawn this runtime. There is no arbitrary URL, PATH
7
+ runtime discovery, or build/download fallback.
8
+
9
+ ## Production metadata and development mode
10
+
11
+ `runtime-manifest.js` is the strict v2 codec: no extra/duplicate JSON keys, malformed
12
+ UTF-8, unknown OS/CPU aliases, invalid protocol/state ranges, unsafe binary paths,
13
+ oversized metadata/binaries or invalid digests. The binary path is exactly
14
+ `bin/openyida-agent-runtime` (Windows: `.exe`). `sourceRevision` is a 40- or
15
+ 64-character lowercase source commit digest, not a public artifact tag.
16
+
17
+ Detached Ed25519 signature: original UTF-8 `runtime-manifest.json` bytes, with no
18
+ re-serialization; `.sig` is canonical base64 for 64 signature bytes, optionally
19
+ one final LF. Trust roots live in the main package's `runtime-catalog.json`:
20
+ `keys[keyId] = {publicKey: <SPKI PEM>, revoked?: boolean}`. Unknown/revoked keys
21
+ fail closed; no TOFU, downloaded trust key, arbitrary URL or PATH fallback exists.
22
+
23
+ Each approved release entry is `{platform, arch, packageName, packageVersion,
24
+ runtimeVersion, manifestSha256}`. The main package must declare that exact
25
+ `optionalDependencies` version. Public package naming is
26
+ `@openyida/local-agent-runtime-${process.platform}-${process.arch}`. Platform metadata must match its
27
+ exact name/version and single-element `os`/`cpu`, and have no lifecycle scripts.
28
+ Main-package-relative Node resolution supports hoisting; platform packages must
29
+ make `package.json` resolvable. Missing optional packages block Local Agent only.
30
+ The current production platform allowlist is macOS (`darwin`) and Windows (`win32`).
31
+ Linux remains a source/development target but must not appear in the production
32
+ catalog or optional dependencies until native acceptance is complete. Ordinary
33
+ non-Agent OpenYida CLI commands remain available on Linux.
34
+
35
+ Unsigned v1 requires CLI `--development-runtime` plus explicit absolute
36
+ `--runtime-path` and `--runtime-manifest`. Environment variables cannot enable it.
37
+ Node sends `developmentRuntime: true` only for that flag. v1 is not accepted as a
38
+ production compatibility fallback. Development binaries are normalized to the
39
+ bundle executable filename; their original manifest is preserved as evidence.
40
+
41
+ ## Launch DTO (protocolVersion remains 1)
42
+
43
+ Go argv is `[command, "--stdio-config"]`; stdin is one JSON object plus LF, at
44
+ most 64 KiB. Business access/refresh tokens and ordinary OpenYida profile
45
+ identifiers are never included. The launch contains the control endpoint,
46
+ endpointId, providers and stateDir; web-first connect may additionally contain
47
+ one short-lived Enrollment token. `node.version` remains the OpenYida package
48
+ version, not the Node version.
49
+
50
+ CLI-first connect lets Go create a state/PKCE/device-key-bound Enrollment and open
51
+ the configured browser authorization page. Web-first connect receives the same
52
+ device result by claiming the single-use `--enroll` token. Both modes persist only
53
+ device connection credentials. For every accepted attempt, Python requests a
54
+ fixed-value Tianshu task grant with an initial 60-minute lease and Go injects it
55
+ only into that attempt's managed environment; Node never falls back to a normal
56
+ local profile. The authenticated Runtime heartbeat lets Python renew the lease
57
+ server-to-server while the authoritative Run remains active. Node has no task
58
+ RefreshToken and cannot renew the bearer itself. The grant is bound to the
59
+ application, not one form/page. An optional `formUuid` remains per-turn focus
60
+ context and may change between turns inside the same `appType`.
61
+
62
+ The device bearer is not sufficient by itself. Python sends a fresh nonce before
63
+ accepting the Runtime hello, and Go signs the device/endpoint/nonce binding with
64
+ the Ed25519 key created at Enrollment. Refresh requests similarly sign the
65
+ device/action/refresh-token digest/endpoint binding. A copied access or refresh
66
+ bearer therefore cannot connect or rotate without the enrolled private key.
67
+
68
+ For both `connect` and `run`, added fields are:
69
+
70
+ ```json
71
+ {
72
+ "parentWatchFD": 3,
73
+ "node": {
74
+ "executable": "/external/node",
75
+ "nodeVersion": "v22.22.3",
76
+ "sha256": "NODE_BINARY_SHA256",
77
+ "version": "2026.9.7",
78
+ "packageRoot": "/private/bundles/BUNDLE_SHA256/openyida",
79
+ "cliEntry": "/private/bundles/BUNDLE_SHA256/openyida/bin/yida.js"
80
+ },
81
+ "bundle": {
82
+ "schemaVersion": 1,
83
+ "id": "BUNDLE_SHA256",
84
+ "root": "/private/bundles/BUNDLE_SHA256",
85
+ "manifestPath": "/private/bundles/BUNDLE_SHA256/bundle-manifest.json",
86
+ "manifestSha256": "BUNDLE_SHA256",
87
+ "runtimeVersion": "0.1.0-dev",
88
+ "skillsDir": "/private/bundles/BUNDLE_SHA256/openyida/yida-skills"
89
+ }
90
+ }
91
+ ```
92
+
93
+ The launcher uses four stdio pipes and keeps fd 3 open independently from normal
94
+ configuration stdin EOF. Parent death/pipe EOF asks Go to stop admission and
95
+ cancel/drain, never to report successful completion. Node forwards SIGINT/TERM;
96
+ failure grants 15 seconds before forcing the Go wrapper down. Such timeout returns
97
+ `AGENT_RUNTIME_STOP_UNCONFIRMED` with `executionState: unknown`, not proof that
98
+ provider processes or in-flight business effects stopped.
99
+
100
+ ## Bundle bytes, ownership and lifetime
101
+
102
+ `bundle-manifest.json` is `JSON.stringify(object) + "\n"`, encoded as UTF-8.
103
+ Its exact SHA-256 is the bundle ID; it is a local content manifest, not a publisher
104
+ signature. Fields (in emitted order) are:
105
+
106
+ ```text
107
+ schemaVersion: 1
108
+ product: "openyida-local-agent-bundle"
109
+ openyidaVersion, runtimeVersion
110
+ launcherProtocol: 1
111
+ wireProtocol: 1
112
+ node: {version: <process.version>, sha256: <external Node binary hash>}
113
+ runtime: {executable: "runtime/bin/openyida-agent-runtime[.exe]",
114
+ manifest: "runtime/runtime-manifest.json",
115
+ signature: "runtime/runtime-manifest.sig" | null,
116
+ development: boolean}
117
+ cliEntry: "openyida/bin/yida.js"
118
+ skillsDir: "openyida/yida-skills"
119
+ files: [{path: <relative POSIX>, size: <integer>, sha256: <lowercase hex>,
120
+ mode: 292 | 365}]
121
+ ```
122
+
123
+ Files sort by path using UTF-8 byte order (not JavaScript UTF-16 comparison).
124
+ Mode 292 is octal 0444; 365 is 0555.
125
+ The manifest is not its own files entry. Unknown files and symlinks are rejected.
126
+ Go verifies the same raw manifest digest, file list/hash/size/mode, launch paths,
127
+ runtime version and external Node identity before accepting work and before
128
+ subsequent helper/new-turn use. Production publication trust is established by
129
+ Node's v2 signature and approved digest, not by pretending the local bundle hash
130
+ is a release signature.
131
+
132
+ Node copies only packaged CLI assets and the recursively resolved production
133
+ dependency/peer/available optional closure, preserving nested dependency
134
+ resolution and cycles. It omits root platform packages (verified runtime bytes
135
+ are copied separately), user projects, credentials, checkout metadata and
136
+ devDependencies. Source content/tree membership is checked again before publish;
137
+ missing dependencies or concurrent replacement fail closed. No install hook runs.
138
+
139
+ Unique private staging directories plus atomic compare-and-publish handle
140
+ concurrent materializers without a second execution lease. Contents are read-only;
141
+ on macOS the staging root must remain writable until rename, then becomes 0500.
142
+ Cache is 0700 on Unix. Same-user/root compromise is outside this protection;
143
+ Windows ACL/OS release acceptance remains unverified. Interrupted staging is not
144
+ an executable published bundle. Node does not repair a modified winner or GC
145
+ existing bundles, unknown journals or user files.
146
+
147
+ Go exclusively owns durable refs under
148
+ `stateDir/bundle-references/<bundleId>/...` for connection, session and attempt
149
+ lifetimes. Session identity no longer pins BundleID.
150
+ Node has no ref writer, owner lease, GC or automatic migration. Global npm upgrade
151
+ can produce a new bundle. After the connection drains and is restarted, a compatible
152
+ bundle may continue the same native session; an active attempt never switches. External Node and
153
+ provider binaries are not redistributed/frozen by this package; loss or an
154
+ incompatible change blocks new work rather than silently starting fresh.
155
+
156
+ For allowlisted Yida write commands only, Node writes one bounded Mutation Receipt
157
+ after an explicit success result in OPENYIDA_AGENT_RECEIPTS_DIR. Read-only, failed
158
+ and interrupted commands write nothing.
159
+ Go forwards a successful openyida_mutation through the authenticated Run stream;
160
+ Python adds requiresHostRefresh. No Go command gateway is involved.
161
+ Windows parent lifetime uses parentPID and an OS process handle, not parentWatchFD.
162
+
163
+ All current automated tests use generated signing keys and fake processes.
164
+ No real provider, user account, Yida write, public upload or npm publication is
165
+ part of this implementation checkpoint.
166
+
167
+ ### Background startup receipt
168
+
169
+ A background `run` can carry a fresh `startupId` (safe identifier, generated per
170
+ spawn). The runtime atomically writes `startup-<startupId>.json` in `stateDir`
171
+ with mode 0600 after the control endpoint is bound, the cloud welcome is
172
+ validated, and recovery finishes. Its only fields are `protocolVersion: 1`,
173
+ `startupId`, and boolean `ready`. The launcher requires `ready: true`, then removes
174
+ the receipt and detaches. Provider logs are never a startup acknowledgement.
175
+ A startup failure/exit or the 60-second deadline rejects the launch; the launcher
176
+ terminates only its own new child, preserving credentials and journals.
177
+
178
+ This is an additive launch-config extension. Older launchers omit it. Older
179
+ runtimes reject unknown fields, so release the updated runtime and update the
180
+ CLI's exact runtime dependencies and signed catalog together before publishing
181
+ this launcher. Do not pair the updated launcher with a previously published
182
+ runtime and assume compatibility.
183
+
184
+ ## Managed output delivery and business questions
185
+
186
+ Only `openyida.ask-human.v2` receipts are accepted. The CLI explicitly declares
187
+ bounded text attachments and freezes their bytes in the attempt's private
188
+ `.files` directory. Runtime verifies each snapshot and uploads it with device
189
+ authentication to `/api/local-runtime/v1/runs/{runId}/artifacts`, with bounded
190
+ base64url JSON metadata in `X-OpenYida-Output` and an octet-stream body.
191
+ Server-owned paths, digests and sizes are durably acknowledged in the attempt
192
+ journal before `question_opened`; local paths and file bytes never enter events.
193
+ No recursive directory sync or v1 receipt compatibility is implemented.
194
+
195
+ Question choices retain stable `value` separately from display labels; business
196
+ `contextRevision` is distinct from the integer interaction revision. Answers and
197
+ file references remain bound to the displayed question on continuation. The UI
198
+ uses the existing authenticated project-file reader and isolated HTML preview.
199
+ Permanent upload errors fail the turn; bounded transient retries remain cancellable.
200
+ Persisted ACKs/events can be replayed without executing business commands again.
201
+ An interrupted upload does not silently resume an unconfirmed provider execution.
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ // Shared static help: no authentication, file access, or receipt creation.
4
+ const example = {
5
+ title: '确认搭建方案', revision: '<confirmation.revision>',
6
+ attachments: [{ name: '搭建方案.html', path: '<outputs.confirmationHtml>' }],
7
+ questions: [{ id: 'plan_decision', header: '搭建方案', question: '请确认当前搭建方案',
8
+ type: 'single_select', options: [
9
+ { label: '确认并开始搭建', value: 'confirm_build' },
10
+ { label: '继续调整', value: 'continue_editing' },
11
+ ] }],
12
+ };
13
+
14
+ module.exports = {
15
+ path: ['agent', 'ask-human'],
16
+ usage: 'openyida agent ask-human --request-key <稳定业务键> --questions-file <相对路径> --json',
17
+ description: '在宜搭云端发起的本地 Agent 任务中提交结构化问题并等待网页回答。',
18
+ examples: [JSON.stringify(example)],
19
+ };
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { CliError } = require('../core/cli-error');
7
+ const { readManagedContext } = require('./managed-context');
8
+
9
+ const SCHEMA = 'openyida.ask-human.v2';
10
+ const MAX_FILE_BYTES = 32 * 1024;
11
+ const HELP = require('./ask-human-help');
12
+ const USAGE = `用法:${HELP.usage}`;
13
+
14
+ function fail(code, message) { throw new CliError(message, { code, details: { retryable: false } }); }
15
+ function text(value, max) { return typeof value === 'string' && value === value.trim() && value.length > 0 && Buffer.byteLength(value) <= max && !/\p{Cc}/u.test(value); }
16
+
17
+ function parseArgs(argv) {
18
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) { return { help: true }; }
19
+ const result = { json: false };
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const name = argv[i];
22
+ if (name === '--json' && !result.json) { result.json = true; continue; }
23
+ const key = name === '--request-key' ? 'requestKey' : name === '--questions-file' ? 'questionsFile' : null;
24
+ if (!key || result[key] !== undefined || !argv[i + 1] || argv[i + 1].startsWith('--')) {
25
+ fail('AGENT_ASK_HUMAN_INVALID', USAGE);
26
+ }
27
+ result[key] = argv[++i];
28
+ }
29
+ if (!text(result.requestKey, 128) || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(result.requestKey) ||
30
+ !text(result.questionsFile, 512) || path.isAbsolute(result.questionsFile)) {
31
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 参数无效。');
32
+ }
33
+ return result;
34
+ }
35
+
36
+ function readQuestions(relativePath, cwd) {
37
+ let root;
38
+ let candidate;
39
+ let realCandidate;
40
+ let stat;
41
+ try {
42
+ root = fs.realpathSync(cwd);
43
+ candidate = path.resolve(root, relativePath);
44
+ stat = fs.lstatSync(candidate);
45
+ realCandidate = fs.realpathSync(candidate);
46
+ } catch { fail('AGENT_ASK_HUMAN_INVALID', '问题文件不可读取。'); }
47
+ if (candidate === root || !candidate.startsWith(root + path.sep) || realCandidate === root || !realCandidate.startsWith(root + path.sep)) {
48
+ fail('AGENT_ASK_HUMAN_INVALID', '问题文件必须位于当前工作目录。');
49
+ }
50
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES) { fail('AGENT_ASK_HUMAN_INVALID', '问题文件必须是 32 KiB 以内的普通文件。'); }
51
+ let document;
52
+ try { document = JSON.parse(fs.readFileSync(candidate, 'utf8')); } catch { fail('AGENT_ASK_HUMAN_INVALID', '问题文件不是有效 JSON。'); }
53
+ if (!document || typeof document !== 'object' || Array.isArray(document) || !text(document.title, 256) ||
54
+ !Array.isArray(document.questions) || document.questions.length < 1 || document.questions.length > 3) {
55
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 问题结构无效。');
56
+ }
57
+ const ids = new Set();
58
+ for (const question of document.questions) {
59
+ const allowed = new Set(['id', 'header', 'question', 'type', 'required', 'options']);
60
+ if (!question || typeof question !== 'object' || Array.isArray(question) || Object.keys(question).some(key => !allowed.has(key)) ||
61
+ !text(question.id, 128) || ids.has(question.id) || !text(question.header, 128) || !text(question.question, 2048) ||
62
+ !['text', 'single_select', 'multi_select'].includes(question.type) ||
63
+ (question.required !== undefined && typeof question.required !== 'boolean')) {
64
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 问题结构无效。');
65
+ }
66
+ ids.add(question.id);
67
+ const options = question.options === undefined ? [] : question.options;
68
+ if (!Array.isArray(options)) { fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 选项结构无效。'); }
69
+ if ((question.type === 'text' && options.length) || (question.type !== 'text' && (!Array.isArray(options) || options.length < 1 || options.length > 12)) ||
70
+ options.some(option => !option || typeof option !== 'object' || Array.isArray(option) || Object.keys(option).some(key => !['label', 'value', 'description'].includes(key)) || !text(option.label, 512) || (option.value !== undefined && !text(option.value, 512)) ||
71
+ (option.description !== undefined && option.description !== '' && !text(option.description, 1024)))) {
72
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 选项结构无效。');
73
+ }
74
+ if (new Set(options.map(option => option.value || option.label)).size !== options.length) { fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 选项不能重复。'); }
75
+ }
76
+ if (Object.keys(document).some(key => !['title', 'questions', 'revision', 'attachments'].includes(key)) ||
77
+ (document.revision !== undefined && !text(document.revision, 256)) ||
78
+ (document.attachments !== undefined && (!Array.isArray(document.attachments) || document.attachments.length > 10))) {
79
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 附件或方案版本无效。');
80
+ }
81
+ return { title: document.title,
82
+ ...(document.revision ? { contextRevision: document.revision } : {}),
83
+ attachments: document.attachments || [],
84
+ questions: document.questions.map(question => ({ ...question, required: question.required !== false,
85
+ ...(question.options ? { options: question.options.map(option => ({ ...option, value: option.value || option.label })) } : {}) })) };
86
+ }
87
+
88
+ // Explicit output only. Freeze bytes before publishing a question receipt.
89
+ function snapshotAttachments(attachments, cwd, directory) {
90
+ const root = fs.realpathSync(cwd);
91
+ const files = path.join(directory, '.files');
92
+ fs.mkdirSync(files, { recursive: true, mode: 0o700 });
93
+ if (fs.lstatSync(files).isSymbolicLink() || fs.realpathSync(files) !== files) { fail('AGENT_ASK_HUMAN_INVALID', '附件目录无效。'); }
94
+ const seen = new Set();
95
+ let total = 0;
96
+ return attachments.map(attachment => {
97
+ if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment) ||
98
+ Object.keys(attachment).some(key => !['name', 'path'].includes(key)) ||
99
+ !text(attachment.name, 256) || !text(attachment.path, 1024)) {
100
+ fail('AGENT_ASK_HUMAN_INVALID', '附件格式无效。');
101
+ }
102
+ const source = path.resolve(root, attachment.path);
103
+ const relative = path.relative(root, source);
104
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) ||
105
+ relative.split(path.sep).some(part => /^(?:\.env(?:\..*)?|\.git|node_modules|\.openyida|credentials?|auth|tokens?)$/i.test(part))) {
106
+ fail('AGENT_ASK_HUMAN_INVALID', '附件必须是任务工作目录中的交付文件。');
107
+ }
108
+ const extension = path.extname(source).toLowerCase();
109
+ if (!['.html', '.htm', '.md', '.txt'].includes(extension)) { fail('AGENT_ASK_HUMAN_INVALID', '附件仅支持 HTML、Markdown 或文本。'); }
110
+ let bytes;
111
+ try {
112
+ // Reject symlink/reparse traversal, including linked parent directories.
113
+ if (fs.realpathSync(source) !== source || !fs.lstatSync(source).isFile() || fs.lstatSync(source).isSymbolicLink()) { throw new Error(); }
114
+ const fd = fs.openSync(source, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
115
+ try {
116
+ const info = fs.fstatSync(fd);
117
+ if (!info.isFile() || info.size > 1024 * 1024) { throw new Error(); }
118
+ const bounded = Buffer.alloc(1024 * 1024 + 1);
119
+ let count = 0;
120
+ while (count < bounded.length) {
121
+ const read = fs.readSync(fd, bounded, count, bounded.length - count, null);
122
+ if (!read) { break; }
123
+ count += read;
124
+ }
125
+ bytes = bounded.subarray(0, count);
126
+ } finally { fs.closeSync(fd); }
127
+ if (bytes.length > 1024 * 1024 || bytes.includes(0) || !Buffer.from(bytes.toString('utf8')).equals(bytes)) { throw new Error(); }
128
+ } catch { fail('AGENT_ASK_HUMAN_INVALID', '附件不可读取,或超过 1 MiB/不是 UTF-8 文本。'); }
129
+ total += bytes.length;
130
+ if (total > 10 * 1024 * 1024) { fail('AGENT_ASK_HUMAN_LIMIT', '附件总大小超过限制。'); }
131
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
132
+ const file = `${sha256}${extension}`;
133
+ if (seen.has(file)) { fail('AGENT_ASK_HUMAN_INVALID', '附件不能重复。'); }
134
+ seen.add(file);
135
+ const target = path.join(files, file);
136
+ if (fs.existsSync(target)) {
137
+ if (fs.lstatSync(target).isSymbolicLink() || !fs.readFileSync(target).equals(bytes)) { fail('AGENT_ASK_HUMAN_CONFLICT', '附件快照冲突。'); }
138
+ } else {
139
+ const temporary = path.join(files, `.${crypto.randomUUID()}.tmp`);
140
+ const fd = fs.openSync(temporary, 'wx', 0o600);
141
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
142
+ retryWindowsFileOperation(() => fs.renameSync(temporary, target));
143
+ }
144
+ return { name: attachment.name, path: `.files/${file}`, sha256, sizeBytes: bytes.length };
145
+ });
146
+ }
147
+
148
+ function ensureReceiptDir(env) {
149
+ const value = env.OPENYIDA_AGENT_INTERACTIONS_DIR;
150
+ if (!value || !path.isAbsolute(value)) { fail('AGENT_ASK_HUMAN_UNAVAILABLE', '当前任务未启用 Ask Human。'); }
151
+ fs.mkdirSync(value, { recursive: true, mode: 0o700 });
152
+ const stat = fs.lstatSync(value);
153
+ if (!stat.isDirectory() || stat.isSymbolicLink()) { fail('AGENT_ASK_HUMAN_UNAVAILABLE', 'Ask Human 私有目录不可用。'); }
154
+ if (fs.readdirSync(value).filter(name => name.endsWith('.json')).length >= 64) { fail('AGENT_ASK_HUMAN_LIMIT', '本轮 Ask Human 请求过多。'); }
155
+ return fs.realpathSync(value);
156
+ }
157
+
158
+ function retryWindowsFileOperation(operation) {
159
+ const delays = [20, 40, 80, 160, 320];
160
+ for (let attempt = 0; ; attempt++) {
161
+ try { return operation(); } catch (error) {
162
+ if (process.platform !== 'win32' || attempt === delays.length || !['EBUSY', 'EPERM', 'EACCES'].includes(error.code)) { throw error; }
163
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delays[attempt]);
164
+ }
165
+ }
166
+ }
167
+
168
+ function writeReceipt(directory, requestId, receipt) {
169
+ const temporary = path.join(directory, `.${requestId}.tmp`);
170
+ const target = path.join(directory, `${requestId}.json`);
171
+ let fd;
172
+ try {
173
+ fd = fs.openSync(temporary, 'wx', 0o600);
174
+ fs.writeFileSync(fd, JSON.stringify(receipt));
175
+ fs.fsyncSync(fd);
176
+ fs.closeSync(fd);
177
+ fd = undefined;
178
+ retryWindowsFileOperation(() => fs.renameSync(temporary, target));
179
+ if (process.platform !== 'win32') {
180
+ const parent = fs.openSync(directory, 'r');
181
+ try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }
182
+ }
183
+ } catch (error) {
184
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* Preserve the original write failure if cleanup also fails. */ } }
185
+ if (fs.existsSync(temporary)) { try { retryWindowsFileOperation(() => fs.unlinkSync(temporary)); } catch { /* Preserve the original write failure if cleanup also fails. */ } }
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ function run(argv = [], options = {}) {
191
+ const env = options.env || process.env;
192
+ const context = readManagedContext(env);
193
+ if (!context) { fail('AGENT_ASK_HUMAN_MANAGED_ONLY', 'Ask Human 仅可在宜搭云端发起的本地 Agent 任务中使用。'); }
194
+ const args = parseArgs(argv);
195
+ if (args.help) {
196
+ (options.stdout || process.stdout).write([USAGE, HELP.description, ...HELP.examples].join('\n') + '\n');
197
+ return { ok: true, help: true };
198
+ }
199
+ const document = readQuestions(args.questionsFile, options.cwd || process.cwd());
200
+ const directory = ensureReceiptDir(env);
201
+ document.attachments = snapshotAttachments(document.attachments, options.cwd || process.cwd(), directory);
202
+ const digest = crypto.createHash('sha256').update(JSON.stringify(document)).digest('hex');
203
+ for (const name of fs.readdirSync(directory).filter(item => item.endsWith('.json'))) {
204
+ let prior;
205
+ try { prior = JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8')); } catch { continue; }
206
+ if (prior.requestKey === args.requestKey) {
207
+ if (prior.contentDigest !== digest) { fail('AGENT_ASK_HUMAN_CONFLICT', '同一 request-key 不能提交不同问题。'); }
208
+ const output = { ok: true, requestId: prior.requestId, status: 'queued' };
209
+ (options.stdout || process.stdout).write(JSON.stringify(output) + '\n');
210
+ return output;
211
+ }
212
+ }
213
+ const requestId = crypto.randomUUID();
214
+ const receipt = { schemaVersion: SCHEMA, requestId, requestKey: args.requestKey, contentDigest: digest,
215
+ runId: context.runId, attemptId: context.attemptId, createdAt: new Date().toISOString(), ...document };
216
+ writeReceipt(directory, requestId, receipt);
217
+ const output = { ok: true, requestId, status: 'queued' };
218
+ (options.stdout || process.stdout).write(JSON.stringify(output) + '\n');
219
+ return output;
220
+ }
221
+
222
+ module.exports = { run, SCHEMA };