cofluxd 2.7.0 → 2.9.0

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/coflux.mjs CHANGED
@@ -5,11 +5,14 @@ import { randomUUID } from "node:crypto";
5
5
  import { parseArgs } from "node:util";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { existsSync } from "node:fs";
8
+ import { request as httpRequest } from "node:http";
8
9
  import { homedir } from "node:os";
9
10
  import { join } from "node:path";
10
11
  const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
11
- // Native integration owns explicit workspace selection and conversation state.
12
- if (process.argv[2] === "agent" || (process.argv[2] === "workspace" && process.argv[3] === "enter")) {
12
+ // Native integration owns explicit workspace selection and conversation state. `secret` is native
13
+ // only too: it speaks the worker's kernel-attested socket and masks child output, and one
14
+ // implementation of that is enough (plan 20260926-agent-secret-input).
15
+ if (process.argv[2] === "agent" || process.argv[2] === "secret" || (process.argv[2] === "workspace" && process.argv[3] === "enter")) {
13
16
  const native = process.env.COFLUX_AGENT_BUNDLE
14
17
  ? join(process.env.COFLUX_AGENT_BUNDLE, "coflux")
15
18
  : join(HOME, "bin", "coflux");
@@ -33,6 +36,93 @@ function localGatewayPort() {
33
36
  }
34
37
  return { ok: true, port };
35
38
  }
39
+
40
+ /* ---------------------- local transport (plan 20260926-agent-endpoint-hardening) ---------------------- */
41
+ // `/agent` and `/hook` reach the worker over its kernel-attested Unix socket first; the worker reads
42
+ // this process's pid from the kernel, so the body carries no pid/ppid there. The loopback TCP port
43
+ // is used only when the socket is **absent** (no file, or nobody listening: an older worker), and
44
+ // only then is COFLUX_LOCAL_GATEWAY_PORT read. A reply from the socket, refusals included, is final
45
+ // and never retried over TCP; a connect refused for any other reason (a sandbox's EPERM/EACCES)
46
+ // fails hard and names the socket. Mirrors `local_post` in crates/cli/src/gateway.rs.
47
+ // The path mirrors `SOCKET_FILE` in crates/worker/src/agent_socket.rs.
48
+ const AGENT_SOCKET = join(HOME, "ipc", "agent.sock");
49
+ /** The worker never binds a longer socket path (sun_path limits), so a longer one is absent. */
50
+ const MAX_SOCKET_PATH_BYTES = 100;
51
+
52
+ /**
53
+ * One HTTP/1.1 POST over the agent socket.
54
+ * → { ok: true, status, text } | { ok: false, kind: "absent" | "denied" | "transport", message }
55
+ */
56
+ function socketPost(path, payload, timeoutMs) {
57
+ if (Buffer.byteLength(AGENT_SOCKET) > MAX_SOCKET_PATH_BYTES) return Promise.resolve({ ok: false, kind: "absent" });
58
+ return new Promise((resolve) => {
59
+ let settled = false;
60
+ let timedOut = false;
61
+ const finish = (result) => {
62
+ if (settled) return;
63
+ settled = true;
64
+ clearTimeout(timer);
65
+ resolve(result);
66
+ };
67
+ const req = httpRequest(
68
+ {
69
+ socketPath: AGENT_SOCKET,
70
+ path,
71
+ method: "POST",
72
+ agent: false,
73
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload), connection: "close" },
74
+ },
75
+ (res) => {
76
+ const chunks = [];
77
+ res.on("data", (chunk) => chunks.push(chunk));
78
+ res.on("end", () => finish({ ok: true, status: res.statusCode, text: Buffer.concat(chunks).toString("utf8") }));
79
+ res.on("error", (error) => finish({ ok: false, kind: "transport", message: error?.message || String(error) }));
80
+ },
81
+ );
82
+ const timer = setTimeout(() => {
83
+ timedOut = true;
84
+ req.destroy(new Error("请求超时"));
85
+ }, timeoutMs);
86
+ req.on("error", (error) => {
87
+ if (!timedOut && error?.syscall === "connect") {
88
+ if (error.code === "ENOENT" || error.code === "ECONNREFUSED") return finish({ ok: false, kind: "absent" });
89
+ return finish({
90
+ ok: false,
91
+ kind: "denied",
92
+ message: `cannot connect to the coflux daemon's agent socket at ${AGENT_SOCKET} (${error.code || error.message}); this process is not allowed to reach it (a sandbox without local network access?)`,
93
+ });
94
+ }
95
+ finish({ ok: false, kind: "transport", message: error?.message || String(error) });
96
+ });
97
+ req.end(payload);
98
+ });
99
+ }
100
+
101
+ /**
102
+ * POST one JSON body to the local daemon: the agent socket first, the TCP gateway only when the
103
+ * socket is absent. `body.pid`/`body.ppid` are transport business: dropped on the socket, set to
104
+ * this process's on TCP.
105
+ * → { ok: true, status, text } | { ok: false, kind: "refused" | "transport", message }
106
+ */
107
+ async function localPost(path, body, timeoutMs) {
108
+ const { pid: _pid, ppid: _ppid, ...rest } = body;
109
+ const viaSocket = await socketPost(path, JSON.stringify(rest), timeoutMs);
110
+ if (viaSocket.ok || viaSocket.kind === "transport") return viaSocket;
111
+ if (viaSocket.kind === "denied") return { ok: false, kind: "refused", message: viaSocket.message };
112
+ const portResult = localGatewayPort();
113
+ if (!portResult.ok) return { ok: false, kind: "refused", message: portResult.error };
114
+ try {
115
+ const res = await fetch(`http://127.0.0.1:${portResult.port}${path}`, {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body: JSON.stringify({ ...rest, pid: process.pid, ppid: process.ppid }),
119
+ signal: AbortSignal.timeout(timeoutMs),
120
+ });
121
+ return { ok: true, status: res.status, text: await res.text() };
122
+ } catch (error) {
123
+ return { ok: false, kind: "transport", message: error?.message || String(error) };
124
+ }
125
+ }
36
126
  /* ------------------------------ hook:agent 事件信使 ------------------------------ */
