cotal-ai 0.97.1 → 0.99.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.
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { connect as netConnect } from "node:net";
10
10
  import { randomUUID } from "node:crypto";
11
+ import { inspectLock, processStartToken } from "@cotal-ai/core";
11
12
  import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES, EndpointRefusal, assertLinux, checkDir, checkSocket } from "./endpoint.js";
12
13
  import { encode } from "./protocol.js";
13
14
  /** Ask the daemon whether it could register with `space`. Nothing is registered either way. */
@@ -129,7 +130,11 @@ async function openVerified(opts) {
129
130
  assertLinux();
130
131
  const uid = opts.uid ?? process.geteuid();
131
132
  const { paths } = opts;
133
+ if (paths.lock !== undefined && opts.peerPid === undefined)
134
+ throw new Error("an explicit daemon home needs the peer pid check");
132
135
  try {
136
+ if (paths.parent !== undefined)
137
+ checkDir(paths.parent, uid);
133
138
  checkDir(paths.dir, uid);
134
139
  checkSocket(paths.socket, uid);
135
140
  }
@@ -151,6 +156,23 @@ async function openVerified(opts) {
151
156
  sock.destroy();
152
157
  throw new EndpointRefusal("PEER_UID_MISMATCH", `the process answering at ${paths.socket} runs as uid ${peer}, not ${uid}; nothing was sent`);
153
158
  }
159
+ if (paths.lock !== undefined) {
160
+ // Same uid is not enough for an explicit daemon home: the answering process must be the one
161
+ // holding that home's lifetime lock, the same process (pid and start token) the lock recorded.
162
+ let pid;
163
+ try {
164
+ pid = opts.peerPid(sock);
165
+ }
166
+ catch {
167
+ pid = undefined;
168
+ }
169
+ const held = inspectLock(paths.lock);
170
+ const owner = held.state === "active" ? held.owner : undefined;
171
+ if (pid === undefined || owner === undefined || owner.pid !== pid || owner.start === undefined || processStartToken(pid) !== owner.start) {
172
+ sock.destroy();
173
+ throw new EndpointRefusal("PEER_NOT_LOCK_OWNER", `the process answering at ${paths.socket} is not the daemon holding ${paths.lock}; nothing was sent`);
174
+ }
175
+ }
154
176
  return sock;
155
177
  }
156
178
  /** Ask the daemon to sign in, show its prompt, and return its single result. */
@@ -19,14 +19,14 @@
19
19
  * ALREADY_RUNNING remedy). `state: "ready"` means the local control listener is bound and
20
20
  * verified; it says nothing about any mesh or Cloud connection.
21
21
  */
22
- import { readFileSync } from "node:fs";
23
- import { dirname, join } from "node:path";
22
+ import { readFileSync, realpathSync } from "node:fs";
23
+ import { dirname, isAbsolute, join } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { registry } from "@cotal-ai/core";
26
- import { CommandFailure, jsonFlag, plainFlag, presentationOf, renderReport, spaceFlag, terminalWidth, writeResult } from "@cotal-ai/workspace";
26
+ import { CommandFailure, homeCotalDir, jsonFlag, plainFlag, presentationOf, renderReport, spaceFlag, terminalWidth, writeResult } from "@cotal-ai/workspace";
27
27
  import { peerCredentials } from "@cotal-ai/seat";
28
28
  import { normalizeIdpUrl } from "@cotal-ai/auth";
29
- import { EndpointRefusal, daemonPaths } from "./endpoint.js";
29
+ import { EndpointRefusal, checkDir, daemonPaths, daemonPathsFor } from "./endpoint.js";
30
30
  import { serveDaemon } from "./server.js";
31
31
  import { connectCheckViaDaemon, loginViaDaemon, queryDaemon, registerViaDaemon, serviceViaDaemon } from "./client.js";
