cotal-ai 0.93.0 → 0.94.1

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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The client side of the local daemon endpoint, used by `cotal daemon status`.
3
+ *
4
+ * It refuses before connecting unless the directory and socket pass the same ownership and mode
5
+ * checks the server applies, and refuses after connecting, before writing a single byte, unless
6
+ * the kernel reports the listening peer as this same uid. So a squatter under another uid, or a
7
+ * socket in a loosened directory, never receives a request.
8
+ */
9
+ import { connect as netConnect } from "node:net";
10
+ import { randomUUID } from "node:crypto";
11
+ import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES, EndpointRefusal, assertLinux, checkDir, checkSocket } from "./endpoint.js";
12
+ import { encode } from "./protocol.js";
13
+ function connectTo(path, timeoutMs) {
14
+ return new Promise((resolve, reject) => {
15
+ const s = netConnect(path);
16
+ const t = setTimeout(() => {
17
+ s.destroy();
18
+ reject(new Error(`timed out connecting to ${path}`));
19
+ }, timeoutMs);
20
+ s.once("connect", () => {
21
+ clearTimeout(t);
22
+ resolve(s);
23
+ });
24
+ s.once("error", (e) => {
25
+ clearTimeout(t);
26
+ if (e.code === "ENOENT" || e.code === "ECONNREFUSED")
27
+ reject(new EndpointRefusal("NOT_RUNNING", `no daemon answers at ${path}`));
28
+ else
29
+ reject(e);
30
+ });
31
+ });
32
+ }
33
+ function readLine(sock, timeoutMs) {
34
+ return new Promise((resolve, reject) => {
35
+ let buf = "";
36
+ const t = setTimeout(() => finish(new Error("timed out waiting for the daemon's reply")), timeoutMs);
37
+ const finish = (err, line) => {
38
+ clearTimeout(t);
39
+ sock.removeAllListeners("data");
40
+ sock.removeAllListeners("end");
41
+ sock.removeAllListeners("error");
42
+ if (err)
43
+ reject(err);
44
+ else
45
+ resolve(line);
46
+ };
47
+ sock.setEncoding("utf8");
48
+ sock.on("data", (chunk) => {
49
+ buf += chunk;
50
+ const nl = buf.indexOf("\n");
51
+ if (nl !== -1)
52
+ finish(undefined, buf.slice(0, nl));
53
+ else if (Buffer.byteLength(buf, "utf8") > MAX_LINE_BYTES)
54
+ finish(new Error("daemon reply exceeds the line limit"));
55
+ });
56
+ sock.once("end", () => finish(new Error("daemon closed the connection without a reply")));
57
+ sock.once("error", (e) => finish(e));
58
+ });
59
+ }
60
+ /** Send one request and return the daemon's parsed reply. */
61
+ export async function queryDaemon(opts) {
62
+ assertLinux();
63
+ const uid = opts.uid ?? process.geteuid();
64
+ const timeoutMs = opts.timeoutMs ?? 5000;
65
+ const counters = opts.counters ?? { bytesWritten: 0 };
66
+ const { paths } = opts;
67
+ try {
68
+ checkDir(paths.dir, uid);
69
+ checkSocket(paths.socket, uid);
70
+ }
71
+ catch (e) {
72
+ if (e.code === "ENOENT")
73
+ throw new EndpointRefusal("NOT_RUNNING", `no daemon endpoint at ${paths.socket}`);
74
+ throw e;
75
+ }
76
+ const sock = await connectTo(paths.socket, timeoutMs);
77
+ try {
78
+ let peer;
79
+ try {
80
+ peer = opts.peerUid(sock);
81
+ }
82
+ catch (e) {
83
+ throw new EndpointRefusal("PEER_UID_MISMATCH", `could not read the daemon's peer credentials: ${e.message}`);
84
+ }
85
+ if (peer !== uid)
86
+ throw new EndpointRefusal("PEER_UID_MISMATCH", `the process answering at ${paths.socket} runs as uid ${peer}, not ${uid}; nothing was sent`);
87
+ const line = opts.rawLine !== undefined ? `${opts.rawLine}\n` : encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: "status" });
88
+ counters.bytesWritten += Buffer.byteLength(line, "utf8");
89
+ sock.write(line);
90
+ const reply = await readLine(sock, timeoutMs);
91
+ return JSON.parse(reply);
92
+ }
93
+ finally {
94
+ sock.destroy();
95
+ }
96
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `cotal daemon`: the local machine daemon's control surface, composed in this binary rather than
3
+ * in an `implementations/*` package so it can later wire `@cotal-ai/auth` and `@cotal-ai/manager`
4
+ * without one implementation importing another (AGENTS.md, repository map).
5
+ *
6
+ * This slice serves a local, authenticated status endpoint and nothing else: no sign-in, no
7
+ * Manager, no service install. Linux only. It is a foreground foundation, not a resilient machine
8
+ * service: a daemon killed without cleanup leaves its socket behind, and the next start refuses
9
+ * until the operator removes it (see the ALREADY_RUNNING remedy). `state: "ready"` means the local
10
+ * control listener is bound and verified; it says nothing about any mesh or Cloud connection.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { registry } from "@cotal-ai/core";
16
+ import { CommandFailure, jsonFlag, plainFlag, presentationOf, writeResult } from "@cotal-ai/workspace";
17
+ import { peerCredentials } from "@cotal-ai/seat";
18
+ import { EndpointRefusal, daemonPaths } from "./endpoint.js";
19
+ import { serveDaemon } from "./server.js";
20
+ import { queryDaemon } from "./client.js";
21
+ export const daemonFlags = [jsonFlag, plainFlag];
22
+ /** This binary's own version: the nearest package.json above this module (bin/ in a source run). */
23
+ export function binVersion() {
24
+ let dir = dirname(fileURLToPath(import.meta.url));
25
+ for (let i = 0; i < 4; i++) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
28
+ if (pkg.name === "cotal-ai" && typeof pkg.version === "string")
29
+ return pkg.version;
30
+ }
31
+ catch (e) {
32
+ if (e.code !== "ENOENT")
33
+ throw e;
34
+ }
35
+ dir = dirname(dir);
36
+ }
37
+ throw new Error("cannot determine the cotal-ai version: no cotal-ai package.json above the daemon module");
38
+ }
39
+ const peerUid = (sock) => peerCredentials(sock).uid;
40
+ const REFUSAL = {
41
+ UNSUPPORTED_PLATFORM: { code: "UNSUPPORTED" },
42
+ NO_RUNTIME_DIR: { code: "TARGET_UNAVAILABLE", remedy: "run from a logged-in session with a per-user runtime directory (systemd-logind sets XDG_RUNTIME_DIR)" },
43
+ NOT_RUNNING: { code: "TARGET_UNAVAILABLE", remedy: "start it with cotal daemon serve" },
44
+ ALREADY_RUNNING: { code: "CONFLICT", remedy: "run cotal daemon status: if it answers, a daemon is already running. If it refuses NOT_RUNNING, a previous daemon exited without cleanup and the entry is stale; confirm no cotal daemon process is running, then remove the socket path named above by hand and start again. The daemon never removes it for you" },
45
+ DIR_NOT_DIRECTORY: { code: "PERMISSION_DENIED", remedy: "inspect the path; nothing was changed" },
46
+ DIR_SYMLINK: { code: "PERMISSION_DENIED", remedy: "inspect the path; nothing was changed" },
47
+ DIR_WRONG_OWNER: { code: "PERMISSION_DENIED", remedy: "inspect the path; nothing was changed" },
48
+ DIR_WRONG_MODE: { code: "PERMISSION_DENIED", remedy: "inspect the directory; the daemon never changes the mode of an existing directory" },
49
+ SOCKET_NOT_SOCKET: { code: "PERMISSION_DENIED", remedy: "inspect the path; nothing was removed" },
50
+ SOCKET_WRONG_OWNER: { code: "PERMISSION_DENIED", remedy: "inspect the path; nothing was removed" },
51
+ SOCKET_WRONG_MODE: { code: "PERMISSION_DENIED", remedy: "inspect the socket; nothing was sent" },
52
+ PEER_UID_MISMATCH: { code: "PERMISSION_DENIED", remedy: "another user's process holds the daemon endpoint; nothing was sent" },
53
+ };
54
+ function asFailure(e) {
55
+ if (!(e instanceof EndpointRefusal))
56
+ return e;
57
+ const r = REFUSAL[e.code];
58
+ return new CommandFailure("refused", r.code, e.message, { ...(r.remedy ? { remedy: r.remedy } : {}), native: { code: e.code } });
59
+ }
60
+ function statusView(s) {
61
+ return { sections: [{ title: "Daemon", blocks: [{ kind: "fields", rows: [["State", s.state], ["Version", s.version], ["Protocol", String(s.protocol)], ["PID", String(s.pid)], ["Started", s.startedAt], ["Uptime", `${Math.round(s.uptimeMs / 1000)}s`]] }] }] };
62
+ }
63
+ async function status(args) {
64
+ const presentation = presentationOf(args.values);
65
+ let reply;
66
+ try {
67
+ reply = await queryDaemon({ paths: daemonPaths(), peerUid });
68
+ }
69
+ catch (e) {
70
+ throw asFailure(e);
71
+ }
72
+ if (!reply.ok)
73
+ throw new CommandFailure("failed", "OPERATION_FAILED", `the daemon refused the status request: ${reply.error.code} ${reply.error.message}`);
74
+ writeResult(presentation, { command: "daemon status", target: null, result: reply.result }, statusView);
75
+ }
76
+ async function serve() {
77
+ let running;
78
+ try {
79
+ running = await serveDaemon({ paths: daemonPaths(), version: binVersion(), peerUid, log: (l) => process.stderr.write(`${l}\n`) });
80
+ }
81
+ catch (e) {
82
+ throw asFailure(e);
83
+ }
84
+ process.stdout.write(`daemon ready at ${running.paths.socket}\n`);
85
+ await new Promise((resolve) => {
86
+ const stop = () => void running.close().then(resolve, resolve);
87
+ process.once("SIGTERM", stop);
88
+ process.once("SIGINT", stop);
89
+ });
90
+ }
91
+ async function run(args) {
92
+ const [mode, ...rest] = args.positionals;
93
+ if (rest.length)
94
+ throw new CommandFailure("refused", "USAGE_INVALID", `cotal daemon ${mode} takes no positionals`);
95
+ if (mode === "status")
96
+ return status(args);
97
+ if (mode === "serve")
98
+ return serve();
99
+ throw new CommandFailure("refused", "USAGE_INVALID", mode === undefined ? "cotal daemon needs a mode: status or serve" : `unknown daemon mode ${JSON.stringify(mode)}`, { remedy: "cotal daemon status" });
100
+ }
101
+ const daemonCommand = {
102
+ kind: "command",
103
+ name: "daemon",
104
+ group: "Manager",
105
+ summary: "the local machine daemon (Linux): serve its control endpoint, read its status",
106
+ positionals: "<status|serve>",
107
+ flags: daemonFlags,
108
+ subcommands: [
109
+ { name: "status", summary: "ask the running daemon for its status over the local endpoint", outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
110
+ { name: "serve", summary: "run the daemon in the foreground and serve its local endpoint (refusals honor --json)", flagNames: ["json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
111
+ ],
112
+ prepareMeshTarget: false,
113
+ examples: ["cotal daemon serve", "cotal daemon status", "cotal daemon status --json"],
114
+ run,
115
+ };
116
+ registry.register(daemonCommand);
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The local control endpoint of `cotal daemon`, Linux only.
3
+ *
4
+ * Trust boundary, stated as it is: the kernel separates OS users, not processes of one user. The
5
+ * runtime directory is owned by the daemon's uid with mode 0700, the socket is 0600, and both ends
6
+ * read `SO_PEERCRED` through the stock `@cotal-ai/seat` helper and require the peer to be that same
7
+ * uid. Any process running as that uid can still reach the daemon and read its files; that user is
8
+ * the trust principal (see docs/connect-jcode.md, "Security limits").
9
+ *
10
+ * Nothing here repairs a path into shape. A pre-existing directory that is a symlink, not owned by
11
+ * this uid, or not exactly 0700 is refused, never chmod-ed. Any pre-existing entry at the socket
12
+ * path is refused, never removed, so this code never unlinks a socket it did not create.
13
+ */
14
+ import { lstatSync, mkdirSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ /** Wire protocol version. A request naming any other version is refused before any effect. */
17
+ export const DAEMON_PROTOCOL_VERSION = 1;
18
+ /** Largest accepted request or response line, in bytes, including its newline. */
19
+ export const MAX_LINE_BYTES = 64 * 1024;
20
+ /** A refusal at the local endpoint, typed so callers and tests can count it. */
21
+ export class EndpointRefusal extends Error {
22
+ code;
23
+ constructor(code, message) {
24
+ super(message);
25
+ this.name = "EndpointRefusal";
26
+ this.code = code;
27
+ }
28
+ }
29
+ /** The fixed endpoint location for this user: `$XDG_RUNTIME_DIR/cotal/daemon.sock`. */
30
+ export function daemonPaths(env = process.env) {
31
+ const runtime = env.XDG_RUNTIME_DIR;
32
+ if (!runtime) {
33
+ throw new EndpointRefusal("NO_RUNTIME_DIR", "XDG_RUNTIME_DIR is not set, so there is no per-user runtime directory to hold the daemon socket");
34
+ }
35
+ const dir = join(runtime, "cotal");
36
+ return { dir, socket: join(dir, "daemon.sock") };
37
+ }
38
+ export function assertLinux(platform = process.platform) {
39
+ if (platform !== "linux") {
40
+ throw new EndpointRefusal("UNSUPPORTED_PLATFORM", `the local daemon endpoint is Linux only (this is ${platform})`);
41
+ }
42
+ }
43
+ function euid() {
44
+ if (typeof process.geteuid !== "function")
45
+ throw new EndpointRefusal("UNSUPPORTED_PLATFORM", "this platform has no effective uid");
46
+ return process.geteuid();
47
+ }
48
+ /** Refuse unless `dir` is a real directory owned by this uid with mode exactly 0700. */
49
+ export function checkDir(dir, uid = euid()) {
50
+ const st = lstatSync(dir);
51
+ if (st.isSymbolicLink())
52
+ throw new EndpointRefusal("DIR_SYMLINK", `${dir} is a symlink; the daemon directory must be a real directory`);
53
+ if (!st.isDirectory())
54
+ throw new EndpointRefusal("DIR_NOT_DIRECTORY", `${dir} exists and is not a directory`);
55
+ if (st.uid !== uid)
56
+ throw new EndpointRefusal("DIR_WRONG_OWNER", `${dir} is owned by uid ${st.uid}, not ${uid}`);
57
+ const mode = st.mode & 0o777;
58
+ if (mode !== 0o700)
59
+ throw new EndpointRefusal("DIR_WRONG_MODE", `${dir} has mode ${mode.toString(8).padStart(4, "0")}, not 0700`);
60
+ }
61
+ /** Refuse unless `path` is a socket owned by this uid with mode exactly 0600. */
62
+ export function checkSocket(path, uid = euid()) {
63
+ const st = lstatSync(path);
64
+ if (!st.isSocket())
65
+ throw new EndpointRefusal("SOCKET_NOT_SOCKET", `${path} exists and is not a socket`);
66
+ if (st.uid !== uid)
67
+ throw new EndpointRefusal("SOCKET_WRONG_OWNER", `${path} is owned by uid ${st.uid}, not ${uid}`);
68
+ const mode = st.mode & 0o777;
69
+ if (mode !== 0o600)
70
+ throw new EndpointRefusal("SOCKET_WRONG_MODE", `${path} has mode ${mode.toString(8).padStart(4, "0")}, not 0600`);
71
+ }
72
+ function exists(path) {
73
+ try {
74
+ lstatSync(path);
75
+ return true;
76
+ }
77
+ catch (e) {
78
+ if (e.code === "ENOENT")
79
+ return false;
80
+ throw e;
81
+ }
82
+ }
83
+ /** Create the directory 0700 if absent; if present, check it and never change it. */
84
+ export function ensureDir(dir, uid = euid()) {
85
+ if (!exists(dir)) {
86
+ try {
87
+ mkdirSync(dir, { mode: 0o700 });
88
+ checkDir(dir, uid);
89
+ return "created";
90
+ }
91
+ catch (e) {
92
+ // A concurrent creator won the race: fall through and check what is there now.
93
+ if (e.code !== "EEXIST")
94
+ throw e;
95
+ }
96
+ }
97
+ checkDir(dir, uid);
98
+ return "existing";
99
+ }
100
+ /**
101
+ * Refuse any pre-existing entry at the public socket path. There is no automatic stale-socket
102
+ * reclamation: a leftover socket from a crashed daemon is reported, never removed, because probing
103
+ * then unlinking is a race that can delete another starter's live socket. The operator removes a
104
+ * known-stale socket by hand; `link(2)` at bind time then gives exactly one winner.
105
+ */
106
+ export function refuseExisting(path) {
107
+ if (!exists(path))
108
+ return;
109
+ const st = lstatSync(path);
110
+ if (!st.isSocket())
111
+ throw new EndpointRefusal("SOCKET_NOT_SOCKET", `${path} exists and is not a socket; nothing was removed`);
112
+ throw new EndpointRefusal("ALREADY_RUNNING", `${path} already exists (uid ${st.uid}); a daemon may be running, or a previous one exited without cleanup. Nothing was removed`);
113
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The closed, versioned wire contract of the local daemon endpoint: one JSON object per line.
3
+ *
4
+ * A request is exactly `{ v, id, op }`. Any other key, any other version, an unknown op, a line
5
+ * that is not a JSON object, or a line over {@link MAX_LINE_BYTES} is refused before any effect,
6
+ * with a typed error. Responses echo the request id when one could be read.
7
+ */
8
+ import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES } from "./endpoint.js";
9
+ export const DAEMON_OPS = ["status"];
10
+ const REQUEST_KEYS = new Set(["v", "id", "op"]);
11
+ const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
12
+ /** Parse one line (without its newline). Pure: it decides, it does nothing. */
13
+ export function parseRequest(line) {
14
+ if (Buffer.byteLength(line, "utf8") + 1 > MAX_LINE_BYTES)
15
+ return { ok: false, id: null, code: "LINE_TOO_LONG", message: `request exceeds ${MAX_LINE_BYTES} bytes` };
16
+ let raw;
17
+ try {
18
+ raw = JSON.parse(line);
19
+ }
20
+ catch {
21
+ return { ok: false, id: null, code: "BAD_JSON", message: "request is not valid JSON" };
22
+ }
23
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
24
+ return { ok: false, id: null, code: "NOT_OBJECT", message: "request must be a JSON object" };
25
+ const obj = raw;
26
+ const id = typeof obj.id === "string" && ID_PATTERN.test(obj.id) ? obj.id : null;
27
+ for (const k of Object.keys(obj)) {
28
+ if (!REQUEST_KEYS.has(k))
29
+ return { ok: false, id, code: "UNKNOWN_FIELD", message: `unknown request field ${JSON.stringify(k)}` };
30
+ }
31
+ if (obj.v !== DAEMON_PROTOCOL_VERSION)
32
+ return { ok: false, id, code: "BAD_VERSION", message: `unsupported protocol version ${JSON.stringify(obj.v)}; this daemon speaks ${DAEMON_PROTOCOL_VERSION}` };
33
+ if (id === null)
34
+ return { ok: false, id: null, code: "BAD_ID", message: "request id must be 1-64 characters of [A-Za-z0-9_-]" };
35
+ if (typeof obj.op !== "string" || !DAEMON_OPS.includes(obj.op))
36
+ return { ok: false, id, code: "UNKNOWN_OP", message: `unknown op ${JSON.stringify(obj.op)}` };
37
+ return { ok: true, request: { v: DAEMON_PROTOCOL_VERSION, id, op: obj.op } };
38
+ }
39
+ export function encode(msg) {
40
+ return `${JSON.stringify(msg)}\n`;
41
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The `cotal daemon serve` listener: binds the local endpoint, answers `status` once it is ready,
3
+ * and removes only its own socket on shutdown.
4
+ *
5
+ * Readiness is real: `status` can only be answered by a listener that has passed the directory
6
+ * check, found the public path empty, bound a private name, verified it is a 0600 socket owned by
7
+ * this uid, and published it with link(2), which yields exactly one winner among concurrent
8
+ * starters. A peer whose kernel-reported uid differs is dropped before one byte of its input is
9
+ * decoded.
10
+ */
11
+ import { chmodSync, linkSync, unlinkSync, lstatSync } from "node:fs";
12
+ import { randomBytes } from "node:crypto";
13
+ import { join } from "node:path";
14
+ import { createServer } from "node:net";
15
+ import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES, EndpointRefusal, assertLinux, checkSocket, ensureDir, refuseExisting } from "./endpoint.js";
16
+ import { encode, parseRequest } from "./protocol.js";
17
+ function listen(server, path) {
18
+ return new Promise((resolve, reject) => {
19
+ const onError = (e) => {
20
+ server.off("listening", onListening);
21
+ reject(e);
22
+ };
23
+ const onListening = () => {
24
+ server.off("error", onError);
25
+ resolve();
26
+ };
27
+ server.once("error", onError);
28
+ server.once("listening", onListening);
29
+ server.listen(path);
30
+ });
31
+ }
32
+ export async function serveDaemon(opts) {
33
+ assertLinux();
34
+ const uid = opts.uid ?? process.geteuid();
35
+ const log = opts.log ?? (() => { });
36
+ const { paths } = opts;
37
+ const dirOutcome = ensureDir(paths.dir, uid);
38
+ refuseExisting(paths.socket);
39
+ const counters = { accepted: 0, peerRejected: 0, framesDecoded: 0, requestsRefused: 0, statusAnswered: 0 };
40
+ const startedAt = new Date();
41
+ const status = () => ({
42
+ state: "ready",
43
+ protocol: DAEMON_PROTOCOL_VERSION,
44
+ version: opts.version,
45
+ pid: process.pid,
46
+ startedAt: startedAt.toISOString(),
47
+ uptimeMs: Date.now() - startedAt.getTime(),
48
+ });
49
+ const sockets = new Set();
50
+ const server = createServer({ allowHalfOpen: false, pauseOnConnect: true }, (sock) => {
51
+ sockets.add(sock);
52
+ sock.once("close", () => sockets.delete(sock));
53
+ sock.on("error", () => { });
54
+ let peer;
55
+ try {
56
+ peer = opts.peerUid(sock);
57
+ }
58
+ catch (e) {
59
+ counters.peerRejected++;
60
+ log(`daemon: peer credentials unreadable (${e.message}); dropped`);
61
+ sock.destroy();
62
+ return;
63
+ }
64
+ if (peer !== uid) {
65
+ counters.peerRejected++;
66
+ log(`daemon: peer uid ${peer} is not ${uid}; dropped before any frame was read`);
67
+ sock.destroy();
68
+ return;
69
+ }
70
+ counters.accepted++;
71
+ let buf = "";
72
+ let bufBytes = 0;
73
+ sock.setEncoding("utf8");
74
+ sock.on("data", (chunk) => {
75
+ buf += chunk;
76
+ bufBytes += Buffer.byteLength(chunk, "utf8");
77
+ // Enforce the line limit while buffering: an unterminated stream is cut off at the limit,
78
+ // never accumulated.
79
+ if (bufBytes > MAX_LINE_BYTES && !buf.includes("\n")) {
80
+ counters.requestsRefused++;
81
+ const r = { v: DAEMON_PROTOCOL_VERSION, id: null, ok: false, error: { code: "LINE_TOO_LONG", message: `request exceeds ${MAX_LINE_BYTES} bytes` } };
82
+ sock.removeAllListeners("data");
83
+ sock.end(encode(r));
84
+ sock.destroySoon();
85
+ buf = "";
86
+ bufBytes = 0;
87
+ return;
88
+ }
89
+ let nl;
90
+ while ((nl = buf.indexOf("\n")) !== -1) {
91
+ const line = buf.slice(0, nl);
92
+ buf = buf.slice(nl + 1);
93
+ bufBytes = Buffer.byteLength(buf, "utf8");
94
+ counters.framesDecoded++;
95
+ const parsed = parseRequest(line);
96
+ if (!parsed.ok) {
97
+ counters.requestsRefused++;
98
+ sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: parsed.id, ok: false, error: { code: parsed.code, message: parsed.message } }));
99
+ continue;
100
+ }
101
+ counters.statusAnswered++;
102
+ sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: parsed.request.id, ok: true, result: status() }));
103
+ }
104
+ });
105
+ sock.resume();
106
+ });
107
+ // Bind on a private name inside the verified 0700 directory, then publish it at the fixed path
108
+ // with link(2), which refuses to replace anything already there. Node's server.close() unlinks
109
+ // whatever sits at the path it bound (measured on Node 24), so binding the private name means
110
+ // close() can only ever remove that name, never a socket another process later put at the
111
+ // public path. The private name is removed as soon as the link exists.
112
+ const bindPath = join(paths.dir, `.bind-${process.pid}-${randomBytes(6).toString("hex")}`);
113
+ // umask is process-wide; it is narrowed only around listen() and restored in `finally`. The
114
+ // directory is already verified 0700 and the bound socket is chmod-ed and re-checked below, so
115
+ // the umask is defense in depth, not the guarantee.
116
+ const previous = process.umask(0o177);
117
+ try {
118
+ await listen(server, bindPath);
119
+ }
120
+ finally {
121
+ process.umask(previous);
122
+ }
123
+ const abort = async (e) => {
124
+ await new Promise((r) => server.close(() => r()));
125
+ throw e;
126
+ };
127
+ try {
128
+ chmodSync(bindPath, 0o600);
129
+ checkSocket(bindPath, uid);
130
+ linkSync(bindPath, paths.socket);
131
+ }
132
+ catch (e) {
133
+ if (e.code === "EEXIST")
134
+ return abort(new EndpointRefusal("ALREADY_RUNNING", `another process published ${paths.socket} first; nothing was replaced`));
135
+ return abort(e);
136
+ }
137
+ unlinkSync(bindPath);
138
+ try {
139
+ checkSocket(paths.socket, uid);
140
+ }
141
+ catch (e) {
142
+ return abort(e);
143
+ }
144
+ const bound = lstatSync(paths.socket);
145
+ const boundDev = bound.dev, boundIno = bound.ino;
146
+ log(`daemon: ready at ${paths.socket}`);
147
+ let closed;
148
+ const close = () => {
149
+ closed ??= (async () => {
150
+ for (const s of sockets)
151
+ s.destroy();
152
+ await new Promise((r) => server.close(() => r()));
153
+ // Remove the public socket only if it is still the exact file (dev + ino) this process
154
+ // created; a replacement of any kind is left in place.
155
+ try {
156
+ const st = lstatSync(paths.socket);
157
+ if (st.isSocket() && st.uid === uid && st.dev === boundDev && st.ino === boundIno)
158
+ unlinkSync(paths.socket);
159
+ }
160
+ catch (e) {
161
+ if (e.code !== "ENOENT")
162
+ throw e;
163
+ }
164
+ })();
165
+ return closed;
166
+ };
167
+ return { paths, counters, dirOutcome, status, close };
168
+ }
169
+ export { EndpointRefusal };
package/dist/run.js CHANGED
@@ -18,6 +18,7 @@ import "@cotal-ai/delivery"; // self-registers `deliver` — the server-side Pla
18
18
  import "@cotal-ai/runtime"; // self-registers `run` — the workflow-run operator surface (start/resume/ps/journal/answer)
