autowonder 0.3.5 → 0.3.7

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.
package/README.md CHANGED
@@ -6,10 +6,10 @@ AutoWonder 本地 agent runtime。安装后启动 daemon,持续轮询本地 as
6
6
 
7
7
  ```bash
8
8
  # Qoder CLI(模型使用 provider model ID;Context Window 使用固定档位)
9
- npx -y autowonder@0.3.5 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
9
+ npx -y autowonder@0.3.7 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
10
10
 
11
11
  # Qoder CLI CN
12
- npx -y autowonder@0.3.5 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
12
+ npx -y autowonder@0.3.7 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
13
13
  ```
14
14
 
15
15
  `connect` 会安装当前 npm 包内置的 daemon,并使用页面生成的 WebSocket endpoint、Token 和执行器 ID 建立连接。客户端只支持通过 Qoder 系列 CLI 启动(`qoder` 对应 `qodercli`,`qodercn` 对应 `qodercn`),其他 `--provider` 取值会在启动 daemon 前直接报错拦截并提示 `Unsupported provider`。选择 Qoder CLI 或 Qoder CLI CN 时需要 Node.js 20+;runtime 会在缺少对应 CLI 时自动通过 npm 安装,并在尚未登录时打开浏览器登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
@@ -37,7 +37,7 @@ daemon 会响应服务端的 `QODER_MODEL_CATALOG_REQUEST` 帧,用当前 provi
37
37
 
38
38
  daemon 还会响应服务端的 `CONVERSATION_COMMANDS_PROBE` 帧,用独立会话抓取该会话工作目录下可用的斜杠命令,回传 `CONVERSATION_COMMANDS_RESULT` 帧,让会话面板在用户输入 `/` 时列出真实可用的命令。该探测与回合执行完全隔离:任何失败只产生 `FAILED` / `TIMEOUT` 帧,不影响进行中的会话;provider 后端不支持抓取时同样降级为 `FAILED` 帧。帧里的 `executorId` 与本机不一致或非正数时直接忽略,避免在别的执行器工作区里建目录或启动 CLI。daemon 侧给整个探测 25 秒硬上限,刻意长于 agent 侧的 20 秒预算,使 agent 自己的超时先触发并可被归类。
39
39
 
40
- 给 `connect` 追加 `--token-aware-enable` 可开启 qoder / qodercn 的 token 用量采集:CLI 会以 sidecar 方式启动本地 HTTPS 转发代理(`bin/token-proxy.js`),从 Qoder API 的 SSE 响应流中提取 usage 数据并按 dispatch ID 归属上报。该功能旁路设计、不阻塞任务执行,代理启动失败时仅告警并继续。代理端口可用 `TOKEN_PROXY_PORT` 环境变量调整(默认 19876);CA 证书位于 `~/.autowonder/token-tap/ca-cert.pem`。daemon 退出时 sidecar 一并停止。
40
+ 给 `connect` 追加 `--token-aware-enable` 可开启 qoder / qodercn 的 token 用量采集:CLI 会以 sidecar 方式启动本地 HTTPS 转发代理(`bin/token-proxy.js`),从 Qoder API 的 SSE 响应流中提取 usage 数据并按 dispatch ID 归属上报。usage 解析失败只丢失计量数据;代理启动失败时仅告警并继续。启用后模型流量经过代理,连接失败由客户端处理重试,不保证自动直连。代理端口可用 `TOKEN_PROXY_PORT` 环境变量调整(默认 19876);CA 证书位于 `~/.autowonder/token-tap/ca-cert.pem`。daemon 退出时 sidecar 一并停止。代理对 TCP 建连和两端 TLS 握手设置 15 秒期限,对无读写活动的连接设置 120 秒空闲期限;超时、报错或任一端关闭时释放两端连接。持续有数据的 SSE 流不受总时长限制,连续静默超过 120 秒的流会断开。
41
41
 
42
42
  `connect` 支持 `--workspace-root <path>` 指定 dispatch 仓库与任务工作区所在目录(默认 `~/autowonder_workspaces`)。该参数会转发给 daemon,等价于设置 `AUTOWONDER_WORKSPACE_ROOT` 环境变量,适合把工作区放在非默认路径的执行机上(如 Windows 的 `D:\autowonder`)。