32
32
  export const daemonFlags = [
@@ -34,6 +34,7 @@ export const daemonFlags = [
34
34
  plainFlag,
35
35
  { name: "idp", type: "string", value: "<auth base URL>", description: "login: the provider's auth base URL, e.g. <origin>/api/auth" },
36
36
  { name: "client-id", type: "string", value: "<id>", description: "login: the OAuth device-flow client id" },
37
+ { name: "daemon-home", type: "string", value: "<abs dir>", description: "run or reach the daemon whose state lives in this directory (absolute, yours, mode 0700, not the Cotal home): its own sign-in, identity and registration, its own socket and lifetime lock. Without it, the default daemon" },
37
38
  spaceFlag,
38
39
  ];
39
40
  /** This binary's own version: the nearest package.json above this module (bin/ in a source run). */
@@ -54,6 +55,37 @@ export function binVersion() {
54
55
  throw new Error("cannot determine the cotal-ai version: no cotal-ai package.json above the daemon module");
55
56
  }
56
57
  const peerUid = (sock) => peerCredentials(sock).uid;
58
+ const peerPid = (sock) => peerCredentials(sock).pid;
59
+ function target(args) {
60
+ const raw = args.values["daemon-home"];
61
+ if (raw === undefined)
62
+ return { paths: daemonPaths() };
63
+ if (!raw || !isAbsolute(raw))
64
+ throw new CommandFailure("refused", "USAGE_INVALID", "--daemon-home needs an absolute directory path");
65
+ checkDir(raw);
66
+ const home = realpathSync(raw);
67
+ let cotalHome;
68
+ try {
69
+ cotalHome = realpathSync(homeCotalDir());
70
+ }
71
+ catch (e) {
72
+ if (e.code !== "ENOENT")
73
+ throw e;
74
+ }
75
+ if (home === cotalHome)
76
+ throw new CommandFailure("refused", "USAGE_INVALID", "--daemon-home cannot be the Cotal home; name a separate directory for this daemon's state");
77
+ return { paths: daemonPathsFor(home), home };
78
+ }
79
+ function resolveTarget(args) {
80
+ try {
81
+ return target(args);
82
+ }
83
+ catch (e) {
84
+ if (e.code === "ENOENT")
85
+ throw new CommandFailure("refused", "TARGET_NOT_FOUND", "the --daemon-home directory does not exist; create it with mode 0700 first");
86
+ throw asFailure(e);
87
+ }
88
+ }
57
89
  const REFUSAL = {
58
90
  UNSUPPORTED_PLATFORM: { code: "UNSUPPORTED" },
59
91
  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)" },
@@ -67,6 +99,8 @@ const REFUSAL = {
67
99
  SOCKET_WRONG_OWNER: { code: "PERMISSION_DENIED", remedy: "inspect the path; nothing was removed" },
68
100
  SOCKET_WRONG_MODE: { code: "PERMISSION_DENIED", remedy: "inspect the socket; nothing was sent" },
69
101
  PEER_UID_MISMATCH: { code: "PERMISSION_DENIED", remedy: "another user's process holds the daemon endpoint; nothing was sent" },
102
+ PEER_NOT_LOCK_OWNER: { code: "PERMISSION_DENIED", remedy: "the process at this daemon home's socket is not the daemon holding its lock; nothing was sent" },
103
+ LOCK_HELD: { code: "CONFLICT", remedy: "a daemon already serves this daemon home; run cotal daemon status with the same --daemon-home" },
70
104
  };
71
105
  function asFailure(e) {
72
106
  if (!(e instanceof EndpointRefusal))
@@ -79,9 +113,10 @@ function statusView(s) {
79
113
  }
80
114
  async function status(args) {
81
115
  const presentation = presentationOf(args.values);
116
+ const { paths } = resolveTarget(args);
82
117
  let reply;
83
118
  try {
84
- reply = await queryDaemon({ paths: daemonPaths(), peerUid });
119
+ reply = await queryDaemon({ paths, peerUid, peerPid });
85
120
  }
86
121
  catch (e) {
87
122
  throw asFailure(e);
@@ -107,13 +142,14 @@ async function login(args) {
107
142
  }
108
143
  if (values["client-id"] !== undefined && !values["client-id"].trim())
109
144
  throw new CommandFailure("refused", "USAGE_INVALID", "--client-id cannot be empty");
145
+ const { paths } = resolveTarget(args);
110
146
  const abort = new AbortController();
111
147
  const onSignal = () => abort.abort();
112
148
  process.once("SIGINT", onSignal);
113
149
  let reply;
114
150
  try {
115
151
  reply = await loginViaDaemon({
116
- paths: daemonPaths(), peerUid, idp, signal: abort.signal,
152
+ paths, peerUid, peerPid, idp, signal: abort.signal,
117
153
  ...(values["client-id"] !== undefined ? { clientId: values["client-id"] } : {}),
118
154
  onPrompt: (p) => {
119
155
  process.stderr.write(`${renderReport({ sections: [{ title: "Approve sign-in", blocks: [
@@ -161,9 +197,10 @@ async function connectCheckCommand(args) {
161
197
  const space = args.values.space;
162
198
  if (typeof space !== "string" || !space)
163
199
  throw new CommandFailure("refused", "USAGE_INVALID", "cotal daemon connect-check needs --space <s>");
200
+ const { paths } = resolveTarget(args);
164
201
  let reply;
165
202
  try {
166
- reply = await connectCheckViaDaemon({ paths: daemonPaths(), peerUid, space });
203
+ reply = await connectCheckViaDaemon({ paths, peerUid, peerPid, space });
167
204
  }
168
205
  catch (e) {
169
206
  throw asFailure(e);
@@ -198,9 +235,10 @@ async function registerCommand(args) {
198
235
  const space = args.values.space;
199
236
  if (typeof space !== "string" || !space)
200
237
  throw new CommandFailure("refused", "USAGE_INVALID", "cotal daemon register needs --space <s>");
238
+ const { paths } = resolveTarget(args);
201
239
  let reply;
202
240
  try {
203
- reply = await registerViaDaemon({ paths: daemonPaths(), peerUid, space });
241
+ reply = await registerViaDaemon({ paths, peerUid, peerPid, space });
204
242
  }
205
243
  catch (e) {
206
244
  throw asFailure(e);
@@ -239,9 +277,10 @@ async function serviceCommand(args, op) {
239
277
  const space = args.values.space;
240
278
  if (typeof space !== "string" || !space)
241
279
  throw new CommandFailure("refused", "USAGE_INVALID", `cotal daemon ${op} needs --space <s>`);
280
+ const { paths } = resolveTarget(args);
242
281
  let reply;
243
282
  try {
244
- reply = await serviceViaDaemon({ paths: daemonPaths(), peerUid, space, op });
283
+ reply = await serviceViaDaemon({ paths, peerUid, peerPid, space, op });
245
284
  }
246
285
  catch (e) {
247
286
  throw asFailure(e);
@@ -269,10 +308,11 @@ async function serviceCommand(args, op) {
269
308
  ] },
270
309
  ] }));
271
310
  }
272
- async function serve() {
311
+ async function serve(args) {
312
+ const { paths, home } = resolveTarget(args);
273
313
  let running;
274
314
  try {
275
- running = await serveDaemon({ paths: daemonPaths(), version: binVersion(), peerUid, log: (l) => process.stderr.write(`${l}\n`) });
315
+ running = await serveDaemon({ paths, version: binVersion(), peerUid, log: (l) => process.stderr.write(`${l}\n`), ...(home !== undefined ? { sessionDir: home } : {}) });
276
316
  }
277
317
  catch (e) {
278
318
  throw asFailure(e);
@@ -291,7 +331,7 @@ async function run(args) {
291
331
  if (mode === "status")
292
332
  return status(args);
293
333
  if (mode === "serve")
294
- return serve();
334
+ return serve(args);
295
335
  if (mode === "login")
296
336
  return login(args);
297
337
  if (mode === "connect-check")
@@ -310,14 +350,14 @@ const daemonCommand = {
310
350
  positionals: "<status|serve|login|connect-check|register|service-connect|service-disconnect|service-status>",
311
351
  flags: daemonFlags,
312
352
  subcommands: [
313
- { name: "status", summary: "ask the running daemon for its status over the local endpoint", flagNames: ["json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
314
- { 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 },
315
- { name: "login", summary: "sign the running daemon in to a provider with device approval; the daemon keeps the session", flagNames: ["idp", "client-id", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
316
- { name: "connect-check", summary: "ask the running daemon whether it could register with a space as its own service; registers nothing", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
317
- { name: "register", summary: "ask the running daemon to request its own service registration with a space; the space owner must approve it, and it does not connect", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
318
- { name: "service-connect", summary: "connect the running daemon to a space as its own approved service; succeeds only when the broker accepts the connection and the space accepts it as ready", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
319
- { name: "service-disconnect", summary: "close the running daemon's service connection to a space", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
320
- { name: "service-status", summary: "read the running daemon's service connection to a space, without contacting it", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
353
+ { name: "status", summary: "ask the running daemon for its status over the local endpoint", flagNames: ["daemon-home", "json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
354
+ { name: "serve", summary: "run the daemon in the foreground and serve its local endpoint (refusals honor --json)", flagNames: ["daemon-home", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
355
+ { name: "login", summary: "sign the running daemon in to a provider with device approval; the daemon keeps the session", flagNames: ["idp", "client-id", "daemon-home", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
356
+ { name: "connect-check", summary: "ask the running daemon whether it could register with a space as its own service; registers nothing", flagNames: ["space", "daemon-home", "json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
357
+ { name: "register", summary: "ask the running daemon to request its own service registration with a space; the space owner must approve it, and it does not connect", flagNames: ["space", "daemon-home", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
358
+ { name: "service-connect", summary: "connect the running daemon to a space as its own approved service; succeeds only when the broker accepts the connection and the space accepts it as ready", flagNames: ["space", "daemon-home", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
359
+ { name: "service-disconnect", summary: "close the running daemon's service connection to a space", flagNames: ["space", "daemon-home", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
360
+ { name: "service-status", summary: "read the running daemon's service connection to a space, without contacting it", flagNames: ["space", "daemon-home", "json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
321
361
  ],
322
362
  prepareMeshTarget: false,
323
363
  examples: ["cotal daemon serve", "cotal daemon status", "cotal daemon status --json", "cotal daemon login --idp https://example.org/api/auth", "cotal daemon connect-check --space acme", "cotal daemon register --space acme", "cotal daemon service-connect --space acme", "cotal daemon service-status --space acme"],
@@ -11,6 +11,7 @@
11
11
  * this uid, or not exactly 0700 is refused, never chmod-ed. Any pre-existing entry at the socket
12
12
  * path is refused, never removed, so this code never unlinks a socket it did not create.
13
13
  */
14
+ import { createHash } from "node:crypto";
14
15
  import { lstatSync, mkdirSync } from "node:fs";
15
16
  import { join } from "node:path";
16
17
  /** Wire protocol version. A request naming any other version is refused before any effect. */
@@ -35,6 +36,17 @@ export function daemonPaths(env = process.env) {
35
36
  const dir = join(runtime, "cotal");
36
37
  return { dir, socket: join(dir, "daemon.sock") };
37
38
  }
39
+ /**
40
+ * The endpoint of the daemon whose home is `daemonHome` (an absolute, canonical path):
41
+ * `$XDG_RUNTIME_DIR/cotal/home-<16 hex of sha256(daemonHome)>/daemon.sock`, with its lifetime lock
42
+ * at `<daemonHome>/daemon.lock`. Derived, so client and server agree with no extra record, and
43
+ * disjoint from the legacy socket.
44
+ */
45
+ export function daemonPathsFor(daemonHome, env = process.env) {
46
+ const base = daemonPaths(env);
47
+ const dir = join(base.dir, `home-${createHash("sha256").update(daemonHome).digest("hex").slice(0, 16)}`);
48
+ return { parent: base.dir, dir, socket: join(dir, "daemon.sock"), lock: join(daemonHome, "daemon.lock") };
49
+ }
38
50
  export function assertLinux(platform = process.platform) {
39
51
  if (platform !== "linux") {
40
52
  throw new EndpointRefusal("UNSUPPORTED_PLATFORM", `the local daemon endpoint is Linux only (this is ${platform})`);
@@ -19,6 +19,7 @@ import { connectCheck } from "./connect.js";
19
19
  import { registerDaemon } from "./register.js";
20
20
  import { createServiceConnections } from "./service.js";
21
21
  import { homeCotalDir } from "@cotal-ai/workspace";
22
+ import { acquireLock } from "@cotal-ai/core";
22
23
  /** Requests one connection may send. A sign-in needs two (start, cancel); status polling a few. */
23
24
  export const MAX_REQUESTS_PER_CONNECTION = 64;
24
25
  function listen(server, path) {
@@ -39,8 +40,33 @@ function listen(server, path) {
39
40
  export async function serveDaemon(opts) {
40
41
  assertLinux();
41
42
  const uid = opts.uid ?? process.geteuid();
43
+ const { paths } = opts;
44
+ // An explicit daemon home holds its lifetime lock before touching any endpoint path, so a second
45
+ // serve for the same home refuses here and never reaches the socket. The stock lock reclaims a dead
46
+ // owner's record; a stale socket is still refused below and never removed.
47
+ let lock;
48
+ if (paths.lock !== undefined) {
49
+ const path = paths.lock;
50
+ lock = acquireLock(path, {
51
+ waitMs: 0,
52
+ label: "cotal daemon",
53
+ onTimeout: (owner) => new EndpointRefusal("LOCK_HELD", `${path} is held by a live process (pid ${owner.pid}): a daemon already serves this daemon home. Nothing was changed`),
54
+ });
55
+ }
56
+ try {
57
+ return await serveLocked(opts, uid, lock);
58
+ }
59
+ catch (e) {
60
+ // Every failed start releases the lock it took. A started daemon releases it only in close().
61
+ lock?.release();
62
+ throw e;
63
+ }
64
+ }
65
+ async function serveLocked(opts, uid, lock) {
42
66
  const log = opts.log ?? (() => { });
43
67
  const { paths } = opts;
68
+ if (paths.parent !== undefined)
69
+ ensureDir(paths.parent, uid);
44
70
  const dirOutcome = ensureDir(paths.dir, uid);
45
71
  refuseExisting(paths.socket);
46
72
  const counters = { accepted: 0, peerRejected: 0, framesDecoded: 0, requestsRefused: 0, statusAnswered: 0, loginsStarted: 0, loginsRefused: 0, cancelsAnswered: 0, connectChecksAnswered: 0, registersAnswered: 0, serviceAnswered: 0 };
@@ -259,6 +285,10 @@ export async function serveDaemon(opts) {
259
285
  if (e.code !== "ENOENT")
260
286
  throw e;
261
287
  }
288
+ // The lock is released only once this daemon's own endpoint is gone. If its socket is still
289
+ // published (the removal above threw), the lock stays held until this process exits, so no
290
+ // other serve can start beside a live endpoint.
291
+ lock?.release();
262
292
  })();
263
293
  return closed;
264
294
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cotal-ai",
3
- "version": "0.97.1",
3
+ "version": "0.99.0",
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,26 +33,26 @@
33
33
  "node": ">=22"
34
34
  },
35
35
  "dependencies": {
36
- "@cotal-ai/auth": "0.97.1",
37
- "@cotal-ai/cli": "0.97.1",
38
- "@cotal-ai/connector-core": "0.97.1",
39
- "@cotal-ai/core": "0.97.1",
40
- "@cotal-ai/delivery": "0.97.1",
41
- "@cotal-ai/manager": "0.97.1",
42
- "@cotal-ai/runtime": "0.97.1",
43
- "@cotal-ai/seat": "0.97.1",
44
- "@cotal-ai/workspace": "0.97.1"
36
+ "@cotal-ai/auth": "0.99.0",
37
+ "@cotal-ai/cli": "0.99.0",
38
+ "@cotal-ai/connector-core": "0.99.0",
39
+ "@cotal-ai/core": "0.99.0",
40
+ "@cotal-ai/delivery": "0.99.0",
41
+ "@cotal-ai/manager": "0.99.0",
42
+ "@cotal-ai/runtime": "0.99.0",
43
+ "@cotal-ai/seat": "0.99.0",
44
+ "@cotal-ai/workspace": "0.99.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@cotal-ai/connector-claude-code": "0.97.1",
48
- "@cotal-ai/connector-codex": "0.97.1",
49
- "@cotal-ai/connector-hermes": "0.97.1",
50
- "@cotal-ai/connector-jcode": "0.97.1",
51
- "@cotal-ai/connector-opencode": "0.97.1",
52
- "@cotal-ai/linear": "0.97.1",
53
- "@cotal-ai/pi": "0.97.1",
47
+ "@cotal-ai/connector-claude-code": "0.99.0",
48
+ "@cotal-ai/connector-codex": "0.99.0",
49
+ "@cotal-ai/connector-hermes": "0.99.0",
50
+ "@cotal-ai/connector-jcode": "0.99.0",
51
+ "@cotal-ai/connector-opencode": "0.99.0",
52
+ "@cotal-ai/linear": "0.99.0",
53
+ "@cotal-ai/pi": "0.99.0",
54
54
  "@cotal-ai/smoke-kit": "0.0.0",
55
- "@cotal-ai/web": "0.97.1",
55
+ "@cotal-ai/web": "0.99.0",
56
56
  "@nats-io/jetstream": "^3.4.0",
57
57
  "@nats-io/transport-node": "^3.4.0",
58
58
  "typescript": "^5.9.3"