19
19
  import "@cotal-ai/auth"; // self-registers login / logout — per-user IdP sessions (device-code sign-in)
20
20
  import "./docs.js"; // self-registers `docs`; the docs bundle loads only when `docs` runs
21
+ import "./daemon/command.js"; // self-registers `daemon`: the local machine daemon's authenticated control endpoint (Linux)
21
22
  // Self-registers the `ag-ui.frame` part renderer, so `cotal console` and `cotal join` draw an event
22
23
  // frame instead of `[unrenderable part kind "ag-ui.frame"]`.
23
24
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cotal-ai",
3
- "version": "0.93.0",
3
+ "version": "0.94.1",
4
4
  "description": "Cotal — lateral agent coordination over NATS. Run `npx cotal-ai` for the CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -33,25 +33,26 @@
33
33
  "node": ">=22"
34
34
  },
35
35
  "dependencies": {
36
- "@cotal-ai/auth": "0.93.0",
37
- "@cotal-ai/cli": "0.93.0",
38
- "@cotal-ai/connector-core": "0.93.0",
39
- "@cotal-ai/core": "0.93.0",
40
- "@cotal-ai/delivery": "0.93.0",
41
- "@cotal-ai/manager": "0.93.0",
42
- "@cotal-ai/runtime": "0.93.0",
43
- "@cotal-ai/workspace": "0.93.0"
36
+ "@cotal-ai/auth": "0.94.1",
37
+ "@cotal-ai/cli": "0.94.1",
38
+ "@cotal-ai/connector-core": "0.94.1",
39
+ "@cotal-ai/core": "0.94.1",
40
+ "@cotal-ai/delivery": "0.94.1",
41
+ "@cotal-ai/manager": "0.94.1",
42
+ "@cotal-ai/runtime": "0.94.1",
43
+ "@cotal-ai/seat": "0.94.1",
44
+ "@cotal-ai/workspace": "0.94.1"
44
45
  },