43
43
 
@@ -158,7 +158,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
158
158
  也可以直接提交到本地 API:
159
159
 
160
160
  ```bash
161
- npx -y autowonder@0.3.5 dispatch ./assignment.json
161
+ npx -y autowonder@0.3.7 dispatch ./assignment.json
162
162
  ```
163
163
 
164
164
  ## 上传工单附件
@@ -166,7 +166,7 @@ npx -y autowonder@0.3.5 dispatch ./assignment.json
166
166
  把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
167
167
 
168
168
  ```bash
169
- npx -y autowonder@0.3.5 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
169
+ npx -y autowonder@0.3.7 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
170
170
  ```
171
171
 
172
172
  上传令牌通过 MCP 工具 `autowonder.workitem_cli_upload_token` 签发,经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 环境变量传入。`--file` 可重复指定多个文件;`--json` 输出机器可读结果。失败时按错误类型返回不同退出码(401→3、403→4、404→5、409→6、413→7、其他 4xx→8、网络/重定向→9)。
@@ -176,7 +176,7 @@ npx -y autowonder@0.3.5 workitem upload --server-url <autowonder-server-url> --w
176
176
  把工单上已有的需求/设计文档下载到本地目录,再用本地文件工具阅读,避免把大文件或二进制附件搬进会话上下文:
177
177
 
178
178
  ```bash
179
- npx -y autowonder@0.3.5 workitem download --server-url <autowonder-server-url> --workitem-id <id> --file <name-or-id> --output-dir <dir> --json
179
+ npx -y autowonder@0.3.7 workitem download --server-url <autowonder-server-url> --workitem-id <id> --file <name-or-id> --output-dir <dir> --json
180
180
  ```
181
181
 
182
182
  下载令牌通过 MCP 工具 `autowonder.workitem_cli_download_token` 签发(与上传令牌相互独立),经 `--token` 或 `AUTOWONDER_DOWNLOAD_TOKEN` 环境变量传入。`--file` 可重复指定文档名或 artifact id,省略则下载该工单的全部附件;`--output-dir` 默认为当前目录;`--json` 输出机器可读结果,含每个文件的 `id`、`name`、`path`、`size`。退出码约定与 `workitem upload` 相同(401→3、403→4、404→5、409→6、413→7、其他 4xx→8、网络/重定向→9)。
@@ -186,7 +186,7 @@ npx -y autowonder@0.3.5 workitem download --server-url <autowonder-server-url> -
186
186
  把需求/设计文档上传到 7x24 定时任务,用法与工单附件一致(同一套上传令牌与预检规则):
187
187
 
188
188
  ```bash
189
- npx -y autowonder@0.3.5 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
189
+ npx -y autowonder@0.3.7 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
190
190
  ```
191
191
 
192
192
  上传令牌同样通过 `autowonder.workitem_cli_upload_token` 签发(令牌为用户级,对当前可修改的任意定时任务生效),经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 传入;退出码约定与 `workitem upload` 相同。
@@ -194,8 +194,8 @@ npx -y autowonder@0.3.5 scheduled-task upload --server-url <autowonder-server-ur
194
194
  ## 管理 daemon
195
195
 
196
196
  ```bash
197
- npx -y autowonder@0.3.5 status
198
- npx -y autowonder@0.3.5 stop
197
+ npx -y autowonder@0.3.7 status
198
+ npx -y autowonder@0.3.7 stop
199
199
  ```
200
200
 
201
201
  默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
@@ -203,7 +203,7 @@ npx -y autowonder@0.3.5 stop
203
203
  ## 调试
204
204
 
205
205
  ```bash
206
- npx -y autowonder@0.3.5 connect ... --debug
206
+ npx -y autowonder@0.3.7 connect ... --debug
207
207
  ```
208
208
 