37
127
  // agent hook 的上报信使:用户在 claude/codex 的 hook 配置里指向本命令,事件发生时它把
38
128
  // 事件名转发给本机 worker 的固定 gateway(POST /hook),供活动状态判定。
@@ -85,11 +175,6 @@ async function cmdHook() {
85
175
  hookDebug("payload 缺事件名,忽略");
86
176
  return;
87
177
  }
88
- const portResult = localGatewayPort();
89
- if (!portResult.ok) {
90
- hookDebug(portResult.error);
91
- return;
92
- }
93
178
  const notification = payload.notification_type ?? payload.notificationType;
94
179
  const body = {
95
180
  agent,
@@ -106,13 +191,9 @@ async function cmdHook() {
106
191
  backgroundTasks: Array.isArray(payload.background_tasks) ? payload.background_tasks.length : undefined,
107
192
  };
108
193
  hookDebug("POST /hook", JSON.stringify(body));
109
- const res = await fetch(`http://127.0.0.1:${portResult.port}/hook`, {
110
- method: "POST",
111
- headers: { "content-type": "application/json" },
112
- body: JSON.stringify(body),
113
- signal: AbortSignal.timeout(HOOK_POST_TIMEOUT_MS),
114
- });
115
- hookDebug(`响应 ${res.status}`);
194
+ // The agent socket first; the gateway port is resolved only when the socket is absent.
195
+ const res = await localPost("/hook", body, HOOK_POST_TIMEOUT_MS);
196
+ hookDebug(res.ok ? `响应 ${res.status}` : res.message);
116
197
  } catch (error) {
117
198
  hookDebug(error?.message || String(error));
118
199
  } finally {
@@ -163,23 +244,15 @@ function agentTimeoutMs() {
163
244
  // failure may be re-sent with the same submissionId (the daemon deduplicates on it); re-sending a
164
245
  // "refused" request accomplishes nothing. Mirrors `AgentError` in crates/cli/src/gateway.rs.
165
246
  async function agentPostResult(body) {
166
- const portResult = localGatewayPort();
167
- if (!portResult.ok) return { ok: false, kind: "refused", message: portResult.error };
168
- let res;
169
- try {
170
- res = await fetch(`http://127.0.0.1:${portResult.port}/agent`, {
171
- method: "POST",
172
- headers: { "content-type": "application/json" },
173
- body: JSON.stringify({ ...body, pid: process.pid, ppid: process.ppid, cwd: callerCwd() }),
174
- signal: AbortSignal.timeout(agentTimeoutMs()),
175
- });
176
- } catch (error) {
177
- const detail = error?.message || error;
178
- return { ok: false, kind: "transport", message: `连不上本机 daemon:${detail}(daemon 没在跑?查看 Coflux.app 或 cofluxd status)` };
247
+ const res = await localPost("/agent", { ...body, cwd: callerCwd() }, agentTimeoutMs());
248
+ if (!res.ok) {
249
+ if (res.kind === "refused") return res;
250
+ return { ok: false, kind: "transport", message: `连不上本机 daemon:${res.message}(daemon 没在跑?查看 Coflux.app 或 cofluxd status)` };
179
251
  }
180
252
  let parsed = null;
181
- try { parsed = await res.json(); } catch { /* 非 JSON 响应按下面的兜底报错处理 */ }
182
- if (!res.ok || !parsed?.ok) return { ok: false, kind: "refused", message: parsed?.error || `daemon 返回 ${res.status}` };
253
+ try { parsed = JSON.parse(res.text); } catch { /* 非 JSON 响应按下面的兜底报错处理 */ }
254
+ const success = res.status >= 200 && res.status < 300;
255
+ if (!success || !parsed?.ok) return { ok: false, kind: "refused", message: parsed?.error || `daemon 返回 ${res.status}` };
183
256
  return { ok: true, value: parsed };
184
257
  }
185
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Coflux 无界面宿主(cofluxd)与统一操作工具(coflux)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: coflux
3
- description: Use coflux to enter workspaces, open terminals the user can see and take over, run commands in them, wait for those commands, read their scrollback, type into them, run one-shot commands on another device in the account and get their output, report progress, notify the user, obtain preview URLs, and hand a bounded mechanical sub-task to the built-in executor instead of spending your own context on it. Prefer zero-credential local commands in the current workspace; use the account CLI across workspaces and devices. Coordinates arrive through coflux-session or COFLUX_* variables.
3
+ description: Use coflux to enter workspaces, open terminals the user can see and take over, run commands in them, wait for those commands, read their scrollback, type into them, run one-shot commands on another device in the account and get their output, report progress, notify the user, obtain preview URLs, get a secret (API key, password) from the user without the value entering your context, and hand a bounded mechanical sub-task to the built-in executor instead of spending your own context on it. Prefer zero-credential local commands in the current workspace; use the account CLI across workspaces and devices. Coordinates arrive through coflux-session or COFLUX_* variables.
4
4
  ---
5
5
 
6
6
  # Working inside coflux
@@ -14,7 +14,7 @@ and a way to operate the other workspaces and devices under the account when you
14
14
 
15
15
  | Track | Credentials | Reach | Use for |
16
16
  |---|---|---|---|
17
- | Local commands `coflux terminal/progress/notify/ports/executor` | none (the daemon identifies you by process tree) | **the workspace your cwd is in** | open, run, wait, read, send, close, report progress, call the user, preview URLs, hand a bounded sub-task to the built-in executor: the default; some actions require a server connection |
17
+ | Local commands `coflux terminal/progress/notify/ports/executor/secret` | none (the daemon identifies you by process tree) | **the workspace your cwd is in** | open, run, wait, read, send, close, report progress, call the user, preview URLs, get a secret from the user, hand a bounded sub-task to the built-in executor: the default; some actions require a server connection |
18
18
  | Account CLI | app login or `coflux login` | all devices and workspaces in the account | child workspaces and remote terminals; JSON output |
19
19
 
20
20
  Of the local commands, `run`/`wait`/`read`/`send`/`close`/`progress`/`executor` complete
@@ -467,6 +467,49 @@ says so in one line — relay that to the user instead of retrying.
467
467
  **Images and other files are not an input.** The prompt is a task description with a size cap, not a
468
468
  file channel: point at paths inside the workspace instead of trying to hand anything over.
469
469
 
470
+ ### Get a secret from the user
471
+
472
+ ```sh
473
+ coflux secret ask OPENAI_API_KEY --reason "Run the integration tests against the real API"
474
+ coflux secret exec OPENAI_API_KEY -- pnpm test:integration
475
+ coflux secret inject DATABASE_URL --file .env.local
476
+ ```
477
+
478
+ When a task needs a value only the user has — an API key, a token, a database password — and it
479
+ goes to a **non-interactive** destination (a command that reads it from the environment, or a
480
+ dotenv/config file), ask for it with `coflux secret`. **Never ask the user to paste a secret into
481
+ the chat**: that puts it in the transcript, the model provider's logs and on screen. With
482
+ `coflux secret` you never see the value; you get a name and an outcome, and every use of the value
483
+ goes back through coflux.
484
+
485
+ - `ask NAME --reason "<why>"` shows a request card over this terminal on every Coflux desktop of
486
+ the account (plus one inbox notification). It blocks until the user answers, then prints exactly
487
+ one word: `provided`, `declined` or `cancelled` (closed card, timeout — default 10 minutes,
488
+ `--timeout <seconds>` — or the terminal ended). Exit status is 0 only for `provided`. Write the
489
+ reason for the user: say what the value is for and where it will go. NAME is an
490
+ environment-variable name. Asking again for a NAME replaces its value. On `declined`, do not ask
491
+ again unprompted; on `cancelled`, tell the user what you were waiting for before retrying. A
492
+ timeout with no card on the user's screen usually means their desktop app is too old — say so.
493
+ - `exec NAME [NAME…] -- <cmd> [args…]` runs the command with each value in a same-name environment
494
+ variable, passes its exit status through, and shows every occurrence of a value in its output
495
+ as `***`. A NAME that was not provided in this terminal fails with a sentence telling you to
496
+ `ask` first.
497
+ - `inject NAME --file <path> [--key KEY]` makes the daemon insert or update `KEY=value` (KEY
498
+ defaults to NAME) in a dotenv file inside the workspace your cwd is in. A new file is owner-only;
499
+ a path outside the workspace, including through a symlink, is refused. It prints only that the
500
+ file was written. Checking that the file is gitignored is yours.
501
+
502
+ Values belong to **this terminal**: only processes in it can use them, and they are dropped when it
503
+ ends (and when the user's device restarts its coflux runtime) — a new terminal must `ask` again.
504
+ They live only in the local daemon's memory, never on disk or on the center. `coflux terminal read`
505
+ and the center's copy of any terminal show a held value as `***`.
506
+
507
+ Not for interactive prompts: an `ssh` or `sudo` password prompt stays with the user — open a
508
+ terminal they can take over, `coflux notify` them, and `coflux terminal wait`. The built-in
509
+ executor cannot use `coflux secret` (it is not a terminal process). And it prevents accidents, not
510
+ a determined agent: once a value is in an environment variable or a file you can read, printing it
511
+ on purpose would leak it — never do that.
512
+
470
513
  ### Errors from local commands
471
514
 
472
515
  Errors are one readable sentence; do what they say: "not inside a coflux terminal" = you are not
@@ -596,6 +639,8 @@ There is no `--workspace`, and there is **no stdin**.
596
639
  - A workspace has a cap on concurrently live terminals (default 8, including the user's own).
597
640
  On hitting the cap, `list` first: usually some finished terminals were never collected. If the
598
641
  user really filled it up, `notify` them instead of forcing it.
642
+ - `coflux secret ask` needs the daemon connected to the center (the request reaches the user's
643
+ desktops through it) and fails at once otherwise; `exec` and `inject` stay local.
599
644
  - `new`/`list`/`ports`/`notify` and account commands need the daemon connected to the center; "letting the
600
645
  user see" is their whole point. `run`/`wait`/`read`/`send`/`close`/`progress` do not
601
646
  depend on the center. When disconnected they fail loudly rather than degrade silently.