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 +102 -29
- package/package.json +1 -1
- package/skills/coflux/SKILL.md +47 -2
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
167
|
-
if (!
|
|
168
|
-
|
|
169
|
-
|
|
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 =
|
|
182
|
-
|
|
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
package/skills/coflux/SKILL.md
CHANGED
|
@@ -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.
|