45
46
  "devDependencies": {
46
- "@cotal-ai/connector-claude-code": "0.93.0",
47
- "@cotal-ai/connector-codex": "0.93.0",
48
- "@cotal-ai/connector-hermes": "0.93.0",
49
- "@cotal-ai/connector-jcode": "0.93.0",
50
- "@cotal-ai/connector-opencode": "0.93.0",
51
- "@cotal-ai/linear": "0.93.0",
52
- "@cotal-ai/pi": "0.93.0",
47
+ "@cotal-ai/connector-claude-code": "0.94.1",
48
+ "@cotal-ai/connector-codex": "0.94.1",
49
+ "@cotal-ai/connector-hermes": "0.94.1",
50
+ "@cotal-ai/connector-jcode": "0.94.1",
51
+ "@cotal-ai/connector-opencode": "0.94.1",
52
+ "@cotal-ai/linear": "0.94.1",
53
+ "@cotal-ai/pi": "0.94.1",
53
54
  "@cotal-ai/smoke-kit": "0.0.0",
54
- "@cotal-ai/web": "0.93.0",
55
+ "@cotal-ai/web": "0.94.1",
55
56
  "@nats-io/jetstream": "^3.4.0",
56
57
  "@nats-io/transport-node": "^3.4.0",
57
58
  "typescript": "^5.9.3"
@@ -1 +1 @@
1
- {"version":3,"file":"app-host.d.ts","sourceRoot":"","sources":["../src/app-host.ts"],"names":[],"mappings":"AAKA,OAAO,EAAiH,KAAK,WAAW,EAAkB,MAAM,0BAA0B,CAAC;AAS3L,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,SAAS,MAAM,EAAE,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;CACxB;AAED,MAAM,WAAW,OAAQ,SAAQ,WAAW;IAC1C,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,WAAW,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAC5C;AA4CD,wBAAgB,aAAa,CAAC,CAAC,EAAE,cAAc,GAAG,OAAO,CA+IxD"}
1
+ {"version":3,"file":"app-host.d.ts","sourceRoot":"","sources":["../src/app-host.ts"],"names":[],"mappings":"AAKA,OAAO,EAAiH,KAAK,WAAW,EAAkB,MAAM,0BAA0B,CAAC;AAS3L,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,SAAS,MAAM,EAAE,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;CACxB;AAED,MAAM,WAAW,OAAQ,SAAQ,WAAW;IAC1C,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,WAAW,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAC5C;AA4CD,wBAAgB,aAAa,CAAC,CAAC,EAAE,cAAc,GAAG,OAAO,CAmJxD"}