cotal-ai 0.94.4 → 0.94.5
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/dist/daemon/client.js +76 -17
- package/dist/daemon/command.js +89 -13
- package/dist/daemon/login.js +111 -0
- package/dist/daemon/protocol.js +46 -8
- package/dist/daemon/server.js +74 -9
- package/package.json +18 -18
- package/seeded-connectors/claude/dist/mcp.cjs +4 -4
- package/seeded-connectors/claude/package.json +1 -1
- package/seeded-connectors/codex/dist/host.js +4 -4
- package/seeded-connectors/codex/package.json +1 -1
- package/seeded-connectors/hermes/dist/launch.js +4 -4
- package/seeded-connectors/hermes/package.json +1 -1
- package/seeded-connectors/hermes/plugin/cotal/_sidecar/standalone.cjs +4 -4
- package/seeded-connectors/jcode/dist/host.js +4 -4
- package/seeded-connectors/jcode/dist/mcp.js +4 -4
- package/seeded-connectors/jcode/package.json +1 -1
- package/seeded-connectors/opencode/dist/plugin/index.js +4 -4
- package/seeded-connectors/opencode/package.json +1 -1
- package/seeded-connectors/pi/dist/index.js +4 -4
- package/seeded-connectors/pi/dist/standalone.js +4 -4
- package/seeded-connectors/pi/package.json +1 -1
- package/seeded-connectors/web/package.json +1 -1
package/dist/daemon/client.js
CHANGED
|
@@ -59,10 +59,23 @@ function readLine(sock, timeoutMs) {
|
|
|
59
59
|
}
|
|
60
60
|
/** Send one request and return the daemon's parsed reply. */
|
|
61
61
|
export async function queryDaemon(opts) {
|
|
62
|
+
const sock = await openVerified(opts);
|
|
63
|
+
try {
|
|
64
|
+
const line = opts.rawLine !== undefined ? `${opts.rawLine}\n` : encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: "status" });
|
|
65
|
+
(opts.counters ?? { bytesWritten: 0 }).bytesWritten += Buffer.byteLength(line, "utf8");
|
|
66
|
+
sock.write(line);
|
|
67
|
+
const reply = await readLine(sock, opts.timeoutMs ?? 5000);
|
|
68
|
+
return JSON.parse(reply);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
sock.destroy();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Connect only after the directory and socket checks pass, and keep the connection only when the
|
|
75
|
+
* kernel reports the listener as this same uid. Nothing is written before both. */
|
|
76
|
+
async function openVerified(opts) {
|
|
62
77
|
assertLinux();
|
|
63
78
|
const uid = opts.uid ?? process.geteuid();
|
|
64
|
-
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
65
|
-
const counters = opts.counters ?? { bytesWritten: 0 };
|
|
66
79
|
const { paths } = opts;
|
|
67
80
|
try {
|
|
68
81
|
checkDir(paths.dir, uid);
|
|
@@ -73,22 +86,68 @@ export async function queryDaemon(opts) {
|
|
|
73
86
|
throw new EndpointRefusal("NOT_RUNNING", `no daemon endpoint at ${paths.socket}`);
|
|
74
87
|
throw e;
|
|
75
88
|
}
|
|
76
|
-
const sock = await connectTo(paths.socket, timeoutMs);
|
|
89
|
+
const sock = await connectTo(paths.socket, opts.timeoutMs ?? 5000);
|
|
90
|
+
let peer;
|
|
77
91
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
peer = opts.peerUid(sock);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
sock.destroy();
|
|
96
|
+
throw new EndpointRefusal("PEER_UID_MISMATCH", `could not read the daemon's peer credentials: ${e.message}`);
|
|
97
|
+
}
|
|
98
|
+
if (peer !== uid) {
|
|
99
|
+
sock.destroy();
|
|
100
|
+
throw new EndpointRefusal("PEER_UID_MISMATCH", `the process answering at ${paths.socket} runs as uid ${peer}, not ${uid}; nothing was sent`);
|
|
101
|
+
}
|
|
102
|
+
return sock;
|
|
103
|
+
}
|
|
104
|
+
/** Ask the daemon to sign in, show its prompt, and return its single result. */
|
|
105
|
+
export async function loginViaDaemon(opts) {
|
|
106
|
+
const sock = await openVerified(opts);
|
|
107
|
+
try {
|
|
108
|
+
const id = randomUUID().replaceAll("-", "");
|
|
109
|
+
return await new Promise((resolve, reject) => {
|
|
110
|
+
let buf = "";
|
|
111
|
+
let attempt;
|
|
112
|
+
const cleanup = () => { sock.removeAllListeners("data"); sock.removeAllListeners("end"); sock.removeAllListeners("error"); opts.signal?.removeEventListener("abort", onAbort); };
|
|
113
|
+
const finish = (r) => { cleanup(); if (r instanceof Error)
|
|
114
|
+
reject(r);
|
|
115
|
+
else
|
|
116
|
+
resolve(r); };
|
|
117
|
+
const abandoned = { ok: false, code: "ABANDONED", message: "stopped before the daemon showed a prompt; the connection was closed, which cancels the sign-in on the daemon" };
|
|
118
|
+
const onAbort = () => {
|
|
119
|
+
if (!attempt)
|
|
120
|
+
return finish(abandoned);
|
|
121
|
+
sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: `${id}c`.slice(0, 64), op: "login-cancel", attempt }));
|
|
122
|
+
};
|
|
123
|
+
sock.setEncoding("utf8");
|
|
124
|
+
sock.on("data", (chunk) => {
|
|
125
|
+
buf += chunk;
|
|
126
|
+
if (Buffer.byteLength(buf, "utf8") > MAX_LINE_BYTES && !buf.includes("\n"))
|
|
127
|
+
return finish(new Error("daemon reply exceeds the line limit"));
|
|
128
|
+
let nl;
|
|
129
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
130
|
+
const msg = JSON.parse(buf.slice(0, nl));
|
|
131
|
+
buf = buf.slice(nl + 1);
|
|
132
|
+
if (msg.id !== id)
|
|
133
|
+
continue;
|
|
134
|
+
if (!msg.ok)
|
|
135
|
+
return finish({ ok: false, code: msg.error.code, message: msg.error.message });
|
|
136
|
+
if ("event" in msg && msg.event === "login-prompt") {
|
|
137
|
+
attempt = msg.attempt;
|
|
138
|
+
opts.onPrompt(msg.prompt);
|
|
139
|
+
}
|
|
140
|
+
else if ("event" in msg && msg.event === "login-result")
|
|
141
|
+
return finish({ ok: true, attempt: msg.attempt, result: msg.result });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
sock.once("end", () => finish(new Error("the daemon closed the connection before the sign-in finished")));
|
|
145
|
+
sock.once("error", (e) => finish(e));
|
|
146
|
+
if (opts.signal?.aborted)
|
|
147
|
+
return finish(abandoned);
|
|
148
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
149
|
+
sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id, op: "login-start", idp: opts.idp, ...(opts.clientId !== undefined ? { clientId: opts.clientId } : {}) }));
|
|
150
|
+
});
|
|
92
151
|
}
|
|
93
152
|
finally {
|
|
94
153
|
sock.destroy();
|
package/dist/daemon/command.js
CHANGED
|
@@ -3,22 +3,31 @@
|
|
|
3
3
|
* in an `implementations/*` package so it can later wire `@cotal-ai/auth` and `@cotal-ai/manager`
|
|
4
4
|
* without one implementation importing another (AGENTS.md, repository map).
|
|
5
5
|
*
|
|
6
|
-
* This slice serves a local, authenticated status
|
|
7
|
-
* Manager, no service install. Linux only.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* This slice serves a local, authenticated endpoint for status and sign-in, and nothing else: no
|
|
7
|
+
* Manager, no mesh or Cloud connection, no service install. Linux only. Sign-in runs inside the
|
|
8
|
+
* daemon through stock `establishIdpSession`, so the session lands in the machine-home Cotal
|
|
9
|
+
* directory where `cotal login` keeps it, and no token ever crosses the local endpoint. It is a
|
|
10
|
+
* foreground foundation, not a resilient machine service: a daemon killed without cleanup leaves
|
|
11
|
+
* its socket behind, and the next start refuses until the operator removes it (see the
|
|
12
|
+
* ALREADY_RUNNING remedy). `state: "ready"` means the local control listener is bound and
|
|
13
|
+
* verified; it says nothing about any mesh or Cloud connection.
|
|
11
14
|
*/
|
|
12
15
|
import { readFileSync } from "node:fs";
|
|
13
16
|
import { dirname, join } from "node:path";
|
|
14
17
|
import { fileURLToPath } from "node:url";
|
|
15
18
|
import { registry } from "@cotal-ai/core";
|
|
16
|
-
import { CommandFailure, jsonFlag, plainFlag, presentationOf, writeResult } from "@cotal-ai/workspace";
|
|
19
|
+
import { CommandFailure, jsonFlag, plainFlag, presentationOf, renderReport, terminalWidth, writeResult } from "@cotal-ai/workspace";
|
|
17
20
|
import { peerCredentials } from "@cotal-ai/seat";
|
|
21
|
+
import { normalizeIdpUrl } from "@cotal-ai/auth";
|
|
18
22
|
import { EndpointRefusal, daemonPaths } from "./endpoint.js";
|
|
19
23
|
import { serveDaemon } from "./server.js";
|
|
20
|
-
import { queryDaemon } from "./client.js";
|
|
21
|
-
export const daemonFlags = [
|
|
24
|
+
import { loginViaDaemon, queryDaemon } from "./client.js";
|
|
25
|
+
export const daemonFlags = [
|
|
26
|
+
jsonFlag,
|
|
27
|
+
plainFlag,
|
|
28
|
+
{ name: "idp", type: "string", value: "<auth base URL>", description: "login: the provider's auth base URL, e.g. <origin>/api/auth" },
|
|
29
|
+
{ name: "client-id", type: "string", value: "<id>", description: "login: the OAuth device-flow client id" },
|
|
30
|
+
];
|
|
22
31
|
/** This binary's own version: the nearest package.json above this module (bin/ in a source run). */
|
|
23
32
|
export function binVersion() {
|
|
24
33
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -73,6 +82,70 @@ async function status(args) {
|
|
|
73
82
|
throw new CommandFailure("failed", "OPERATION_FAILED", `the daemon refused the status request: ${reply.error.code} ${reply.error.message}`);
|
|
74
83
|
writeResult(presentation, { command: "daemon status", target: null, result: reply.result }, statusView);
|
|
75
84
|
}
|
|
85
|
+
/** Sign in through the running daemon. The daemon talks to the IdP and keeps the session; this
|
|
86
|
+
* process only shows the approval prompt and the result, and never sees a token. Ctrl-C asks the
|
|
87
|
+
* daemon to cancel the attempt and waits for its answer. */
|
|
88
|
+
async function login(args) {
|
|
89
|
+
const presentation = presentationOf(args.values);
|
|
90
|
+
const values = args.values;
|
|
91
|
+
if (typeof values.idp !== "string")
|
|
92
|
+
throw new CommandFailure("refused", "USAGE_INVALID", "cotal daemon login needs --idp <auth base URL>");
|
|
93
|
+
let idp;
|
|
94
|
+
try {
|
|
95
|
+
idp = normalizeIdpUrl(values.idp);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new CommandFailure("refused", "USAGE_INVALID", "use an HTTPS provider auth URL, or an HTTP loopback IP URL, without embedded credentials, a query or a fragment");
|
|
99
|
+
}
|
|
100
|
+
if (values["client-id"] !== undefined && !values["client-id"].trim())
|
|
101
|
+
throw new CommandFailure("refused", "USAGE_INVALID", "--client-id cannot be empty");
|
|
102
|
+
const abort = new AbortController();
|
|
103
|
+
const onSignal = () => abort.abort();
|
|
104
|
+
process.once("SIGINT", onSignal);
|
|
105
|
+
let reply;
|
|
106
|
+
try {
|
|
107
|
+
reply = await loginViaDaemon({
|
|
108
|
+
paths: daemonPaths(), peerUid, idp, signal: abort.signal,
|
|
109
|
+
...(values["client-id"] !== undefined ? { clientId: values["client-id"] } : {}),
|
|
110
|
+
onPrompt: (p) => {
|
|
111
|
+
process.stderr.write(`${renderReport({ sections: [{ title: "Approve sign-in", blocks: [
|
|
112
|
+
{ kind: "fields", rows: [["Provider", idp], ["Open", p.verificationUriComplete], ["Or open", p.verificationUri], ["Approval code", p.userCode], ["Expires", p.expiresAt]] },
|
|
113
|
+
{ kind: "lines", lines: [{ text: "Waiting for the provider's approval. The daemon keeps the session. Ctrl-C cancels this sign-in on this machine. Do not share the approval link or code.", tone: "hint" }] },
|
|
114
|
+
] }] }, presentation === "plain" ? "plain" : "rich", terminalWidth(process.stderr))}\n`);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
throw asFailure(e);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
process.off("SIGINT", onSignal);
|
|
123
|
+
}
|
|
124
|
+
if (!reply.ok) {
|
|
125
|
+
if (reply.code === "LOGIN_IN_PROGRESS")
|
|
126
|
+
throw new CommandFailure("refused", "CONFLICT", reply.message, { native: { code: reply.code } });
|
|
127
|
+
if (reply.code === "ABANDONED")
|
|
128
|
+
throw new CommandFailure("refused", "USAGE_INVALID", reply.message, { native: { code: reply.code } });
|
|
129
|
+
throw new CommandFailure("failed", "OPERATION_FAILED", `the daemon refused the sign-in: ${reply.code} ${reply.message}`, { native: { code: reply.code } });
|
|
130
|
+
}
|
|
131
|
+
const r = reply.result;
|
|
132
|
+
if (r.outcome === "cancelled")
|
|
133
|
+
throw new CommandFailure("refused", "USAGE_INVALID", "the sign-in was cancelled on this machine; nothing was saved and nothing was revoked at the provider", { native: { code: "CANCELLED" } });
|
|
134
|
+
if (r.outcome === "failed") {
|
|
135
|
+
const native = { native: { code: r.reason.toUpperCase() } };
|
|
136
|
+
// Denied, expired and unreachable end before stock writes anything: refusals at the provider's
|
|
137
|
+
// boundary. Any other failure can come after stock saved the session, so it claims nothing about
|
|
138
|
+
// what was saved; `cotal daemon login` again replaces whatever is there.
|
|
139
|
+
if (r.reason === "unreachable")
|
|
140
|
+
throw new CommandFailure("refused", "DEPENDENCY_UNAVAILABLE", `${r.message}; no session was saved`, native);
|
|
141
|
+
if (r.reason === "denied" || r.reason === "expired")
|
|
142
|
+
throw new CommandFailure("refused", "PERMISSION_DENIED", `${r.message}; no session was saved`, native);
|
|
143
|
+
throw new CommandFailure("failed", "OPERATION_FAILED", `${r.message}; a session may have been saved before the failure, so sign in again`, native);
|
|
144
|
+
}
|
|
145
|
+
writeResult(presentation, { command: "daemon login", target: { idp }, result: { attempt: reply.attempt, ...r } }, () => ({ sections: [
|
|
146
|
+
{ title: "Signed in", blocks: [{ kind: "fields", rows: [["Account", r.label || r.sub], ["Subject", r.sub], ["Provider", r.idp]] }, { kind: "lines", lines: [{ text: "The daemon holds this session in the machine's Cotal home. No session token is printed. Each space's grants decide access separately.", tone: "hint" }] }] },
|
|
147
|
+
] }));
|
|
148
|
+
}
|
|
76
149
|
async function serve() {
|
|
77
150
|
let running;
|
|
78
151
|
try {
|
|
@@ -96,21 +169,24 @@ async function run(args) {
|
|
|
96
169
|
return status(args);
|
|
97
170
|
if (mode === "serve")
|
|
98
171
|
return serve();
|
|
99
|
-
|
|
172
|
+
if (mode === "login")
|
|
173
|
+
return login(args);
|
|
174
|
+
throw new CommandFailure("refused", "USAGE_INVALID", mode === undefined ? "cotal daemon needs a mode: status, serve or login" : `unknown daemon mode ${JSON.stringify(mode)}`, { remedy: "cotal daemon status" });
|
|
100
175
|
}
|
|
101
176
|
const daemonCommand = {
|
|
102
177
|
kind: "command",
|
|
103
178
|
name: "daemon",
|
|
104
179
|
group: "Manager",
|
|
105
|
-
summary: "the local machine daemon (Linux): serve its control endpoint, read its status",
|
|
106
|
-
positionals: "<status|serve>",
|
|
180
|
+
summary: "the local machine daemon (Linux): serve its control endpoint, read its status, sign it in",
|
|
181
|
+
positionals: "<status|serve|login>",
|
|
107
182
|
flags: daemonFlags,
|
|
108
183
|
subcommands: [
|
|
109
|
-
{ name: "status", summary: "ask the running daemon for its status over the local endpoint", outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
|
|
184
|
+
{ name: "status", summary: "ask the running daemon for its status over the local endpoint", flagNames: ["json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
|
|
110
185
|
{ 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 },
|
|
186
|
+
{ 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 },
|
|
111
187
|
],
|
|
112
188
|
prepareMeshTarget: false,
|
|
113
|
-
examples: ["cotal daemon serve", "cotal daemon status", "cotal daemon status --json"],
|
|
189
|
+
examples: ["cotal daemon serve", "cotal daemon status", "cotal daemon status --json", "cotal daemon login --idp https://example.org/api/auth"],
|
|
114
190
|
run,
|
|
115
191
|
};
|
|
116
192
|
registry.register(daemonCommand);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's local sign-in: one attempt at a time, driven through stock `establishIdpSession`,
|
|
3
|
+
* which persists the session through the stock writer and fences persistence on its AbortSignal.
|
|
4
|
+
*
|
|
5
|
+
* Attempts are named by a daemon-minted id. A cancel names the attempt it means, so a cancel aimed
|
|
6
|
+
* at a finished attempt can never stop its successor. A cancel is answered only after the attempt
|
|
7
|
+
* has settled, meaning its result has been delivered to the requester, and it answers with what
|
|
8
|
+
* actually happened: `cancelled` when the stock fence stopped it before anything was persisted,
|
|
9
|
+
* `completed` when it had already finished (or finished while the cancel raced it), and `not-found`
|
|
10
|
+
* for an attempt this daemon never started or no longer remembers. Until that delivery the attempt
|
|
11
|
+
* stays in flight, so no second sign-in starts underneath an undelivered result.
|
|
12
|
+
*
|
|
13
|
+
* A failure crosses the endpoint as a fixed sentence per reason. Stock error text can quote the
|
|
14
|
+
* provider's answer, so none of it is relayed.
|
|
15
|
+
*
|
|
16
|
+
* Cancelling stops this local sign-in. It does not revoke anything at the IdP.
|
|
17
|
+
*/
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { establishIdpSession, IdpLoginCancelled, IdpUnreachableError } from "@cotal-ai/auth";
|
|
20
|
+
/** The client id stock `cotal login` presents by default. */
|
|
21
|
+
export const DEFAULT_CLIENT_ID = "cotal-cli";
|
|
22
|
+
/** How many finished attempt ids are remembered, so a late cancel still gets a truthful answer. */
|
|
23
|
+
const REMEMBERED = 16;
|
|
24
|
+
/** The only failure text a requester sees. It never quotes the provider or a stock error. */
|
|
25
|
+
const FAILURE = {
|
|
26
|
+
denied: "the sign-in was denied at the verification page",
|
|
27
|
+
expired: "the approval code expired before the sign-in was approved",
|
|
28
|
+
unreachable: "the provider could not be reached",
|
|
29
|
+
refused: "the provider's answer could not be used to sign in",
|
|
30
|
+
};
|
|
31
|
+
const failed = (reason) => ({ outcome: "failed", reason, message: FAILURE[reason] });
|
|
32
|
+
export function createLoginCoordinator(opts) {
|
|
33
|
+
const establish = opts.establish ?? establishIdpSession;
|
|
34
|
+
let current;
|
|
35
|
+
const finished = new Map();
|
|
36
|
+
const remember = (id, outcome) => {
|
|
37
|
+
finished.set(id, outcome);
|
|
38
|
+
while (finished.size > REMEMBERED)
|
|
39
|
+
finished.delete(finished.keys().next().value);
|
|
40
|
+
};
|
|
41
|
+
const run = async (id, abort, req) => {
|
|
42
|
+
try {
|
|
43
|
+
const r = await establish({
|
|
44
|
+
dir: opts.sessionDir,
|
|
45
|
+
idpUrl: req.idp,
|
|
46
|
+
clientId: req.clientId ?? DEFAULT_CLIENT_ID,
|
|
47
|
+
signal: abort.signal,
|
|
48
|
+
onPrompt: (p) => req.onPrompt(id, {
|
|
49
|
+
verificationUri: p.verificationUri,
|
|
50
|
+
verificationUriComplete: p.verificationUriComplete,
|
|
51
|
+
userCode: p.userCode,
|
|
52
|
+
expiresAt: new Date(Date.now() + p.expiresInSec * 1000).toISOString(),
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
return { outcome: "signed-in", idp: req.idp, sub: r.sub, ...(r.label ? { label: r.label } : {}), ...(r.previousSub ? { previousSub: r.previousSub } : {}) };
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
if (e instanceof IdpLoginCancelled)
|
|
59
|
+
return { outcome: "cancelled" };
|
|
60
|
+
if (e instanceof IdpUnreachableError)
|
|
61
|
+
return failed("unreachable");
|
|
62
|
+
// Stock throws plain Errors for these two IdP answers; match their exact sentences.
|
|
63
|
+
const message = e instanceof Error ? e.message : "";
|
|
64
|
+
if (message.startsWith("idp login: the sign-in was denied at the verification page"))
|
|
65
|
+
return failed("denied");
|
|
66
|
+
if (message.startsWith("idp login: the device code expired"))
|
|
67
|
+
return failed("expired");
|
|
68
|
+
return failed("refused");
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
start(req) {
|
|
73
|
+
if (current)
|
|
74
|
+
return undefined;
|
|
75
|
+
const id = randomBytes(16).toString("base64url").slice(0, 22);
|
|
76
|
+
const abort = new AbortController();
|
|
77
|
+
const settled = run(id, abort, req).then(async (result) => {
|
|
78
|
+
try {
|
|
79
|
+
await req.onResult(id, result);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
remember(id, result.outcome);
|
|
83
|
+
if (current?.id === id)
|
|
84
|
+
current = undefined;
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
});
|
|
88
|
+
current = { id, abort, settled };
|
|
89
|
+
return { attempt: id, settled };
|
|
90
|
+
},
|
|
91
|
+
async cancel(attempt) {
|
|
92
|
+
if (current?.id === attempt) {
|
|
93
|
+
const live = current;
|
|
94
|
+
live.abort.abort();
|
|
95
|
+
const result = await live.settled;
|
|
96
|
+
return { outcome: result.outcome === "cancelled" ? "cancelled" : "completed", attempt };
|
|
97
|
+
}
|
|
98
|
+
const past = finished.get(attempt);
|
|
99
|
+
if (past === undefined)
|
|
100
|
+
return { outcome: "not-found", attempt };
|
|
101
|
+
return { outcome: past === "cancelled" ? "cancelled" : "completed", attempt };
|
|
102
|
+
},
|
|
103
|
+
async close() {
|
|
104
|
+
const live = current;
|
|
105
|
+
if (!live)
|
|
106
|
+
return;
|
|
107
|
+
live.abort.abort();
|
|
108
|
+
await live.settled;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/dist/daemon/protocol.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The closed, versioned wire contract of the local daemon endpoint: one JSON object per line.
|
|
3
3
|
*
|
|
4
|
-
* A request
|
|
5
|
-
*
|
|
6
|
-
* with a typed error. Responses echo the
|
|
4
|
+
* A request names `{ v, id, op }` plus exactly the fields its op declares. Any other key, any other
|
|
5
|
+
* version, an unknown op, a field of the wrong shape, a line that is not a JSON object, or a line
|
|
6
|
+
* over {@link MAX_LINE_BYTES} is refused before any effect, with a typed error. Responses echo the
|
|
7
|
+
* request id when one could be read.
|
|
8
|
+
*
|
|
9
|
+
* Sign-in streams: `login-start` answers with one `login-prompt` event, then exactly one
|
|
10
|
+
* `login-result` event, both tagged with the request id and the attempt id. No frame carries a
|
|
11
|
+
* session, token, JWT or device code; the verification URL and user code are the only IdP values
|
|
12
|
+
* the requester sees, because a person must type them.
|
|
7
13
|
*/
|
|
14
|
+
import { normalizeIdpUrl } from "@cotal-ai/auth";
|
|
8
15
|
import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES } from "./endpoint.js";
|
|
9
|
-
export const DAEMON_OPS = ["status"];
|
|
10
|
-
|
|
16
|
+
export const DAEMON_OPS = ["status", "login-start", "login-cancel"];
|
|
17
|
+
/** The fields each op takes besides `v`, `id` and `op`. Required ones must be present. */
|
|
18
|
+
const OP_FIELDS = {
|
|
19
|
+
status: { required: [], optional: [] },
|
|
20
|
+
"login-start": { required: ["idp"], optional: ["clientId"] },
|
|
21
|
+
"login-cancel": { required: ["attempt"], optional: [] },
|
|
22
|
+
};
|
|
23
|
+
const ENVELOPE_KEYS = new Set(["v", "id", "op"]);
|
|
11
24
|
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
25
|
+
/** An attempt id is daemon-minted: 22 characters of base64url. */
|
|
26
|
+
export const ATTEMPT_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
|
27
|
+
const MAX_CLIENT_ID = 128;
|
|
28
|
+
const bad = (id, message) => ({ ok: false, id, code: "BAD_FIELD", message });
|
|
12
29
|
/** Parse one line (without its newline). Pure: it decides, it does nothing. */
|
|
13
30
|
export function parseRequest(line) {
|
|
14
31
|
if (Buffer.byteLength(line, "utf8") + 1 > MAX_LINE_BYTES)
|
|
@@ -24,17 +41,38 @@ export function parseRequest(line) {
|
|
|
24
41
|
return { ok: false, id: null, code: "NOT_OBJECT", message: "request must be a JSON object" };
|
|
25
42
|
const obj = raw;
|
|
26
43
|
const id = typeof obj.id === "string" && ID_PATTERN.test(obj.id) ? obj.id : null;
|
|
44
|
+
const op = typeof obj.op === "string" && DAEMON_OPS.includes(obj.op) ? obj.op : undefined;
|
|
45
|
+
const fields = op ? OP_FIELDS[op] : { required: [], optional: [] };
|
|
27
46
|
for (const k of Object.keys(obj)) {
|
|
28
|
-
if (!
|
|
47
|
+
if (!ENVELOPE_KEYS.has(k) && !fields.required.includes(k) && !fields.optional.includes(k))
|
|
29
48
|
return { ok: false, id, code: "UNKNOWN_FIELD", message: `unknown request field ${JSON.stringify(k)}` };
|
|
30
49
|
}
|
|
31
50
|
if (obj.v !== DAEMON_PROTOCOL_VERSION)
|
|
32
51
|
return { ok: false, id, code: "BAD_VERSION", message: `unsupported protocol version ${JSON.stringify(obj.v)}; this daemon speaks ${DAEMON_PROTOCOL_VERSION}` };
|
|
33
52
|
if (id === null)
|
|
34
53
|
return { ok: false, id: null, code: "BAD_ID", message: "request id must be 1-64 characters of [A-Za-z0-9_-]" };
|
|
35
|
-
if (
|
|
54
|
+
if (!op)
|
|
36
55
|
return { ok: false, id, code: "UNKNOWN_OP", message: `unknown op ${JSON.stringify(obj.op)}` };
|
|
37
|
-
|
|
56
|
+
const v = DAEMON_PROTOCOL_VERSION;
|
|
57
|
+
if (op === "status")
|
|
58
|
+
return { ok: true, request: { v, id, op } };
|
|
59
|
+
if (op === "login-cancel") {
|
|
60
|
+
if (typeof obj.attempt !== "string" || !ATTEMPT_PATTERN.test(obj.attempt))
|
|
61
|
+
return bad(id, "attempt must be the 22-character attempt id from login-prompt");
|
|
62
|
+
return { ok: true, request: { v, id, op, attempt: obj.attempt } };
|
|
63
|
+
}
|
|
64
|
+
if (typeof obj.idp !== "string")
|
|
65
|
+
return bad(id, "idp must be the provider's auth base URL");
|
|
66
|
+
let idp;
|
|
67
|
+
try {
|
|
68
|
+
idp = normalizeIdpUrl(obj.idp);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return bad(id, "idp must be an HTTPS auth URL, or an HTTP loopback IP URL, without embedded credentials, a query or a fragment");
|
|
72
|
+
}
|
|
73
|
+
if (obj.clientId !== undefined && (typeof obj.clientId !== "string" || !obj.clientId.trim() || obj.clientId.length > MAX_CLIENT_ID))
|
|
74
|
+
return bad(id, `clientId must be a non-empty string of at most ${MAX_CLIENT_ID} characters`);
|
|
75
|
+
return { ok: true, request: { v, id, op, idp, ...(obj.clientId !== undefined ? { clientId: obj.clientId } : {}) } };
|
|
38
76
|
}
|
|
39
77
|
export function encode(msg) {
|
|
40
78
|
return `${JSON.stringify(msg)}\n`;
|
package/dist/daemon/server.js
CHANGED
|
@@ -14,6 +14,10 @@ import { join } from "node:path";
|
|
|
14
14
|
import { createServer } from "node:net";
|
|
15
15
|
import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES, EndpointRefusal, assertLinux, checkSocket, ensureDir, refuseExisting } from "./endpoint.js";
|
|
16
16
|
import { encode, parseRequest } from "./protocol.js";
|
|
17
|
+
import { createLoginCoordinator } from "./login.js";
|
|
18
|
+
import { homeCotalDir } from "@cotal-ai/workspace";
|
|
19
|
+
/** Requests one connection may send. A sign-in needs two (start, cancel); status polling a few. */
|
|
20
|
+
export const MAX_REQUESTS_PER_CONNECTION = 64;
|
|
17
21
|
function listen(server, path) {
|
|
18
22
|
return new Promise((resolve, reject) => {
|
|
19
23
|
const onError = (e) => {
|
|
@@ -36,7 +40,8 @@ export async function serveDaemon(opts) {
|
|
|
36
40
|
const { paths } = opts;
|
|
37
41
|
const dirOutcome = ensureDir(paths.dir, uid);
|
|
38
42
|
refuseExisting(paths.socket);
|
|
39
|
-
const counters = { accepted: 0, peerRejected: 0, framesDecoded: 0, requestsRefused: 0, statusAnswered: 0 };
|
|
43
|
+
const counters = { accepted: 0, peerRejected: 0, framesDecoded: 0, requestsRefused: 0, statusAnswered: 0, loginsStarted: 0, loginsRefused: 0, cancelsAnswered: 0 };
|
|
44
|
+
const login = opts.login ?? createLoginCoordinator({ sessionDir: opts.sessionDir ?? homeCotalDir() });
|
|
40
45
|
const startedAt = new Date();
|
|
41
46
|
const status = () => ({
|
|
42
47
|
state: "ready",
|
|
@@ -70,6 +75,64 @@ export async function serveDaemon(opts) {
|
|
|
70
75
|
counters.accepted++;
|
|
71
76
|
let buf = "";
|
|
72
77
|
let bufBytes = 0;
|
|
78
|
+
let requests = 0;
|
|
79
|
+
// Sign-in attempts this connection started that have not settled. Closing the connection
|
|
80
|
+
// before the result cancels them through the stock fence, so nothing is persisted for a
|
|
81
|
+
// requester that went away. Once the result is delivered the attempt is no longer here, so a
|
|
82
|
+
// close after it changes nothing.
|
|
83
|
+
const owned = new Set();
|
|
84
|
+
const write = (r) => new Promise((resolve) => {
|
|
85
|
+
if (sock.destroyed || !sock.writable)
|
|
86
|
+
return resolve();
|
|
87
|
+
sock.write(encode(r), () => resolve());
|
|
88
|
+
});
|
|
89
|
+
const refuse = (id, code, message) => {
|
|
90
|
+
counters.requestsRefused++;
|
|
91
|
+
return write({ v: DAEMON_PROTOCOL_VERSION, id, ok: false, error: { code, message } });
|
|
92
|
+
};
|
|
93
|
+
sock.once("close", () => {
|
|
94
|
+
for (const attempt of owned)
|
|
95
|
+
void login.cancel(attempt);
|
|
96
|
+
owned.clear();
|
|
97
|
+
});
|
|
98
|
+
const handle = (line) => {
|
|
99
|
+
counters.framesDecoded++;
|
|
100
|
+
const parsed = parseRequest(line);
|
|
101
|
+
if (!parsed.ok) {
|
|
102
|
+
counters.requestsRefused++;
|
|
103
|
+
void write({ v: DAEMON_PROTOCOL_VERSION, id: parsed.id, ok: false, error: { code: parsed.code, message: parsed.message } });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const req = parsed.request;
|
|
107
|
+
if (req.op === "status") {
|
|
108
|
+
counters.statusAnswered++;
|
|
109
|
+
void write({ v: DAEMON_PROTOCOL_VERSION, id: req.id, ok: true, result: status() });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (req.op === "login-cancel") {
|
|
113
|
+
void login.cancel(req.attempt).then((result) => {
|
|
114
|
+
counters.cancelsAnswered++;
|
|
115
|
+
return write({ v: DAEMON_PROTOCOL_VERSION, id: req.id, ok: true, result });
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const started = login.start({
|
|
120
|
+
idp: req.idp,
|
|
121
|
+
...(req.clientId !== undefined ? { clientId: req.clientId } : {}),
|
|
122
|
+
onPrompt: (attempt, prompt) => void write({ v: DAEMON_PROTOCOL_VERSION, id: req.id, ok: true, event: "login-prompt", attempt, prompt }),
|
|
123
|
+
onResult: async (attempt, result) => {
|
|
124
|
+
owned.delete(attempt);
|
|
125
|
+
await write({ v: DAEMON_PROTOCOL_VERSION, id: req.id, ok: true, event: "login-result", attempt, result });
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
if (!started) {
|
|
129
|
+
counters.loginsRefused++;
|
|
130
|
+
void refuse(req.id, "LOGIN_IN_PROGRESS", "a sign-in is already in progress on this daemon; cancel it or wait for its result");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
counters.loginsStarted++;
|
|
134
|
+
owned.add(started.attempt);
|
|
135
|
+
};
|
|
73
136
|
sock.setEncoding("utf8");
|
|
74
137
|
sock.on("data", (chunk) => {
|
|
75
138
|
buf += chunk;
|
|
@@ -91,15 +154,15 @@ export async function serveDaemon(opts) {
|
|
|
91
154
|
const line = buf.slice(0, nl);
|
|
92
155
|
buf = buf.slice(nl + 1);
|
|
93
156
|
bufBytes = Buffer.byteLength(buf, "utf8");
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
157
|
+
if (++requests > MAX_REQUESTS_PER_CONNECTION) {
|
|
158
|
+
// Bounded per connection: one refusal, then the connection closes and its unread input is dropped.
|
|
159
|
+
sock.removeAllListeners("data");
|
|
160
|
+
buf = "";
|
|
161
|
+
bufBytes = 0;
|
|
162
|
+
void refuse(null, "TOO_MANY_REQUESTS", `a connection may send at most ${MAX_REQUESTS_PER_CONNECTION} requests`).then(() => sock.end());
|
|
163
|
+
return;
|
|
100
164
|
}
|
|
101
|
-
|
|
102
|
-
sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: parsed.request.id, ok: true, result: status() }));
|
|
165
|
+
handle(line);
|
|
103
166
|
}
|
|
104
167
|
});
|
|
105
168
|
sock.resume();
|
|
@@ -147,6 +210,8 @@ export async function serveDaemon(opts) {
|
|
|
147
210
|
let closed;
|
|
148
211
|
const close = () => {
|
|
149
212
|
closed ??= (async () => {
|
|
213
|
+
// An in-flight sign-in is cancelled through the stock fence before the listener goes away.
|
|
214
|
+
await login.close();
|
|
150
215
|
for (const s of sockets)
|
|
151
216
|
s.destroy();
|
|
152
217
|
await new Promise((r) => server.close(() => r()));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cotal-ai",
|
|
3
|
-
"version": "0.94.
|
|
3
|
+
"version": "0.94.5",
|
|
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.94.
|
|
37
|
-
"@cotal-ai/cli": "0.94.
|
|
38
|
-
"@cotal-ai/connector-core": "0.94.
|
|
39
|
-
"@cotal-ai/core": "0.94.
|
|
40
|
-
"@cotal-ai/delivery": "0.94.
|
|
41
|
-
"@cotal-ai/manager": "0.94.
|
|
42
|
-
"@cotal-ai/runtime": "0.94.
|
|
43
|
-
"@cotal-ai/seat": "0.94.
|
|
44
|
-
"@cotal-ai/workspace": "0.94.
|
|
36
|
+
"@cotal-ai/auth": "0.94.5",
|
|
37
|
+
"@cotal-ai/cli": "0.94.5",
|
|
38
|
+
"@cotal-ai/connector-core": "0.94.5",
|
|
39
|
+
"@cotal-ai/core": "0.94.5",
|
|
40
|
+
"@cotal-ai/delivery": "0.94.5",
|
|
41
|
+
"@cotal-ai/manager": "0.94.5",
|
|
42
|
+
"@cotal-ai/runtime": "0.94.5",
|
|
43
|
+
"@cotal-ai/seat": "0.94.5",
|
|
44
|
+
"@cotal-ai/workspace": "0.94.5"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@cotal-ai/connector-claude-code": "0.94.
|
|
48
|
-
"@cotal-ai/connector-codex": "0.94.
|
|
49
|
-
"@cotal-ai/connector-hermes": "0.94.
|
|
50
|
-
"@cotal-ai/connector-jcode": "0.94.
|
|
51
|
-
"@cotal-ai/connector-opencode": "0.94.
|
|
52
|
-
"@cotal-ai/linear": "0.94.
|
|
53
|
-
"@cotal-ai/pi": "0.94.
|
|
47
|
+
"@cotal-ai/connector-claude-code": "0.94.5",
|
|
48
|
+
"@cotal-ai/connector-codex": "0.94.5",
|
|
49
|
+
"@cotal-ai/connector-hermes": "0.94.5",
|
|
50
|
+
"@cotal-ai/connector-jcode": "0.94.5",
|
|
51
|
+
"@cotal-ai/connector-opencode": "0.94.5",
|
|
52
|
+
"@cotal-ai/linear": "0.94.5",
|
|
53
|
+
"@cotal-ai/pi": "0.94.5",
|
|
54
54
|
"@cotal-ai/smoke-kit": "0.0.0",
|
|
55
|
-
"@cotal-ai/web": "0.94.
|
|
55
|
+
"@cotal-ai/web": "0.94.5",
|
|
56
56
|
"@nats-io/jetstream": "^3.4.0",
|
|
57
57
|
"@nats-io/transport-node": "^3.4.0",
|
|
58
58
|
"typescript": "^5.9.3"
|