209
209
  `--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
@@ -14,7 +14,7 @@
14
14
  * 降级策略:
15
15
  * - 所有解析逻辑被 try-catch 包裹,解析失败只丢 token 数据,不断流量
16
16
  * - proxy 启动失败时,wrapper 不注入 HTTPS_PROXY,daemon 正常直连
17
- * - proxy 运行中崩溃时,Node HTTPS 模块遇到 ECONNREFUSED 会忽略 proxy 直连
17
+ * - 运行中的代理连接失败会关闭隧道,由客户端处理重试;不承诺自动直连
18
18
  */
19
19
 
20
20
  'use strict';
@@ -316,8 +316,53 @@ function extractDispatchInfo(req) {
316
316
 
317
317
  // ─── Forward Proxy (CONNECT method) ─────────────────────────────────────────
318
318
 
319
- function startProxy(port) {
320
- const ca = ensureCA();
319
+ // Bound connection setup separately from idle time: a peer can trickle bytes
320
+ // during TLS negotiation, so a socket inactivity timeout alone is insufficient.
321
+ function guardConnection(sockets, { readyEvents, connectTimeoutMs = 15000,
322
+ idleTimeoutMs = 120000, onCleanup = () => {}, onError = () => {} }) {
323
+ let closed = false;
324
+ const pending = new Set(sockets);
325
+ const cleanup = () => {
326
+ if (closed) return;
327
+ closed = true;
328
+ clearTimeout(connectTimer);
329
+ for (const socket of sockets) {
330
+ socket.setTimeout(0);
331
+ socket.destroy();
332
+ }
333
+ onCleanup();
334
+ };
335
+ const connectTimer = setTimeout(() => {
336
+ onError(new Error('proxy connection setup timed out'));
337
+ cleanup();
338
+ }, connectTimeoutMs);
339
+ connectTimer.unref();
340
+ sockets.forEach((socket, i) => {
341
+ socket.setTimeout(idleTimeoutMs);
342
+ socket.on('timeout', () => {
343
+ onError(new Error('proxy connection idle timeout'));
344
+ cleanup();
345
+ });
346
+ socket.on('close', cleanup);
347
+ socket.on('error', error => { onError(error); cleanup(); });
348
+ // Flush queued bytes before closing the other half. The idle timer still
349
+ // bounds a peer that never completes shutdown.
350
+ socket.on('end', () => {
351
+ for (const peer of sockets) if (peer !== socket) peer.end();
352
+ });
353
+ const readyEvent = readyEvents[i];
354
+ if (!readyEvent) pending.delete(socket);
355
+ else socket.once(readyEvent, () => {
356
+ pending.delete(socket);
357
+ if (!pending.size) clearTimeout(connectTimer);
358
+ });
359
+ });
360
+ if (!pending.size) clearTimeout(connectTimer);
361
+ return cleanup;
362
+ }
363
+
364
+ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
365
+ connectTimeoutMs = 15000, idleTimeoutMs = 120000, sniffHosts = SNIFF_HOSTS } = {}) {
321
366
  const dbg = DEBUG ? (...args) => console.log(...args) : () => {};
322
367
 
323
368
  const server = http.createServer((req, res) => {
@@ -332,36 +377,26 @@ function startProxy(port) {
332
377
  const ts = () => new Date().toISOString().slice(11, 23);
333
378
  const [hostname, portStr] = req.url.split(':');
334
379
  const remotePort = parseInt(portStr) || 443;
335
- const shouldSniff = SNIFF_HOSTS.has(hostname);
380
+ const shouldSniff = sniffHosts.has(hostname);
336
381
  const { dispatchId, stepId } = extractDispatchInfo(req);
337
382
 
338
383
  dbg(`[${ts()}] CONNECT #${seq} ${hostname}:${remotePort} sniff=${shouldSniff} dispatch=${dispatchId || 'none'} step=${stepId || 'none'}`);
339
384
 
