cotal-ai 0.94.4 → 0.94.7
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 +93 -17
- package/dist/daemon/command.js +133 -13
- package/dist/daemon/connect.js +103 -0
- package/dist/daemon/login.js +111 -0
- package/dist/daemon/protocol.js +54 -8
- package/dist/daemon/server.js +84 -9
- package/package.json +18 -18
- package/seeded-connectors/claude/dist/mcp.cjs +7 -7
- package/seeded-connectors/claude/package.json +1 -1
- package/seeded-connectors/codex/dist/host.js +7 -7
- package/seeded-connectors/codex/package.json +1 -1
- package/seeded-connectors/hermes/dist/launch.js +7 -7
- package/seeded-connectors/hermes/package.json +1 -1
- package/seeded-connectors/hermes/plugin/cotal/_sidecar/standalone.cjs +7 -7
- package/seeded-connectors/jcode/dist/host.js +7 -7
- package/seeded-connectors/jcode/dist/mcp.js +7 -7
- package/seeded-connectors/jcode/package.json +1 -1
- package/seeded-connectors/opencode/dist/plugin/index.js +7 -7
- package/seeded-connectors/opencode/package.json +1 -1
- package/seeded-connectors/pi/dist/index.js +4 -4
- package/seeded-connectors/pi/dist/standalone.js +7 -7
- package/seeded-connectors/pi/package.json +1 -1
- package/seeded-connectors/web/package.json +1 -1
package/dist/daemon/client.js
CHANGED
|
@@ -10,6 +10,23 @@ import { connect as netConnect } from "node:net";
|
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES, EndpointRefusal, assertLinux, checkDir, checkSocket } from "./endpoint.js";
|
|
12
12
|
import { encode } from "./protocol.js";
|
|
13
|
+
/** Ask the daemon whether it could register with `space`. Nothing is registered either way. */
|
|
14
|
+
export async function connectCheckViaDaemon(opts) {
|
|
15
|
+
const sock = await openVerified({ ...opts, timeoutMs: 5000 });
|
|
16
|
+
try {
|
|
17
|
+
sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: "connect-check", space: opts.space }));
|
|
18
|
+
// The daemon's own network read is bounded by the stock client (30 s); allow a little more.
|
|
19
|
+
const reply = JSON.parse(await readLine(sock, 35_000));
|
|
20
|
+
if (!reply.ok)
|
|
21
|
+
return { ok: false, code: reply.error.code, message: reply.error.message };
|
|
22
|
+
if ("event" in reply || !("outcome" in reply.result) || !("space" in reply.result))
|
|
23
|
+
return { ok: false, code: "BAD_REPLY", message: "the daemon answered the connect check with something else" };
|
|
24
|
+
return { ok: true, result: reply.result };
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
sock.destroy();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
13
30
|
function connectTo(path, timeoutMs) {
|
|
14
31
|
return new Promise((resolve, reject) => {
|
|
15
32
|
const s = netConnect(path);
|
|
@@ -59,10 +76,23 @@ function readLine(sock, timeoutMs) {
|
|
|
59
76
|
}
|
|
60
77
|
/** Send one request and return the daemon's parsed reply. */
|
|
61
78
|
export async function queryDaemon(opts) {
|
|
79
|
+
const sock = await openVerified(opts);
|
|
80
|
+
try {
|
|
81
|
+
const line = opts.rawLine !== undefined ? `${opts.rawLine}\n` : encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: "status" });
|
|
82
|
+
(opts.counters ?? { bytesWritten: 0 }).bytesWritten += Buffer.byteLength(line, "utf8");
|
|
83
|
+
sock.write(line);
|
|
84
|
+
const reply = await readLine(sock, opts.timeoutMs ?? 5000);
|
|
85
|
+
return JSON.parse(reply);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
sock.destroy();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Connect only after the directory and socket checks pass, and keep the connection only when the
|
|
92
|
+
* kernel reports the listener as this same uid. Nothing is written before both. */
|
|
93
|
+
async function openVerified(opts) {
|
|
62
94
|
assertLinux();
|
|
63
95
|
const uid = opts.uid ?? process.geteuid();
|
|
64
|
-
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
65
|
-
const counters = opts.counters ?? { bytesWritten: 0 };
|
|
66
96
|
const { paths } = opts;
|
|
67
97
|
try {
|
|
68
98
|
checkDir(paths.dir, uid);
|
|
@@ -73,22 +103,68 @@ export async function queryDaemon(opts) {
|
|
|
73
103
|
throw new EndpointRefusal("NOT_RUNNING", `no daemon endpoint at ${paths.socket}`);
|
|
74
104
|
throw e;
|
|
75
105
|
}
|
|
76
|
-
const sock = await connectTo(paths.socket, timeoutMs);
|
|
106
|
+
const sock = await connectTo(paths.socket, opts.timeoutMs ?? 5000);
|
|
107
|
+
let peer;
|
|
77
108
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
peer = opts.peerUid(sock);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
sock.destroy();
|
|
113
|
+
throw new EndpointRefusal("PEER_UID_MISMATCH", `could not read the daemon's peer credentials: ${e.message}`);
|
|
114
|
+
}
|
|
115
|
+
if (peer !== uid) {
|
|
116
|
+
sock.destroy();
|
|
117
|
+
throw new EndpointRefusal("PEER_UID_MISMATCH", `the process answering at ${paths.socket} runs as uid ${peer}, not ${uid}; nothing was sent`);
|
|
118
|
+
}
|
|
119
|
+
return sock;
|
|
120
|
+
}
|
|
121
|
+
/** Ask the daemon to sign in, show its prompt, and return its single result. */
|
|
122
|
+
export async function loginViaDaemon(opts) {
|
|
123
|
+
const sock = await openVerified(opts);
|
|
124
|
+
try {
|
|
125
|
+
const id = randomUUID().replaceAll("-", "");
|
|
126
|
+
return await new Promise((resolve, reject) => {
|
|
127
|
+
let buf = "";
|
|
128
|
+
let attempt;
|
|
129
|
+
const cleanup = () => { sock.removeAllListeners("data"); sock.removeAllListeners("end"); sock.removeAllListeners("error"); opts.signal?.removeEventListener("abort", onAbort); };
|
|
130
|
+
const finish = (r) => { cleanup(); if (r instanceof Error)
|
|
131
|
+
reject(r);
|
|
132
|
+
else
|
|
133
|
+
resolve(r); };
|
|
134
|
+
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" };
|
|
135
|
+
const onAbort = () => {
|
|
136
|
+
if (!attempt)
|
|
137
|
+
return finish(abandoned);
|
|
138
|
+
sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: `${id}c`.slice(0, 64), op: "login-cancel", attempt }));
|
|
139
|
+
};
|
|
140
|
+
sock.setEncoding("utf8");
|
|
141
|
+
sock.on("data", (chunk) => {
|
|
142
|
+
buf += chunk;
|
|
143
|
+
if (Buffer.byteLength(buf, "utf8") > MAX_LINE_BYTES && !buf.includes("\n"))
|
|
144
|
+
return finish(new Error("daemon reply exceeds the line limit"));
|
|
145
|
+
let nl;
|
|
146
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
147
|
+
const msg = JSON.parse(buf.slice(0, nl));
|
|
148
|
+
buf = buf.slice(nl + 1);
|
|
149
|
+
if (msg.id !== id)
|
|
150
|
+
continue;
|
|
151
|
+
if (!msg.ok)
|
|
152
|
+
return finish({ ok: false, code: msg.error.code, message: msg.error.message });
|
|
153
|
+
if ("event" in msg && msg.event === "login-prompt") {
|
|
154
|
+
attempt = msg.attempt;
|
|
155
|
+
opts.onPrompt(msg.prompt);
|
|
156
|
+
}
|
|
157
|
+
else if ("event" in msg && msg.event === "login-result")
|
|
158
|
+
return finish({ ok: true, attempt: msg.attempt, result: msg.result });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
sock.once("end", () => finish(new Error("the daemon closed the connection before the sign-in finished")));
|
|
162
|
+
sock.once("error", (e) => finish(e));
|
|
163
|
+
if (opts.signal?.aborted)
|
|
164
|
+
return finish(abandoned);
|
|
165
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
166
|
+
sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id, op: "login-start", idp: opts.idp, ...(opts.clientId !== undefined ? { clientId: opts.clientId } : {}) }));
|
|
167
|
+
});
|
|
92
168
|
}
|
|
93
169
|
finally {
|
|
94
170
|
sock.destroy();
|
package/dist/daemon/command.js
CHANGED
|
@@ -3,22 +3,35 @@
|
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* This slice serves a local, authenticated endpoint for status, sign-in and a connection check, and
|
|
7
|
+
* nothing else: no registration, no Manager, no mesh or Cloud connection, no service install. Linux
|
|
8
|
+
* only. Sign-in runs inside the
|
|
9
|
+
* daemon through stock `establishIdpSession`, so the session lands in the machine-home Cotal
|
|
10
|
+
* directory where `cotal login` keeps it, and no token ever crosses the local endpoint. The
|
|
11
|
+
* connection check (`connect.ts`) reads the space's public account identity and keeps the daemon's
|
|
12
|
+
* own per-space identity, never a Manager's. It is a
|
|
13
|
+
* foreground foundation, not a resilient machine service: a daemon killed without cleanup leaves
|
|
14
|
+
* its socket behind, and the next start refuses until the operator removes it (see the
|
|
15
|
+
* ALREADY_RUNNING remedy). `state: "ready"` means the local control listener is bound and
|
|
16
|
+
* verified; it says nothing about any mesh or Cloud connection.
|
|
11
17
|
*/
|
|
12
18
|
import { readFileSync } from "node:fs";
|
|
13
19
|
import { dirname, join } from "node:path";
|
|
14
20
|
import { fileURLToPath } from "node:url";
|
|
15
21
|
import { registry } from "@cotal-ai/core";
|
|
16
|
-
import { CommandFailure, jsonFlag, plainFlag, presentationOf, writeResult } from "@cotal-ai/workspace";
|
|
22
|
+
import { CommandFailure, jsonFlag, plainFlag, presentationOf, renderReport, spaceFlag, terminalWidth, writeResult } from "@cotal-ai/workspace";
|
|
17
23
|
import { peerCredentials } from "@cotal-ai/seat";
|
|
24
|
+
import { normalizeIdpUrl } from "@cotal-ai/auth";
|
|
18
25
|
import { EndpointRefusal, daemonPaths } from "./endpoint.js";
|
|
19
26
|
import { serveDaemon } from "./server.js";
|
|
20
|
-
import { queryDaemon } from "./client.js";
|
|
21
|
-
export const daemonFlags = [
|
|
27
|
+
import { connectCheckViaDaemon, loginViaDaemon, queryDaemon } from "./client.js";
|
|
28
|
+
export const daemonFlags = [
|
|
29
|
+
jsonFlag,
|
|
30
|
+
plainFlag,
|
|
31
|
+
{ name: "idp", type: "string", value: "<auth base URL>", description: "login: the provider's auth base URL, e.g. <origin>/api/auth" },
|
|
32
|
+
{ name: "client-id", type: "string", value: "<id>", description: "login: the OAuth device-flow client id" },
|
|
33
|
+
spaceFlag,
|
|
34
|
+
];
|
|
22
35
|
/** This binary's own version: the nearest package.json above this module (bin/ in a source run). */
|
|
23
36
|
export function binVersion() {
|
|
24
37
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -73,6 +86,107 @@ async function status(args) {
|
|
|
73
86
|
throw new CommandFailure("failed", "OPERATION_FAILED", `the daemon refused the status request: ${reply.error.code} ${reply.error.message}`);
|
|
74
87
|
writeResult(presentation, { command: "daemon status", target: null, result: reply.result }, statusView);
|
|
75
88
|
}
|
|
89
|
+
/** Sign in through the running daemon. The daemon talks to the IdP and keeps the session; this
|
|
90
|
+
* process only shows the approval prompt and the result, and never sees a token. Ctrl-C asks the
|
|
91
|
+
* daemon to cancel the attempt and waits for its answer. */
|
|
92
|
+
async function login(args) {
|
|
93
|
+
const presentation = presentationOf(args.values);
|
|
94
|
+
const values = args.values;
|
|
95
|
+
if (typeof values.idp !== "string")
|
|
96
|
+
throw new CommandFailure("refused", "USAGE_INVALID", "cotal daemon login needs --idp <auth base URL>");
|
|
97
|
+
let idp;
|
|
98
|
+
try {
|
|
99
|
+
idp = normalizeIdpUrl(values.idp);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
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");
|
|
103
|
+
}
|
|
104
|
+
if (values["client-id"] !== undefined && !values["client-id"].trim())
|
|
105
|
+
throw new CommandFailure("refused", "USAGE_INVALID", "--client-id cannot be empty");
|
|
106
|
+
const abort = new AbortController();
|
|
107
|
+
const onSignal = () => abort.abort();
|
|
108
|
+
process.once("SIGINT", onSignal);
|
|
109
|
+
let reply;
|
|
110
|
+
try {
|
|
111
|
+
reply = await loginViaDaemon({
|
|
112
|
+
paths: daemonPaths(), peerUid, idp, signal: abort.signal,
|
|
113
|
+
...(values["client-id"] !== undefined ? { clientId: values["client-id"] } : {}),
|
|
114
|
+
onPrompt: (p) => {
|
|
115
|
+
process.stderr.write(`${renderReport({ sections: [{ title: "Approve sign-in", blocks: [
|
|
116
|
+
{ kind: "fields", rows: [["Provider", idp], ["Open", p.verificationUriComplete], ["Or open", p.verificationUri], ["Approval code", p.userCode], ["Expires", p.expiresAt]] },
|
|
117
|
+
{ 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" }] },
|
|
118
|
+
] }] }, presentation === "plain" ? "plain" : "rich", terminalWidth(process.stderr))}\n`);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
throw asFailure(e);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
process.off("SIGINT", onSignal);
|
|
127
|
+
}
|
|
128
|
+
if (!reply.ok) {
|
|
129
|
+
if (reply.code === "LOGIN_IN_PROGRESS")
|
|
130
|
+
throw new CommandFailure("refused", "CONFLICT", reply.message, { native: { code: reply.code } });
|
|
131
|
+
if (reply.code === "ABANDONED")
|
|
132
|
+
throw new CommandFailure("refused", "USAGE_INVALID", reply.message, { native: { code: reply.code } });
|
|
133
|
+
throw new CommandFailure("failed", "OPERATION_FAILED", `the daemon refused the sign-in: ${reply.code} ${reply.message}`, { native: { code: reply.code } });
|
|
134
|
+
}
|
|
135
|
+
const r = reply.result;
|
|
136
|
+
if (r.outcome === "cancelled")
|
|
137
|
+
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" } });
|
|
138
|
+
if (r.outcome === "failed") {
|
|
139
|
+
const native = { native: { code: r.reason.toUpperCase() } };
|
|
140
|
+
// Denied, expired and unreachable end before stock writes anything: refusals at the provider's
|
|
141
|
+
// boundary. Any other failure can come after stock saved the session, so it claims nothing about
|
|
142
|
+
// what was saved; `cotal daemon login` again replaces whatever is there.
|
|
143
|
+
if (r.reason === "unreachable")
|
|
144
|
+
throw new CommandFailure("refused", "DEPENDENCY_UNAVAILABLE", `${r.message}; no session was saved`, native);
|
|
145
|
+
if (r.reason === "denied" || r.reason === "expired")
|
|
146
|
+
throw new CommandFailure("refused", "PERMISSION_DENIED", `${r.message}; no session was saved`, native);
|
|
147
|
+
throw new CommandFailure("failed", "OPERATION_FAILED", `${r.message}; a session may have been saved before the failure, so sign in again`, native);
|
|
148
|
+
}
|
|
149
|
+
writeResult(presentation, { command: "daemon login", target: { idp }, result: { attempt: reply.attempt, ...r } }, () => ({ sections: [
|
|
150
|
+
{ 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" }] }] },
|
|
151
|
+
] }));
|
|
152
|
+
}
|
|
153
|
+
/** Ask the running daemon whether it could register with a space. The daemon reads the space's
|
|
154
|
+
* public account identity and nothing else; no registration, credential or authority results. */
|
|
155
|
+
async function connectCheckCommand(args) {
|
|
156
|
+
const presentation = presentationOf(args.values);
|
|
157
|
+
const space = args.values.space;
|
|
158
|
+
if (typeof space !== "string" || !space)
|
|
159
|
+
throw new CommandFailure("refused", "USAGE_INVALID", "cotal daemon connect-check needs --space <s>");
|
|
160
|
+
let reply;
|
|
161
|
+
try {
|
|
162
|
+
reply = await connectCheckViaDaemon({ paths: daemonPaths(), peerUid, space });
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
throw asFailure(e);
|
|
166
|
+
}
|
|
167
|
+
if (!reply.ok)
|
|
168
|
+
throw new CommandFailure("refused", "USAGE_INVALID", `the daemon refused the connect check: ${reply.code} ${reply.message}`, { native: { code: reply.code } });
|
|
169
|
+
const r = reply.result;
|
|
170
|
+
const native = { native: { code: r.outcome.toUpperCase().replaceAll("-", "_") } };
|
|
171
|
+
if (r.outcome === "not-registered")
|
|
172
|
+
throw new CommandFailure("refused", "TARGET_NOT_FOUND", r.message, native);
|
|
173
|
+
if (r.outcome === "not-user-auth" || r.outcome === "unsupported-by-space")
|
|
174
|
+
throw new CommandFailure("refused", "UNSUPPORTED", r.message, native);
|
|
175
|
+
if (r.outcome === "not-signed-in")
|
|
176
|
+
throw new CommandFailure("refused", "AUTH_REQUIRED", r.message, native);
|
|
177
|
+
if (r.outcome === "unreachable")
|
|
178
|
+
throw new CommandFailure("refused", "DEPENDENCY_UNAVAILABLE", r.message, native);
|
|
179
|
+
if (r.outcome === "account-mismatch")
|
|
180
|
+
throw new CommandFailure("refused", "CONFLICT", r.message, native);
|
|
181
|
+
if (r.outcome !== "ready-to-register")
|
|
182
|
+
throw new CommandFailure("refused", "DEPENDENCY_UNAVAILABLE", r.message, native);
|
|
183
|
+
writeResult(presentation, { command: "daemon connect-check", target: { space }, result: r }, () => ({ sections: [
|
|
184
|
+
{ title: "Ready to register", blocks: [
|
|
185
|
+
{ kind: "fields", rows: [["Space", r.space], ["Service", r.endpoint], ["Instance", r.instanceId], ["Account", r.accountPublicKey]] },
|
|
186
|
+
{ kind: "lines", lines: [{ text: "Nothing was registered. The space owner must approve this daemon before it can connect.", tone: "hint" }] },
|
|
187
|
+
] },
|
|
188
|
+
] }));
|
|
189
|
+
}
|
|
76
190
|
async function serve() {
|
|
77
191
|
let running;
|
|
78
192
|
try {
|
|
@@ -96,21 +210,27 @@ async function run(args) {
|
|
|
96
210
|
return status(args);
|
|
97
211
|
if (mode === "serve")
|
|
98
212
|
return serve();
|
|
99
|
-
|
|
213
|
+
if (mode === "login")
|
|
214
|
+
return login(args);
|
|
215
|
+
if (mode === "connect-check")
|
|
216
|
+
return connectCheckCommand(args);
|
|
217
|
+
throw new CommandFailure("refused", "USAGE_INVALID", mode === undefined ? "cotal daemon needs a mode: status, serve, login or connect-check" : `unknown daemon mode ${JSON.stringify(mode)}`, { remedy: "cotal daemon status" });
|
|
100
218
|
}
|
|
101
219
|
const daemonCommand = {
|
|
102
220
|
kind: "command",
|
|
103
221
|
name: "daemon",
|
|
104
222
|
group: "Manager",
|
|
105
|
-
summary: "the local machine daemon (Linux): serve its control endpoint, read its status",
|
|
106
|
-
positionals: "<status|serve>",
|
|
223
|
+
summary: "the local machine daemon (Linux): serve its control endpoint, read its status, sign it in, check whether it can connect to a space",
|
|
224
|
+
positionals: "<status|serve|login|connect-check>",
|
|
107
225
|
flags: daemonFlags,
|
|
108
226
|
subcommands: [
|
|
109
|
-
{ name: "status", summary: "ask the running daemon for its status over the local endpoint", outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
|
|
227
|
+
{ 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
228
|
{ 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 },
|
|
229
|
+
{ 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 },
|
|
230
|
+
{ 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 },
|
|
111
231
|
],
|
|
112
232
|
prepareMeshTarget: false,
|
|
113
|
-
examples: ["cotal daemon serve", "cotal daemon status", "cotal daemon status --json"],
|
|
233
|
+
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"],
|
|
114
234
|
run,
|
|
115
235
|
};
|
|
116
236
|
registry.register(daemonCommand);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's connection readiness for one space (A2b.1). It answers whether this machine's daemon
|
|
3
|
+
* could register with the space as its own service, and if not, exactly why. It never registers,
|
|
4
|
+
* holds no bearer and starts nothing.
|
|
5
|
+
*
|
|
6
|
+
* Every input is stock: the mesh registry entry (`findMesh`), the machine's stock IdP session
|
|
7
|
+
* (`loadIdpSession`), and the registered auth provider's `serviceEndpointInfo`, which reads the
|
|
8
|
+
* space's stock service authority route (GET only, no redirects, closed parser). A 404 there means the
|
|
9
|
+
* space does not host service endpoints (stock `serviceEndpoints` is off by default); the daemon
|
|
10
|
+
* reports that, and never falls back to a Manager view, `manager.vitals` or static credentials.
|
|
11
|
+
*
|
|
12
|
+
* The daemon's identity is its own, separate from the stock Manager identity: one record per
|
|
13
|
+
* (machine home, space) under `<COTAL_HOME>/daemon/<space segment>/service-identity.json`, created
|
|
14
|
+
* once through stock `claimIdentityRecord` (exclusive create, one winner). It pins the space's
|
|
15
|
+
* DATA account on first sight; a later different account is a mismatch, never a silent repin. It
|
|
16
|
+
* holds no credential, owner, hostname or hardware id.
|
|
17
|
+
*/
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { assertLifecycleToken, isAccountNkey, mintLifecycleUid, resolveAuthProvider } from "@cotal-ai/core";
|
|
20
|
+
import { claimIdentityRecord, findMesh, homeCotalDir, spaceSegment, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace";
|
|
21
|
+
import { loadIdpSession } from "@cotal-ai/auth";
|
|
22
|
+
/** The service name the daemon proposes for itself. Single-label names are reserved for reference
|
|
23
|
+
* endpoints and a third-party name must be reverse-DNS, so this is the reverse-DNS of cotal.ai.
|
|
24
|
+
* Binding it still needs the space operator's `name-bind`; the daemon never claims it. */
|
|
25
|
+
export const DAEMON_ENDPOINT = "ai.cotal.machine";
|
|
26
|
+
const KIND = "cotal-daemon-service-identity";
|
|
27
|
+
/** Where this machine's daemon keeps its identity for `space`. */
|
|
28
|
+
export function daemonIdentityFile(space, home = homeCotalDir()) {
|
|
29
|
+
return join(home, "daemon", spaceSegment(space), "service-identity.json");
|
|
30
|
+
}
|
|
31
|
+
function parseIdentity(space) {
|
|
32
|
+
return (raw) => {
|
|
33
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
34
|
+
return undefined;
|
|
35
|
+
const o = raw;
|
|
36
|
+
const keys = Object.keys(o).sort().join(",");
|
|
37
|
+
if (keys !== "accountPublicKey,endpoint,instanceId,kind,space,v")
|
|
38
|
+
return undefined;
|
|
39
|
+
if (o.v !== 1 || o.kind !== KIND || o.space !== space || o.endpoint !== DAEMON_ENDPOINT || !isAccountNkey(o.accountPublicKey))
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof o.instanceId !== "string")
|
|
42
|
+
return undefined;
|
|
43
|
+
try {
|
|
44
|
+
assertLifecycleToken(o.instanceId, "instanceId");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return o;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** The fixed sentence per failure. Nothing from the remote side is quoted. */
|
|
53
|
+
const SENTENCE = {
|
|
54
|
+
"not-registered": "this space is not registered on this machine; add it with `cotal meshes add`",
|
|
55
|
+
"not-user-auth": "this space does not use user sign-in, so the daemon cannot connect to it as its own service",
|
|
56
|
+
"not-signed-in": "this machine is not signed in to the space's provider; run `cotal daemon login --idp <url>`",
|
|
57
|
+
"unsupported-by-space": "the space's auth service does not host service endpoints, so the daemon cannot register with it",
|
|
58
|
+
unreachable: "the space's auth service could not be reached",
|
|
59
|
+
refused: "the space's auth service gave an answer the daemon cannot use",
|
|
60
|
+
};
|
|
61
|
+
/** Check readiness for `space`. `home` is the machine home where the stock session and the daemon's
|
|
62
|
+
* own state live. */
|
|
63
|
+
export async function connectCheck(space, home = homeCotalDir()) {
|
|
64
|
+
const entry = findMesh(space);
|
|
65
|
+
if (!entry)
|
|
66
|
+
return { outcome: "not-registered", space, message: SENTENCE["not-registered"] };
|
|
67
|
+
const ua = entry.mode === "user" ? entry.userAuth : undefined;
|
|
68
|
+
if (!ua)
|
|
69
|
+
return { outcome: "not-user-auth", space, message: SENTENCE["not-user-auth"] };
|
|
70
|
+
let session;
|
|
71
|
+
try {
|
|
72
|
+
session = loadIdpSession(home, ua.idp.url);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
session = undefined;
|
|
76
|
+
}
|
|
77
|
+
if (!session)
|
|
78
|
+
return { outcome: "not-signed-in", space, message: SENTENCE["not-signed-in"] };
|
|
79
|
+
let accountPublicKey;
|
|
80
|
+
try {
|
|
81
|
+
const provider = resolveAuthProvider();
|
|
82
|
+
if (!provider.serviceEndpointInfo)
|
|
83
|
+
return { outcome: "unsupported-by-space", space, message: SENTENCE["unsupported-by-space"] };
|
|
84
|
+
const info = await provider.serviceEndpointInfo({ store: workspaceSecretStore(entry.root), dir: userAuthStateDir(entry.root, space, { readOnly: true }), space });
|
|
85
|
+
accountPublicKey = info.accountPublicKey;
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
const status = e.httpStatus;
|
|
89
|
+
if (status === 404)
|
|
90
|
+
return { outcome: "unsupported-by-space", space, message: SENTENCE["unsupported-by-space"] };
|
|
91
|
+
if (status === undefined && e.outcome === "not-executed" && /did not return a response/.test(e.message))
|
|
92
|
+
return { outcome: "unreachable", space, message: SENTENCE.unreachable };
|
|
93
|
+
return { outcome: "refused", space, message: SENTENCE.refused };
|
|
94
|
+
}
|
|
95
|
+
const identity = claimIdentityRecord(daemonIdentityFile(space, home), "daemon service identity", parseIdentity(space), () => ({
|
|
96
|
+
v: 1, kind: KIND, space, endpoint: DAEMON_ENDPOINT, instanceId: mintLifecycleUid(), accountPublicKey,
|
|
97
|
+
}));
|
|
98
|
+
if (identity.accountPublicKey !== accountPublicKey)
|
|
99
|
+
return { outcome: "account-mismatch", space, pinnedAccountPublicKey: identity.accountPublicKey, observedAccountPublicKey: accountPublicKey,
|
|
100
|
+
message: "the space now answers with a different account than this daemon pinned; nothing was changed. Confirm the space was not replaced before removing the daemon's identity for it by hand" };
|
|
101
|
+
return { outcome: "ready-to-register", space, endpoint: DAEMON_ENDPOINT, instanceId: identity.instanceId, accountPublicKey,
|
|
102
|
+
message: "this daemon can ask the space to register it; the space owner must still approve it, and nothing has been registered" };
|
|
103
|
+
}
|
|
@@ -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,34 @@
|
|
|
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", "connect-check"];
|
|
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
|
+
"connect-check": { required: ["space"], optional: [] },
|
|
23
|
+
};
|
|
24
|
+
const ENVELOPE_KEYS = new Set(["v", "id", "op"]);
|
|
11
25
|
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
26
|
+
/** An attempt id is daemon-minted: 22 characters of base64url. */
|
|
27
|
+
export const ATTEMPT_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
|
28
|
+
const MAX_CLIENT_ID = 128;
|
|
29
|
+
/** A space name as it appears in a subject token. */
|
|
30
|
+
const SPACE_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
31
|
+
const bad = (id, message) => ({ ok: false, id, code: "BAD_FIELD", message });
|
|
12
32
|
/** Parse one line (without its newline). Pure: it decides, it does nothing. */
|
|
13
33
|
export function parseRequest(line) {
|
|
14
34
|
if (Buffer.byteLength(line, "utf8") + 1 > MAX_LINE_BYTES)
|
|
@@ -24,17 +44,43 @@ export function parseRequest(line) {
|
|
|
24
44
|
return { ok: false, id: null, code: "NOT_OBJECT", message: "request must be a JSON object" };
|
|
25
45
|
const obj = raw;
|
|
26
46
|
const id = typeof obj.id === "string" && ID_PATTERN.test(obj.id) ? obj.id : null;
|
|
47
|
+
const op = typeof obj.op === "string" && DAEMON_OPS.includes(obj.op) ? obj.op : undefined;
|
|
48
|
+
const fields = op ? OP_FIELDS[op] : { required: [], optional: [] };
|
|
27
49
|
for (const k of Object.keys(obj)) {
|
|
28
|
-
if (!
|
|
50
|
+
if (!ENVELOPE_KEYS.has(k) && !fields.required.includes(k) && !fields.optional.includes(k))
|
|
29
51
|
return { ok: false, id, code: "UNKNOWN_FIELD", message: `unknown request field ${JSON.stringify(k)}` };
|
|
30
52
|
}
|
|
31
53
|
if (obj.v !== DAEMON_PROTOCOL_VERSION)
|
|
32
54
|
return { ok: false, id, code: "BAD_VERSION", message: `unsupported protocol version ${JSON.stringify(obj.v)}; this daemon speaks ${DAEMON_PROTOCOL_VERSION}` };
|
|
33
55
|
if (id === null)
|
|
34
56
|
return { ok: false, id: null, code: "BAD_ID", message: "request id must be 1-64 characters of [A-Za-z0-9_-]" };
|
|
35
|
-
if (
|
|
57
|
+
if (!op)
|
|
36
58
|
return { ok: false, id, code: "UNKNOWN_OP", message: `unknown op ${JSON.stringify(obj.op)}` };
|
|
37
|
-
|
|
59
|
+
const v = DAEMON_PROTOCOL_VERSION;
|
|
60
|
+
if (op === "status")
|
|
61
|
+
return { ok: true, request: { v, id, op } };
|
|
62
|
+
if (op === "login-cancel") {
|
|
63
|
+
if (typeof obj.attempt !== "string" || !ATTEMPT_PATTERN.test(obj.attempt))
|
|
64
|
+
return bad(id, "attempt must be the 22-character attempt id from login-prompt");
|
|
65
|
+
return { ok: true, request: { v, id, op, attempt: obj.attempt } };
|
|
66
|
+
}
|
|
67
|
+
if (op === "connect-check") {
|
|
68
|
+
if (typeof obj.space !== "string" || !SPACE_PATTERN.test(obj.space))
|
|
69
|
+
return bad(id, "space must be 1-64 characters of [A-Za-z0-9_-]");
|
|
70
|
+
return { ok: true, request: { v, id, op, space: obj.space } };
|
|
71
|
+
}
|
|
72
|
+
if (typeof obj.idp !== "string")
|
|
73
|
+
return bad(id, "idp must be the provider's auth base URL");
|
|
74
|
+
let idp;
|
|
75
|
+
try {
|
|
76
|
+
idp = normalizeIdpUrl(obj.idp);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return bad(id, "idp must be an HTTPS auth URL, or an HTTP loopback IP URL, without embedded credentials, a query or a fragment");
|
|
80
|
+
}
|
|
81
|
+
if (obj.clientId !== undefined && (typeof obj.clientId !== "string" || !obj.clientId.trim() || obj.clientId.length > MAX_CLIENT_ID))
|
|
82
|
+
return bad(id, `clientId must be a non-empty string of at most ${MAX_CLIENT_ID} characters`);
|
|
83
|
+
return { ok: true, request: { v, id, op, idp, ...(obj.clientId !== undefined ? { clientId: obj.clientId } : {}) } };
|
|
38
84
|
}
|
|
39
85
|
export function encode(msg) {
|
|
40
86
|
return `${JSON.stringify(msg)}\n`;
|