340
- if (!shouldSniff) {
341
- dbg(`[${ts()}] #${seq} → BLIND TUNNEL (not in SNIFF_HOSTS)`);
342
- const upstream = net.connect(remotePort, hostname, () => {
343
- clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
344
- upstream.write(head);
345
- upstream.pipe(clientSocket);
346
- clientSocket.pipe(upstream);
347
- });
348
- upstream.on('error', (e) => { dbg(`[${ts()}] #${seq} blind upstream err: ${e.message}`); try { clientSocket.end(); } catch (_) {} });
349
- clientSocket.on('error', (e) => { dbg(`[${ts()}] #${seq} blind client err: ${e.message}`); try { upstream.end(); } catch (_) {} });
350
- return;
351
- }
352
-
353
- // MITM for Qoder API hosts: decrypt, sniff usage, re-encrypt
354
- const hostCert = getHostCert(hostname, ca);
385
+ const hostCert = shouldSniff ? hostCertificate(hostname, ca) : null;
355
386
  if (!hostCert) {
356
- dbg(`[${ts()}] #${seq} → BLIND TUNNEL (cert generation failed for ${hostname})`);
357
- const upstream = net.connect(remotePort, hostname, () => {
387
+ dbg(`[${ts()}] #${seq} → BLIND TUNNEL`);
388
+ const upstream = net.connect(remotePort, hostname);
389
+ guardConnection([clientSocket, upstream], {
390
+ readyEvents: [null, 'connect'], connectTimeoutMs, idleTimeoutMs,
391
+ onError: e => dbg(`[${ts()}] #${seq} tunnel error: ${e.message}`),
392
+ });
393
+ upstream.once('connect', () => {
394
+ if (clientSocket.destroyed) return;
358
395
  clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
359
- upstream.write(head);
396
+ if (head.length) upstream.write(head);
360
397
  upstream.pipe(clientSocket);
361
398
  clientSocket.pipe(upstream);
362
399
  });
363
- upstream.on('error', (e) => { dbg(`[${ts()}] #${seq} cert-fail upstream err: ${e.message}`); try { clientSocket.end(); } catch (_) {} });
364
- clientSocket.on('error', (e) => { dbg(`[${ts()}] #${seq} cert-fail client err: ${e.message}`); try { upstream.end(); } catch (_) {} });
365
400
  return;
366
401
  }
367
402
 
@@ -370,6 +405,8 @@ function startProxy(port) {
370
405
  // Accept TLS from client (presenting our per-host cert signed by our CA)
371
406
  clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
372
407
 
408
+ // CONNECT head contains encrypted TLS bytes, not plaintext HTTP.
409
+ if (head.length) clientSocket.unshift(head);
373
410
  const tlsClient = new tls.TLSSocket(clientSocket, {
374
411
  isServer: true,
375
412
  key: hostCert.key,
@@ -385,7 +422,6 @@ function startProxy(port) {
385
422
  let tlsUpstreamReady = false;
386
423
  tlsUpstream.on('secureConnect', () => { tlsUpstreamReady = true; dbg(`[${ts()}] #${seq} TLS-upstream handshake OK → ${hostname}`); });
387
424
 
388
- let responseBuffer = Buffer.alloc(0);
389
425
  let usageCount = 0;
390
426
  let totalBytes = 0;
391
427
  let rawFd = null;
@@ -429,7 +465,6 @@ function startProxy(port) {
429
465
 
430
466
  tlsUpstream.on('data', (chunk) => {
431
467
  totalBytes += chunk.length;
432
- try { tlsClient.write(chunk); } catch (_) {}
433
468
  if (rawFd !== null) { try { fs.writeSync(rawFd, chunk); } catch (_) {} }
434
469
  try {
435
470
  const text = lineRemainder + chunk.toString('utf8');
@@ -444,9 +479,9 @@ function startProxy(port) {
444
479
  } catch (_) {}
445
480
  });
446
481
 
447
- tlsClient.on('data', (chunk) => {
448
- try { tlsUpstream.write(chunk); } catch (_) {}
449
- });
482
+ // pipe() handles backpressure; accounting only observes the stream.
483
+ tlsUpstream.pipe(tlsClient);
484
+ tlsClient.pipe(tlsUpstream);
450
485
 
451
486
  const cleanup = () => {
452
487
  // Final extraction from any remaining data (lineRemainder may hold last line without trailing \n)
@@ -456,18 +491,24 @@ function startProxy(port) {
456
491
  lineRemainder = '';
457
492
  dbg(`[${ts()}] #${seq} CLOSED ${hostname} bytes=${totalBytes} usages=${usageCount} tlsClient=${tlsClientReady} tlsUpstream=${tlsUpstreamReady}`);
458
493
  if (rawFd !== null) { try { fs.closeSync(rawFd); } catch (_) {} rawFd = null; }
459
- try { tlsClient.end(); } catch (_) {}
460
- try { tlsUpstream.end(); } catch (_) {}
461
494
  };
462
495
 
463
- tlsClient.on('end', cleanup);
464
- tlsClient.on('error', (e) => { dbg(`[${ts()}] #${seq} TLS-client ERROR: ${e.message}`); cleanup(); });
465
- tlsUpstream.on('end', cleanup);
466
- tlsUpstream.on('error', (e) => { dbg(`[${ts()}] #${seq} TLS-upstream ERROR: ${e.message}`); cleanup(); });
496
+ const closeConnection = guardConnection([tlsClient, tlsUpstream], {
497
+ readyEvents: ['secure', 'secureConnect'], connectTimeoutMs, idleTimeoutMs,
498
+ onCleanup: cleanup,
499
+ onError: e => dbg(`[${ts()}] #${seq} TLS error: ${e.message}`),
500
+ });
501
+ // Also fence an aborted raw CONNECT socket during TLS setup.
502
+ clientSocket.on('close', closeConnection);
503
+ clientSocket.on('error', closeConnection);
467
504
 
468
- if (head.length > 0) tlsClient.emit('data', head);
469
505
  });
470
506
 
507
+ return server;
508
+ }
509
+
510
+ function startProxy(port) {
511
+ const server = createProxyServer();
471
512
  server.on('error', (err) => {
472
513
  console.error(`[token-proxy] server error: ${err.message}`);
473
514
  process.exit(1);
@@ -554,29 +595,30 @@ function parseDuration(s) {
554
595
  const args = process.argv.slice(2);
555
596
  const command = args[0];
556
597
 
557
- if (command === 'start') {
558
- const portIdx = args.indexOf('--port');
559
- const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : DEFAULT_PORT;
560
- startProxy(port);
561
- } else if (command === 'query') {
562
- const jsonFlag = args.includes('--json');
563
- const sinceIdx = args.indexOf('--since');
564
- const since = sinceIdx >= 0 ? args[sinceIdx + 1] : '24h';
565
- queryUsage({ json: jsonFlag, since });
566
- } else {
567
- console.log(`Usage:
568
- node token-proxy.js start [--port ${DEFAULT_PORT}] Start the proxy
569
- node token-proxy.js query [--since 1h] [--json] Query token usage
570
-
571
- Environment (set before starting daemon):
572
- HTTPS_PROXY=http://127.0.0.1:${DEFAULT_PORT}
573
- NODE_EXTRA_CA_CERTS=${CA_CERT_PATH}
574
-
575
- Files:
576
- CA cert: ${CA_CERT_PATH}
577
- Usage log: ${USAGE_LOG_PATH}`);
598
+ if (require.main === module) {
599
+ if (command === 'start') {
600
+ const portIdx = args.indexOf('--port');
601
+ const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : DEFAULT_PORT;
602
+ startProxy(port);
603
+ } else if (command === 'query') {
604
+ const jsonFlag = args.includes('--json');
605
+ const sinceIdx = args.indexOf('--since');
606
+ const since = sinceIdx >= 0 ? args[sinceIdx + 1] : '24h';
607
+ queryUsage({ json: jsonFlag, since });
608
+ } else {
609
+ console.log(`Usage:
610
+ node token-proxy.js start [--port ${DEFAULT_PORT}] Start the proxy
611
+ node token-proxy.js query [--since 1h] [--json] Query token usage
612
+
613
+ Environment (set before starting daemon):
614
+ HTTPS_PROXY=http://127.0.0.1:${DEFAULT_PORT}
615
+ NODE_EXTRA_CA_CERTS=${CA_CERT_PATH}
616
+
617
+ Files:
618
+ CA cert: ${CA_CERT_PATH}
619
+ Usage log: ${USAGE_LOG_PATH}`);
620
+ }
578
621
  }
579
622
 
580
- if (require.main !== module) {
581
- module.exports = { extractUsageFromResponse, extractDispatchInfo, buildUsageRecord };
582
- }
623
+ module.exports = { extractUsageFromResponse, extractDispatchInfo, buildUsageRecord,
624
+ guardConnection, createProxyServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autowonder",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "AutoWonder local runtime — execute AI agent dispatch packages on your machine",
5
5
  "bin": {
6
6
  "autowonder": "bin/cli.js"