@cello-protocol/cli 0.0.233 → 0.0.234

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.
Files changed (40) hide show
  1. package/package.json +4 -4
  2. package/dist/arg-parse.d.ts +0 -19
  3. package/dist/arg-parse.d.ts.map +0 -1
  4. package/dist/arg-parse.js +0 -29
  5. package/dist/arg-parse.js.map +0 -1
  6. package/dist/bin/cello.d.ts +0 -15
  7. package/dist/bin/cello.d.ts.map +0 -1
  8. package/dist/bin/cello.js.map +0 -1
  9. package/dist/cli-args.d.ts +0 -62
  10. package/dist/cli-args.d.ts.map +0 -1
  11. package/dist/cli-args.js +0 -119
  12. package/dist/cli-args.js.map +0 -1
  13. package/dist/commands.d.ts +0 -139
  14. package/dist/commands.d.ts.map +0 -1
  15. package/dist/commands.js +0 -1005
  16. package/dist/commands.js.map +0 -1
  17. package/dist/hermes/assets.d.ts +0 -35
  18. package/dist/hermes/assets.d.ts.map +0 -1
  19. package/dist/hermes/assets.js +0 -1396
  20. package/dist/hermes/assets.js.map +0 -1
  21. package/dist/hermes/install-hermes.d.ts +0 -44
  22. package/dist/hermes/install-hermes.d.ts.map +0 -1
  23. package/dist/hermes/install-hermes.js +0 -172
  24. package/dist/hermes/install-hermes.js.map +0 -1
  25. package/dist/json-out.d.ts +0 -41
  26. package/dist/json-out.d.ts.map +0 -1
  27. package/dist/json-out.js +0 -59
  28. package/dist/json-out.js.map +0 -1
  29. package/dist/parity-commands.d.ts +0 -358
  30. package/dist/parity-commands.d.ts.map +0 -1
  31. package/dist/parity-commands.js +0 -720
  32. package/dist/parity-commands.js.map +0 -1
  33. package/dist/registry.d.ts +0 -111
  34. package/dist/registry.d.ts.map +0 -1
  35. package/dist/registry.js +0 -1552
  36. package/dist/registry.js.map +0 -1
  37. package/dist/screener-commands.d.ts +0 -57
  38. package/dist/screener-commands.d.ts.map +0 -1
  39. package/dist/screener-commands.js +0 -229
  40. package/dist/screener-commands.js.map +0 -1
package/dist/commands.js DELETED
@@ -1,1005 +0,0 @@
1
- /**
2
- * CLI command implementations for the `cello` binary.
3
- */
4
- import { join } from "node:path";
5
- import { CO_OWNERSHIP_NOTE } from "@cello-protocol/protocol-types";
6
- import { screenerState, screenerModelDir, runtimeAvailable } from "@cello-protocol/gateway";
7
- import { DOWNLOAD_MB } from "./screener-commands.js";
8
- import { connectOrStart, connectToDaemon, readLock, removeLock,
9
- // isProcessAlive may NEVER answer "does a daemon exist?" — a pid can be dead, or reused by an
10
- // unrelated process. The kernel's lock is the only thing that knows that, and every EXISTENCE
11
- // decision in this file still goes through probeSingletonLock.
12
- //
13
- // DOD-LOGOUT-EXIT-1 imports it for the one question it CAN answer: "is this specific pid, which
14
- // was alive when I told it to stop, gone yet?" That is a non-existence check layered ON TOP of
15
- // the lock, never instead of it, and being wrong fails safe (logout reports "did not complete"
16
- // instead of claiming a stop that did not happen). See `daemonGone`.
17
- isProcessAlive, probeSingletonLock, SINGLETON_LOCK_FILENAME, noAgentsGuidance, } from "@cello-protocol/daemon";
18
- /**
19
- * Open an IPC connection, run `fn`, and ALWAYS close it.
20
- *
21
- * The close belongs in a `finally`, never after the send. The daemon enforces IPC_CONNECTION_LIMIT,
22
- * so a connection is a BOUNDED resource: a `client.close()` written after `client.send()` inside a
23
- * try is SKIPPED on any throw, and each failed command leaks a socket the daemon never reclaims.
24
- * Leak enough and it refuses new connections — a failure whose symptom looks nothing like its cause.
25
- *
26
- * Every single-call command goes through this. The three that cannot are `login` (the connection is
27
- * created by connectOrStart and handed to us), `logout` (it must outlive the send to poll for death),
28
- * and `daemonGone` (it races the connect against a timeout). Each closes in a `finally` or reaps the
29
- * abandoned promise explicitly — see them. Any NEW command that reaches for connectToDaemon directly
30
- * is reintroducing the leak.
31
- */
32
- async function withIpc(socketPath, fn) {
33
- const client = await connectToDaemon(socketPath);
34
- try {
35
- return await fn(client);
36
- }
37
- finally {
38
- client.close();
39
- }
40
- }
41
- /**
42
- * M8C-LOGINSTART-1 CORE: bring every loaded agent online. Called by `cello login` after the daemon
43
- * is up. login ALWAYS completes — a per-agent start failure is COLLECTED, never thrown, so one bad
44
- * agent can't abort login. `cello_start_agent` is idempotent, so re-login is safe. The per-agent
45
- * `autoStart: false` opt-out is PARKED until the M9 config store lands (D14).
46
- * Lives here rather than inline in login() so it is unit-testable against an in-process daemon.
47
- */
48
- export async function autoStartAllAgents(client) {
49
- const started = [];
50
- const failed = [];
51
- const listRes = (await client.send("cello_list_agents"));
52
- for (const a of listRes.agents ?? []) {
53
- try {
54
- const r = (await client.send("cello_start_agent", { name: a.name }));
55
- if (r.ok)
56
- started.push(a.name);
57
- else
58
- failed.push({ name: a.name, reason: r.reason ?? "unknown" });
59
- }
60
- catch (err) {
61
- failed.push({ name: a.name, reason: err instanceof Error ? err.message : String(err) });
62
- }
63
- }
64
- return { started, failed };
65
- }
66
- export async function login(celloDir, daemonBin, logger) {
67
- try {
68
- const result = await connectOrStart(celloDir, logger, daemonBin);
69
- // M8C-LOGINSTART-1: bring every registered agent online, then report. Never let this abort login.
70
- const head = result.alreadyRunning ? "Daemon already running." : "Daemon started.";
71
- let summary;
72
- try {
73
- await result.client.send("ipc.connect", { clientType: "cli" });
74
- summary = formatLoginSummary(await autoStartAllAgents(result.client));
75
- }
76
- catch (err) {
77
- // Auto-start is best-effort — the daemon IS up. Surface the reason but complete login (exit 0).
78
- summary = `Agents were not auto-started (${err instanceof Error ? err.message : String(err)}); run 'cello status' and start them with cello_use_agent.`;
79
- }
80
- finally {
81
- result.client.close();
82
- }
83
- // DOD-M9C-SCREENINSTALL-1: the screener line, EVERY login until it is installed.
84
- //
85
- // It repeats deliberately. An operator who skipped it once has postponed, not decided, and a
86
- // one-time notice at the end of a busy install is indistinguishable from no notice at all. It
87
- // rides on `guidance` (stderr), so a script parsing login's stdout is unaffected.
88
- const screener = await screenerLoginLine();
89
- return { exitCode: 0, output: `${head}\n${summary}`, ...(screener ? { guidance: screener } : {}) };
90
- }
91
- catch (err) {
92
- const message = err instanceof Error ? err.message : String(err);
93
- return { exitCode: 1, output: `Failed to start daemon: ${message}` };
94
- }
95
- }
96
- /**
97
- * One line when the classifier is not installed-and-verified, empty when it is.
98
- *
99
- * Never throws and never blocks login: a screener check that could break sign-in would be a worse
100
- * defect than the one it reports.
101
- */
102
- export async function screenerLoginLine(stateImpl) {
103
- try {
104
- const status = stateImpl
105
- ? await stateImpl()
106
- : await screenerState({ dir: screenerModelDir(), runtimePresent: await runtimeAvailable() });
107
- if (status.state === "ready")
108
- return "";
109
- if (status.state === "broken") {
110
- return `Screening: the classifier is BROKEN: ${status.problem ?? "unknown fault"}. Fix it with: cello screener install --repair`;
111
- }
112
- // The size comes from the command that quotes it, so the two cannot drift apart.
113
- return `Screening: 1 of 2 layers active. Install the classifier (~${DOWNLOAD_MB} MB): cello screener install`;
114
- }
115
- catch (err) {
116
- // Silence here means login stops nagging because the CHECK broke, which is indistinguishable
117
- // from the classifier being installed. Say what happened instead; never throw, because a
118
- // screener check that breaks sign-in is worse than the gap it reports.
119
- return `Screening: state UNKNOWN — the screener check failed: ${err instanceof Error ? err.message : String(err)}`;
120
- }
121
- }
122
- /**
123
- * M8C-LOGINSTART-1: compose the operator-facing auto-start summary — every failed agent enumerated
124
- * by name + reason. Pure + exported so the enumeration string is directly testable.
125
- */
126
- export function formatLoginSummary(result) {
127
- const parts = [];
128
- if (result.started.length > 0)
129
- parts.push(`Started ${result.started.length} agent(s): ${result.started.join(", ")}.`);
130
- if (result.failed.length > 0) {
131
- parts.push(`${result.failed.length} agent(s) failed to start: ${result.failed.map((f) => `${f.name} (${f.reason})`).join(", ")}. ` +
132
- "Run 'cello status' to check; a failed agent stays offline and can be retried with cello_use_agent.");
133
- }
134
- // The empty case is a NEW OPERATOR'S FIRST SCREEN far more often than it is a veteran's, and
135
- // "No registered agents to start." told them nothing: not the next command, not that a token is
136
- // needed, and not that tokens only exist for someone a cohort has admitted. That last omission
137
- // is the expensive one — it sent people to the Telegram operations agent to ask for something it
138
- // could not give them, with nothing in the product having warned them. See onboarding-guidance.ts.
139
- if (result.started.length === 0 && result.failed.length === 0)
140
- parts.push(noAgentsGuidance());
141
- return parts.join("\n");
142
- }
143
- // DOD-LOGOUT-WAIT-1: how long logout waits for the daemon to actually die after acknowledging
144
- // the shutdown request, and how often it re-checks. A daemon closing SQLCipher + libp2p takes
145
- // real time; 5 s is generous headroom, and a daemon still alive past it is genuinely stuck.
146
- const LOGOUT_WAIT_TIMEOUT_MS = 5_000;
147
- const LOGOUT_WAIT_POLL_MS = 50;
148
- /**
149
- * DOD-LOGOUT-WAIT-1: is the daemon actually GONE — judged by the same evidence `connectOrStart`
150
- * consults, so a completed logout GUARANTEES the next login starts fresh.
151
- *
152
- * Two facts, both required, neither of them a file:
153
- * 1. nothing answers on the socket, and
154
- * 2. no process holds the singleton lock.
155
- *
156
- * (1) alone is not enough: a daemon wedged mid-shutdown may have closed its IPC server while still
157
- * holding the DB. (2) alone is not enough either, because a pre-singleton-lock daemon holds no
158
- * singleton lock at all — for those, "free" says nothing about whether it is alive, and only the
159
- * socket can tell us. Together they are exactly the conditions under which the next daemon can
160
- * safely start.
161
- *
162
- * Neither `isProcessAlive(lock.pid)` nor the lock FILE may be SUBSTITUTED for these two facts: a pid
163
- * can be reused, and a lock file can be deleted by anyone.
164
- *
165
- * DOD-LOGOUT-EXIT-1 adds a THIRD, and the distinction from the paragraph above is the whole point:
166
- * `livePid` is an ADDITIONAL requirement, never a substitute. Both facts above are released inside
167
- * `stop()`'s `finally`, BEFORE the daemon actually ends — that gap is precisely how logout came to
168
- * report "Daemon stopped." over a process still holding a directory connection. So when we know a
169
- * pid that was alive at the moment we asked it to stop, it must also be gone.
170
- *
171
- * The pid-reuse objection does not apply to this use: the window is the 5 s logout wait, and being
172
- * wrong here fails SAFE — an unrelated process inheriting the pid makes logout report "did not
173
- * complete" (exit 1, loud, actionable) rather than claim a stop that did not happen.
174
- */
175
- async function daemonGone(celloDir, socketPath, logger, livePid) {
176
- // Checked FIRST — it is the cheapest, and it is the only one of the three that is about the
177
- // process rather than about a handle the process has already let go of.
178
- if (livePid !== undefined && isProcessAlive(livePid))
179
- return false;
180
- // connectToDaemon has no connect timeout of its own — bound the probe so a pathological hang
181
- // cannot stall the poll loop past its deadline. On the expected path the socket is already gone,
182
- // so this fails fast (ENOENT/ECONNREFUSED).
183
- //
184
- // The race LOSER must still be closed. If the timeout wins, the connect promise is abandoned — but
185
- // it can still resolve afterwards into a live IpcClient that nobody holds and nobody closes. This
186
- // runs in a POLL LOOP inside one process, against the daemon's bounded IPC_CONNECTION_LIMIT, so
187
- // those accumulate. Of every connect in this file it is the only one that can genuinely exhaust the
188
- // pool — everywhere else the process exits and the OS reclaims the fd. So: keep the promise, and
189
- // close whatever it eventually yields.
190
- const connecting = connectToDaemon(socketPath);
191
- connecting.then((late) => { try {
192
- late.close();
193
- }
194
- catch { /* already closed by the winner below */ } }, () => { });
195
- try {
196
- const probe = await Promise.race([
197
- connecting,
198
- new Promise((_, reject) => setTimeout(() => reject(new Error("probe_timeout")), 500)),
199
- ]);
200
- probe.close();
201
- return false; // something still answers — the daemon is up
202
- }
203
- catch {
204
- // Nothing answers. Now make sure the process is really gone and not merely deaf: a daemon that
205
- // still holds the kernel lock is still alive, and the next login would refuse to start beside it.
206
- return probeSingletonLock(celloDir, logger) === "free";
207
- }
208
- }
209
- export async function logout(celloDir, onProgress,
210
- // Injectable bounds so the timeout path is testable without a 5 s wait. Production (the bin)
211
- // passes nothing and gets the named defaults.
212
- waitOpts) {
213
- const lockFilePath = join(celloDir, "daemon.lock");
214
- const noopLogger = { debug() { }, info() { }, warn() { }, error() { } };
215
- const timeoutMs = waitOpts?.timeoutMs ?? LOGOUT_WAIT_TIMEOUT_MS;
216
- const pollMs = waitOpts?.pollMs ?? LOGOUT_WAIT_POLL_MS;
217
- // The socket path is DETERMINISTIC. `daemon.lock`'s copy of it is metadata, and metadata is not
218
- // to be trusted here.
219
- const socketPath = join(celloDir, "daemon.sock");
220
- const lock = await readLock(lockFilePath);
221
- // DOD-SINGLE-DAEMON-1 (AC4) — the lock file does not get to decide whether a daemon exists.
222
- //
223
- // Never short-circuit on `if (!lock) return "No daemon running."`. That is a KILL SWITCH REPORTING
224
- // SUCCESS WITHOUT DOING ANYTHING, and the state that triggers it is real: an exiting orphan unlinks
225
- // a HEALTHY daemon's lock (so does `rm ~/.cello/daemon.lock`). The operator then runs `cello logout`
226
- // to stop their agent, is told it was never running, and walks away — while the daemon is still
227
- // online, still on the directory, still extending the hash chain. A kill switch may not lie.
228
- //
229
- // So we ask the daemon itself. If something ANSWERS on the socket, a daemon is running — that is
230
- // the strongest evidence available, it needs no file, and it holds even for a pre-singleton-lock
231
- // daemon, which holds no singleton lock at all.
232
- let client;
233
- try {
234
- client = await connectToDaemon(socketPath);
235
- }
236
- catch (err) {
237
- // Nothing answered. Now the kernel decides — not the pid in the JSON, which may be dead, or
238
- // REUSED by an unrelated process (in which case `isProcessAlive` says "alive" forever).
239
- const probe = probeSingletonLock(celloDir, noopLogger);
240
- if (probe === "held") {
241
- // A daemon holds the lock but will not talk to us. We have NOT stopped it, and must not imply
242
- // otherwise. Point at the authoritative holder rather than a pid we did not verify.
243
- const named = lock ? ` (daemon.lock names pid ${lock.pid}, which may be stale)` : "";
244
- return {
245
- exitCode: 1,
246
- output: `A daemon is running${named} and holds the singleton lock, but is not answering on ` +
247
- `${socketPath} (${err instanceof Error ? err.message : String(err)}). It has NOT been ` +
248
- `stopped. Find the process that holds the lock with \`lsof ${join(celloDir, SINGLETON_LOCK_FILENAME)}\`, ` +
249
- "stop it, then re-run 'cello logout' to clean up.",
250
- };
251
- }
252
- if (probe === "unknown") {
253
- // We could not ask. Reporting "no daemon running" on a guess is the whole failure mode.
254
- return {
255
- exitCode: 1,
256
- output: `Could not determine whether a daemon is running: the singleton lock at ` +
257
- `${join(celloDir, SINGLETON_LOCK_FILENAME)} could not be checked. Refusing to report a ` +
258
- "daemon stopped without proof.",
259
- };
260
- }
261
- // probe === "free": nothing holds the lock and nothing answers. There is no daemon.
262
- if (!lock) {
263
- return { exitCode: 0, output: "No daemon running." };
264
- }
265
- // DOD-LOGOUT-WAIT-1: only CLAIM the removal when it actually happened — removeLock swallows
266
- // non-ENOENT errors into a (here discarded) warn, and printing "Removed" for a lock still on
267
- // disk would be this unit's own lie in miniature.
268
- const removed = await removeLock(lockFilePath, noopLogger)
269
- .then(async () => (await readLock(lockFilePath)) === null)
270
- .catch(() => false);
271
- return {
272
- exitCode: 0,
273
- output: removed
274
- ? "No daemon running. (Removed a stale daemon.lock — no daemon holds the singleton lock.)"
275
- : `No daemon running. (A stale daemon.lock remains at ${lockFilePath} and could not be removed — check its permissions.)`,
276
- };
277
- }
278
- try {
279
- await client.send("shutdown");
280
- }
281
- catch (err) {
282
- const message = err instanceof Error ? err.message : String(err);
283
- return { exitCode: 1, output: `Failed to stop daemon: ${message}` };
284
- }
285
- finally {
286
- // In the `finally`, not after the send: a throw from `shutdown` must not leak the connection.
287
- client.close();
288
- }
289
- // The request is in — tell the operator NOW, then report "stopped" only when it is true.
290
- onProgress?.("Shutting down the daemon…");
291
- // DOD-LOGOUT-WAIT-1: do NOT return the instant the shutdown request is WRITTEN. That leaves a
292
- // window where `cello logout && cello login` finds the daemon mid-death — connectOrStart sees a
293
- // live pid + connectable socket and prints "Daemon already running.", leaving the operator logged
294
- // out while being told otherwise. Wait until the daemon is genuinely gone; "Daemon stopped." for a
295
- // daemon that is still running is the same class of lie as a success log on a failed send
296
- // (DOD-SENDRAW-1).
297
- // DOD-LOGOUT-EXIT-1: the pid to hold the shutdown to. Taken from the lock read BEFORE the
298
- // shutdown was sent, and only trusted if it was actually alive then — a stale lock naming a dead
299
- // (or never-ours) pid must not make every logout wait out the full timeout. `undefined` here
300
- // means we have no process-level evidence to demand, and the two handle facts stand alone, which
301
- // is the pre-singleton-lock daemon's case.
302
- //
303
- // `lock.pid !== process.pid` is NOT a test accommodation: an IN-PROCESS daemon (an embedder, or
304
- // vitest) writes OUR pid into the lock, and requiring it to exit would mean requiring the caller
305
- // to exit before its own logout returns — never satisfiable, so every such logout would burn the
306
- // full timeout and then report a failure that did not happen. For that daemon "the process
307
- // ended" is not the right question; the handles are all there is, which is the pre-existing
308
- // contract. The defect this unit fixes is only reachable when the daemon is a SEPARATE process.
309
- const pidToOutlive = lock && lock.pid !== process.pid && isProcessAlive(lock.pid) ? lock.pid : undefined;
310
- const deadline = Date.now() + timeoutMs;
311
- while (Date.now() < deadline) {
312
- if (await daemonGone(celloDir, socketPath, noopLogger, pidToOutlive)) {
313
- return { exitCode: 0, output: "Daemon stopped." };
314
- }
315
- await new Promise((r) => setTimeout(r, pollMs));
316
- }
317
- const who = lock ? `pid ${lock.pid}, ` : "";
318
- return {
319
- exitCode: 1,
320
- output: `Daemon shutdown did not complete within ${timeoutMs / 1000}s ` +
321
- `(${who}socket ${socketPath}). The daemon acknowledged the request but is ` +
322
- `still running — it may be stuck closing sessions or its database. Check 'cello status'; ` +
323
- `if it never exits, find it with \`lsof ${join(celloDir, SINGLETON_LOCK_FILENAME)}\`, stop it, ` +
324
- "and re-run 'cello logout' to clean up.",
325
- };
326
- }
327
- /**
328
- * register(celloDir, agent, preAuthToken, phoneStub):
329
- * - Read lock file to find socket path (daemon must be running)
330
- * - Connect to daemon, send 'cello_register' with { agent, preAuthToken, phoneStub }
331
- * - The daemon runs ML-DSA keygen → FROST DKG → register_success and persists
332
- * the agent's key material + registration state + agent→user link.
333
- * - Print structured JSON; exit 0 on success, 1 on failure.
334
- */
335
- /**
336
- * Does this look like a pre-auth capability rather than a mis-paste?
337
- *
338
- * A capability (M8B-PREAUTH-CAP) is base64url JSON carrying a signed authorization. This decodes
339
- * far enough to tell it apart from someone pasting the literal words "CELLO_PREAUTH_TOKEN", and no
340
- * further: the directory verifies the signature, the issuer and the window. Anything stricter here
341
- * would let a client-side "malformed" strand a capability the consortium would have accepted.
342
- */
343
- function hasCapabilityShape(value) {
344
- try {
345
- const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
346
- if (typeof parsed !== "object" || parsed === null)
347
- return false;
348
- const c = parsed;
349
- return typeof c["sig"] === "string" && typeof c["nonce"] === "string" && typeof c["expires_at"] === "string";
350
- }
351
- catch {
352
- return false;
353
- }
354
- }
355
- export async function register(celloDir, agent, preAuthToken, phoneStub = "") {
356
- // M8C-ONBOARD-ERRORS-1 (R3/R4): specific, actionable errors on the core onboarding path — never a
357
- // bare Usage dump, never a pointless DKG round-trip to a generic dkg_failed for an obviously
358
- // malformed token. Client-side because a typo'd token and a missing one are knowable without the
359
- // directory. (Unknown-agent stays the daemon's job — it already returns a good agent_not_found.)
360
- // DOD-M15-CLIJSON-1: the FAILURE branches emit JSON too, with the prose intact in `guidance` on
361
- // stderr. These four are the ones a person scripting onboarding actually hits — an unset $TOKEN,
362
- // a daemon that is not up — and prose on stdout breaks their pipe with a byte-offset parse error
363
- // that hides the real problem. Same defect as the success path, one branch over.
364
- const fail = (reason, guidance) => ({
365
- exitCode: 1,
366
- output: JSON.stringify({ ok: false, reason, guidance }, null, 2),
367
- guidance,
368
- });
369
- if (!agent) {
370
- return fail("missing_agent_name", "You didn't name an agent to register. Usage: cello register-agent <agent> <pre-auth-token>. See your agents with 'cello status'.");
371
- }
372
- if (!preAuthToken) {
373
- return fail("missing_preauth_token", `You're missing the pre-auth token. Get a single-use token from the CELLO Operations Agent on Telegram, then run:\n cello register-agent ${agent} <token>\nor set it in the environment:\n CELLO_PREAUTH_TOKEN=<token> cello register-agent ${agent}\nThe token is single-use and expires in 24 hours.`);
374
- }
375
- // Client-side gate on the STABLE brand prefix only (real tokens are "CELLO-" + 33 base58 chars).
376
- // Checking just the prefix catches the common typo (pasting the literal words "CELLO_PREAUTH_TOKEN")
377
- // without hard-coding the exact length/alphabet — the directory stays the authority on the full format,
378
- // so a future format bump can't strand a valid token behind a client-side "malformed". The "DEV-"
379
- // sentinel is ALSO allowed: it is the dev/local pre-auth prefix that DevTokenValidator accepts
380
- // (CELLO_ENV=local), and it is unmistakably intentional, not a paste error. A wrong token of either
381
- // prefix reaches the daemon and is rejected there with a structured reason (the local DevTokenValidator
382
- // rejects non-DEV-, the prod PgTokenValidator rejects non-CELLO-). Without this, local CLI registration
383
- // is impossible — the CLI rejects the very DEV- tokens the local validator requires (it broke the whole
384
- // spine suite).
385
- // A pre-auth CAPABILITY (M8B-PREAUTH-CAP) is also valid here and has neither prefix: it is
386
- // base64url JSON, and preauth-capability.ts specifies it is "carried in the existing round-1
387
- // preAuthToken string field and pasted into `cello register`". Gating on the two legacy prefixes
388
- // alone rejected the very artifact the capability design says to paste — a capability could be
389
- // minted, signed and accepted by every directory, and never got past the client.
390
- //
391
- // Shape only, and deliberately NOT by importing @cello-protocol/crypto: the CLI does not depend
392
- // on it, and adding a package to the operator's install for a paste-error guard is the wrong
393
- // trade. The signature, the issuer and the validity window stay the directory's to verify — this
394
- // must never become a second authority on whether a capability is valid.
395
- const looksLikeCapability = hasCapabilityShape(preAuthToken);
396
- if (!looksLikeCapability && !preAuthToken.startsWith("CELLO-") && !preAuthToken.startsWith("DEV-")) {
397
- return fail("malformed_preauth_token", "That doesn't look like a pre-auth token or capability — tokens start with 'CELLO-' followed by 33 characters, and a capability is a long base64url blob. (Did you paste the words 'CELLO_PREAUTH_TOKEN' instead of the value itself?) Get one from the CELLO Operations Agent on Telegram, then retry.");
398
- }
399
- const lockFilePath = join(celloDir, "daemon.lock");
400
- const lock = await readLock(lockFilePath);
401
- if (!lock) {
402
- return fail("daemon_not_running", "No daemon running. Run 'cello login' first, then retry registration.");
403
- }
404
- try {
405
- const result = (await withIpc(lock.socketPath, (client) => client.send("cello_register", { agent, preAuthToken, phoneStub })));
406
- if (!result.ok) {
407
- return {
408
- exitCode: 1,
409
- output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2),
410
- };
411
- }
412
- return {
413
- exitCode: 0,
414
- output: JSON.stringify({ ok: true, agent_id: result.agent_id, primary_pubkey: result.primary_pubkey }, null, 2),
415
- // M8C-ONBOARD-NEXTSTEP-1: every command output carries the next step + state legibility.
416
- // Broken onto multiple lines — a dense one-liner buries the three status cues.
417
- //
418
- // DOD-M15-CLIJSON-1: on STDERR, not appended to the JSON above. A human in a terminal sees
419
- // both streams and loses nothing; a script gets parseable output. This text used to make
420
- // `register-agent` unparseable on its SUCCESS path.
421
- guidance: `Next: run cello status to confirm '${agent}' is registered.\n` +
422
- ` • it's normal for this to take a minute or two while registration settles.\n` +
423
- ` • ready = the agent shows state 'online' and directory_signaling 'connected'.\n` +
424
- ` • if it stays offline, run cello logout then cello login.`,
425
- };
426
- }
427
- catch (err) {
428
- const message = err instanceof Error ? err.message : String(err);
429
- return fail("register_failed", `Failed to register: ${message}`);
430
- }
431
- }
432
- /**
433
- * createAgent(celloDir, name):
434
- * - Connect to the daemon, send 'cello_create_agent' with { name }.
435
- * - The daemon generates a fresh K_local seed, writes it as an `agents` row in the encrypted DB
436
- * (PERSIST-002 — no key file), and wires the agent in so it can be registered immediately.
437
- */
438
- export async function createAgent(celloDir, name) {
439
- if (!name) {
440
- return { exitCode: 1, output: "Usage: cello create-agent <name> — creates a new local agent identity." };
441
- }
442
- const lockFilePath = join(celloDir, "daemon.lock");
443
- const lock = await readLock(lockFilePath);
444
- if (!lock) {
445
- return { exitCode: 1, output: "No daemon running. Run 'cello login' first, then retry create-agent." };
446
- }
447
- try {
448
- const result = (await withIpc(lock.socketPath, (client) => client.send("cello_create_agent", { name })));
449
- if (!result.ok) {
450
- return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2) };
451
- }
452
- return {
453
- exitCode: 0,
454
- output: JSON.stringify({ ok: true, name: result.name, pubkey: result.pubkey, agentId: result.agentId }, null, 2),
455
- };
456
- }
457
- catch (err) {
458
- const message = err instanceof Error ? err.message : String(err);
459
- return { exitCode: 1, output: `Failed to create agent: ${message}` };
460
- }
461
- }
462
- /**
463
- * refreshShares(celloDir, name): M8B DOD-REFRESH-1.
464
- * - Connect to the daemon, send 'cello_refresh_shares' with { agent }.
465
- * - The daemon runs a proactive share refresh across the consortium: every shareholder rotates its
466
- * share to a new epoch, the group public key is unchanged, and old-epoch shares no longer sign.
467
- */
468
- export async function refreshShares(celloDir, name) {
469
- if (!name) {
470
- return { exitCode: 1, output: "Usage: cello refresh <name> — proactively refresh the agent's threshold shares (new epoch)." };
471
- }
472
- const lockFilePath = join(celloDir, "daemon.lock");
473
- const lock = await readLock(lockFilePath);
474
- if (!lock) {
475
- return { exitCode: 1, output: "No daemon running. Run 'cello login' first, then retry refresh." };
476
- }
477
- try {
478
- const result = (await withIpc(lock.socketPath, (client) => client.send("cello_refresh_shares", { agent: name })));
479
- if (!result.ok) {
480
- return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2) };
481
- }
482
- return {
483
- exitCode: 0,
484
- output: JSON.stringify({ ok: true, epoch: result.epoch, primary_pubkey: result.primary_pubkey, verifying_shares_digest: result.verifying_shares_digest }, null, 2),
485
- };
486
- }
487
- catch (err) {
488
- const message = err instanceof Error ? err.message : String(err);
489
- return { exitCode: 1, output: `Failed to refresh shares: ${message}` };
490
- }
491
- }
492
- /**
493
- * relayReceipts(celloDir, name): M8B DOD-RELAYSIG-1.
494
- * - Connect to the daemon, send 'cello_get_relay_receipts' with { agent }.
495
- * - Returns the agent's durably-stored, signature-verified relay ordering-record receipts.
496
- */
497
- export async function relayReceipts(celloDir, name) {
498
- if (!name) {
499
- return { exitCode: 1, output: "Usage: cello relay-receipts <name> — ADVANCED/DEBUG: the per-message proofs a relay signed when it "
500
- + "delivered for this agent. For a session's notarized seal, use 'cello sealed-receipt <session-id>'." };
501
- }
502
- const lockFilePath = join(celloDir, "daemon.lock");
503
- const lock = await readLock(lockFilePath);
504
- if (!lock) {
505
- return { exitCode: 1, output: "No daemon running. Run 'cello login' first, then retry receipts." };
506
- }
507
- try {
508
- const result = (await withIpc(lock.socketPath, (client) => client.send("cello_get_relay_receipts", { agent: name })));
509
- if (!result.ok) {
510
- return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2) };
511
- }
512
- return { exitCode: 0, output: JSON.stringify({ ok: true, receipts: result.receipts ?? [] }, null, 2) };
513
- }
514
- catch (err) {
515
- const message = err instanceof Error ? err.message : String(err);
516
- return { exitCode: 1, output: `Failed to get relay receipts: ${message}` };
517
- }
518
- }
519
- /**
520
- * removeAgent(celloDir, name) — CELLO-M7-REMOVE-001 (DOD-REMOVE-1):
521
- * - Connect to the daemon, send 'cello_remove_agent' with { name }.
522
- * - The daemon RETIRES the agent (state=retired; row, keys, and history KEPT for accountability) and
523
- * FREES the human name for reuse. One-way.
524
- */
525
- export async function removeAgent(celloDir, name) {
526
- if (!name) {
527
- return { exitCode: 1, output: "Usage: cello remove-agent <name> — retires a local agent (one-way) and frees its name." };
528
- }
529
- const lockFilePath = join(celloDir, "daemon.lock");
530
- const lock = await readLock(lockFilePath);
531
- if (!lock) {
532
- return { exitCode: 1, output: "No daemon running. Run 'cello login' first, then retry remove-agent." };
533
- }
534
- try {
535
- const result = (await withIpc(lock.socketPath, (client) => client.send("cello_remove_agent", { name })));
536
- if (!result.ok) {
537
- return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2) };
538
- }
539
- return {
540
- exitCode: 0,
541
- output: JSON.stringify({
542
- ok: true,
543
- name: result.name,
544
- agentId: result.agentId,
545
- oneWay: result.oneWay,
546
- // DOD-REMOVE-2: whether the signed revocation was recorded at the directory (recorded /
547
- // deferred — directory unreachable / skipped — never registered). The daemon's guidance
548
- // carries the actionable detail.
549
- directoryRevocation: result.directoryRevocation,
550
- message: result.guidance ?? "Agent retired (one-way). Its identity and history are kept; the name is free to reuse.",
551
- }, null, 2),
552
- };
553
- }
554
- catch (err) {
555
- const message = err instanceof Error ? err.message : String(err);
556
- return { exitCode: 1, output: `Failed to remove agent: ${message}` };
557
- }
558
- }
559
- export async function status(celloDir) {
560
- const lockFilePath = join(celloDir, "daemon.lock");
561
- const lock = await readLock(lockFilePath);
562
- // DOD-LOGOUT-EXIT-1 (AC3): `stopped` used to come from a single FILE STAT — no lock file, no
563
- // daemon. That is exactly the reasoning `logout` refuses by name above ("the lock file does not
564
- // get to decide whether a daemon exists"), and it fails the same way: an exiting orphan unlinks a
565
- // HEALTHY daemon's lock, and so does `rm ~/.cello/daemon.lock`. The operator then reads `stopped`
566
- // while the daemon is online, on the directory, and extending the hash chain.
567
- //
568
- // So ask the daemon itself first, at the DETERMINISTIC socket path — daemon.lock's copy is
569
- // metadata, and here there may be no lock file to read it from anyway.
570
- const socketPath = lock?.socketPath ?? join(celloDir, "daemon.sock");
571
- try {
572
- // Bounded, for the same reason `daemonGone` bounds its probe: connectToDaemon has no connect
573
- // timeout of its own, so a half-dead daemon holding a listening socket it will never answer on
574
- // would hang `cello status` forever. That is not a hypothetical here — it is a shape of the
575
- // very broken-shutdown state this function now has to report on.
576
- const result = (await Promise.race([
577
- withIpc(socketPath, (client) => client.send("status")),
578
- new Promise((_, reject) => setTimeout(() => reject(new Error("status probe timed out after 3000ms")), 3_000)),
579
- ]));
580
- return { exitCode: 0, output: JSON.stringify(result, null, 2) };
581
- }
582
- catch (err) {
583
- const message = err instanceof Error ? err.message : String(err);
584
- // Nothing answered. Now the kernel decides whether a daemon process exists — not a file.
585
- const probe = probeSingletonLock(celloDir, { debug() { }, info() { }, warn() { }, error() { } });
586
- if (probe === "held") {
587
- // A daemon process exists but has released (or never opened) its socket. That is a BROKEN
588
- // SHUTDOWN, not a clean stop, and it is the state DOD-LOGOUT-EXIT-1 was filed for: the
589
- // process is still alive and may still be on the network. Reporting `stopped` here is the
590
- // lie. Name the state and point at the holder.
591
- return {
592
- exitCode: 1,
593
- output: JSON.stringify({
594
- daemon: "broken_shutdown",
595
- detail: "A daemon process holds the singleton lock but is not answering on its socket. It has " +
596
- "NOT cleanly stopped and may still be connected to a directory. Find it with " +
597
- `\`lsof ${join(celloDir, SINGLETON_LOCK_FILENAME)}\` and stop it, then run 'cello logout' to clean up.`,
598
- socketPath,
599
- error: message,
600
- }, null, 2),
601
- };
602
- }
603
- if (probe === "unknown") {
604
- // We could not ask. Reporting either "running" or "stopped" on a guess is the failure mode.
605
- return {
606
- exitCode: 1,
607
- output: JSON.stringify({
608
- daemon: "unknown",
609
- detail: `The singleton lock at ${join(celloDir, SINGLETON_LOCK_FILENAME)} could not be checked, ` +
610
- "so whether a daemon is running cannot be determined.",
611
- error: message,
612
- }, null, 2),
613
- };
614
- }
615
- // probe === "free": nothing holds the lock and nothing answers, so there is no daemon OF THIS
616
- // VERSION. The claim is deliberately narrower than "no daemon": a pre-singleton-lock daemon
617
- // holds no singleton lock at all, so `free` says nothing about it and only the socket could
618
- // have told us — which is why the socket is tried first, above. That residue is bounded by the
619
- // upgrade and cannot be closed from this side.
620
- // A lock file that is still present is stale metadata, not evidence of a process.
621
- if (!lock) {
622
- return { exitCode: 1, output: JSON.stringify({ daemon: "stopped" }, null, 2) };
623
- }
624
- return {
625
- exitCode: 1,
626
- output: JSON.stringify({ daemon: "unreachable", error: message }, null, 2),
627
- };
628
- }
629
- }
630
- /**
631
- * `cello sessions [--open|--closed|--failed|--all] [--limit N]` — the full, queryable session
632
- * history (the discovery surface `cello status` deliberately does NOT dump). Defaults to OPEN
633
- * (live + resumable) so a long-lived agent's failed/closed history doesn't flood it, and caps the
634
- * count at the daemon's default limit. Output reports `totalMatched` so the operator can tell when
635
- * results were truncated.
636
- */
637
- export async function sessions(celloDir, opts = {}) {
638
- const lockFilePath = join(celloDir, "daemon.lock");
639
- const lock = await readLock(lockFilePath);
640
- if (!lock) {
641
- return { exitCode: 1, output: JSON.stringify({ daemon: "stopped" }, null, 2) };
642
- }
643
- try {
644
- const params = {};
645
- if (opts.filter)
646
- params.filter = opts.filter;
647
- if (opts.limit !== undefined)
648
- params.limit = opts.limit;
649
- const result = (await withIpc(lock.socketPath, (client) => client.send("list_sessions", params)));
650
- return { exitCode: 0, output: JSON.stringify(result, null, 2) };
651
- }
652
- catch (err) {
653
- const message = err instanceof Error ? err.message : String(err);
654
- return {
655
- exitCode: 1,
656
- output: JSON.stringify({ daemon: "unreachable", error: message }, null, 2),
657
- };
658
- }
659
- }
660
- /** M8C-TGDOOR-1: `cello telegram set-token <bot_token> <allowlisted_chat_id>` — persists the
661
- * daemon-wide bot credentials (narrow, dedicated surface; NOT folded into the parked `cello
662
- * config`, since a bot token has no sensible default and can't wait for M9-CFG-001). */
663
- /**
664
- * `cello attestations` — YOUR words about ANOTHER agent, and what became of them.
665
- *
666
- * SEPARATE FROM `trust-signals` ON PURPOSE. On the wire an attestation IS a trust signal, so folding
667
- * the two together is the obvious move — and it is the wrong one. A trust signal is the NETWORK
668
- * verifying an attribute of yours; an attestation is a PERSON vouching for a PERSON. That second
669
- * thing is the primitive collaboration rests on, and filing it as a subcommand of a wallet listing
670
- * makes the most important capability the hardest one to find.
671
- *
672
- * Subcommands:
673
- * issue <pubkey> <text…> — attest to something you have seen them do
674
- * issued — what happened to the ones you wrote
675
- *
676
- * The receiving direction — what others wrote about YOU — is `cello attestation-consent`.
677
- */
678
- export async function attestations(celloDir, sub, args) {
679
- const lockFilePath = join(celloDir, "daemon.lock");
680
- const lock = await readLock(lockFilePath);
681
- if (!lock) {
682
- return { exitCode: 1, output: "No daemon running. Run 'cello login' first." };
683
- }
684
- // M10B / `M10B-D25r2` — "what happened to what I submitted?". A NETWORK call, so it is its own verb
685
- // rather than folded into `list`: listing what you hold must keep working when the directory is
686
- // unreachable, and a remote failure must not break a local read.
687
- if (sub === "issued") {
688
- try {
689
- // TWO CALLS. `wallet_list_issued` is the local record of what was submitted; `wallet_fetch_results`
690
- // is the network sweep for outcomes. Showing only the second made a submission still awaiting the
691
- // subject INVISIBLE — three in flight printed as "no outcomes waiting", which reads as "nothing
692
- // was ever sent" rather than "nobody has answered yet".
693
- const [issued, res] = (await withIpc(lock.socketPath, (client) => Promise.all([client.send("wallet_list_issued"), client.send("wallet_fetch_results")])));
694
- if (!issued.ok) {
695
- return { exitCode: 1, output: `${issued.reason ?? "failed"}\n${issued.guidance ?? ""}`.trim() };
696
- }
697
- if (!res.ok) {
698
- return { exitCode: 1, output: `${res.reason ?? "failed"}\n${res.guidance ?? ""}`.trim() };
699
- }
700
- const byId = new Map((res.results ?? []).map((r) => [r.submission_id, r]));
701
- const rows = (issued.issued ?? []).map((s) => ({
702
- ...(byId.get(s.submission_id) ?? { outcome: "pending", reason: null, message: null }),
703
- submission_id: s.submission_id,
704
- }));
705
- // Anything that came back for a submission this wallet has no local record of still gets shown —
706
- // dropping it would hide a real outcome behind a local bookkeeping gap.
707
- for (const r of res.results ?? [])
708
- if (!rows.some((x) => x.submission_id === r.submission_id))
709
- rows.push(r);
710
- // A node that did not answer is stated, never folded into the list — an incomplete sweep must not
711
- // be readable as "nothing came back".
712
- const partial = (res.unreachable_nodes ?? []).length > 0
713
- ? `\n\n ⚠ ${res.unreachable_nodes.length} node(s) did not answer (${res.unreachable_nodes.join(", ")}).\n This list may be incomplete — an outcome recorded there is not shown yet.`
714
- : "";
715
- /**
716
- * DOD-M15-ENDORSE-RETRY-1 — THE ONES THAT NEVER REACHED A NODE, printed in their own block.
717
- *
718
- * Not merged into the table above: those rows carry an OUTCOME from a directory, and these
719
- * have none because no directory has ever seen them. Printing them as `pending` would say the
720
- * subject has not answered yet, about a submission nobody was ever asked about.
721
- *
722
- * Printed BEFORE the empty check too, or an operator whose node was down for the whole
723
- * session reads "You have submitted no endorsements" while the daemon is holding three.
724
- */
725
- const flight = issued.in_flight ?? [];
726
- const flightBlock = flight.length === 0 ? "" :
727
- "\n\n Not yet at any directory node (held in memory — a daemon restart loses these):\n" +
728
- flight.map((f) => {
729
- const state = f.delivery === "gave_up" ? `gave up (${f.gave_up_because ?? "unknown"})` : "retrying";
730
- return ` ${state.padEnd(30)} ${f.submission_id.slice(0, 12)}… ${f.op} last: ${f.last_reason}\n ${f.guidance}`;
731
- }).join("\n");
732
- if (rows.length === 0) {
733
- return {
734
- exitCode: 0,
735
- output: (flight.length === 0
736
- ? "You have submitted no endorsements. Results are held until you collect them, so nothing has been missed."
737
- : "No submission has reached a directory node yet.") + flightBlock + partial,
738
- };
739
- }
740
- const lines = rows.map((r) => {
741
- const head = ` ${r.outcome.padEnd(10)} ${r.submission_id.slice(0, 12)}… ${r.reason ?? "—"}`;
742
- // THE MESSAGE ON ITS OWN LINE, quoted and attributed. It is the SUBJECT'S words about the
743
- // operator's claim, and running it into the status columns would read as CELLO's verdict.
744
- return r.message ? `${head}\n they said: "${r.message}"` : head;
745
- });
746
- const header = ` ${"outcome".padEnd(10)} submission reason`;
747
- return {
748
- exitCode: 0,
749
- output: [header, " " + "─".repeat(60), ...lines].join("\n") +
750
- flightBlock +
751
- "\n\n A refusal is the subject declining to stand behind your claim — not a fault in it.\n" +
752
- " Re-submitting a corrected version is the intended next step.\n" +
753
- " 'pending' means the subject has not answered yet — the submission is not lost." + partial,
754
- };
755
- }
756
- catch (err) {
757
- return { exitCode: 1, output: `Could not reach the daemon: ${err instanceof Error ? err.message : String(err)}` };
758
- }
759
- }
760
- if (sub === "issue") {
761
- const [subject, ...rest] = args;
762
- // `--` ENDS FLAG PARSING. Free prose is the whole point of this verb, and prose contains things
763
- // that look like flags: "cut p99 -30ms" is rejected as an unknown flag, and "-h" anywhere prints
764
- // help instead of issuing. The operator needs a way to say "the rest is text", and `--` is the
765
- // convention every shell user already knows.
766
- const body = (rest[0] === "--" ? rest.slice(1) : rest).join(" ");
767
- if (!subject || body.length === 0) {
768
- return {
769
- exitCode: 1,
770
- output: "Usage: cello attestations issue <subject-pubkey> <what you are endorsing them for…>\n" +
771
- "\n" +
772
- "The text: MAXIMUM 500 CHARACTERS — a testimonial, not a document. Line breaks are fine;\n" +
773
- "no other control characters, and no < or >.\n" +
774
- "\n" +
775
- "If your text contains something that looks like a flag (a leading '-', or '-h'), put `--`\n" +
776
- "before it so it is read as text:\n" +
777
- " cello attestations issue <pubkey> -- cut p99 by -30ms on the auth path",
778
- };
779
- }
780
- try {
781
- const result = (await withIpc(lock.socketPath, (client) => client.send("cello_attestations_issue", { subject_pubkey: subject, body })));
782
- if (!result.ok) {
783
- return { exitCode: 1, output: `${result.reason}\n${result.guidance ?? ""}`.trim() };
784
- }
785
- return { exitCode: 0, output: result.guidance ?? "Submitted." };
786
- }
787
- catch (err) {
788
- return { exitCode: 1, output: `Failed to issue signal: ${err instanceof Error ? err.message : String(err)}` };
789
- }
790
- }
791
- return {
792
- exitCode: 1,
793
- output: "Usage:\n" +
794
- " cello attestations issue <pubkey> <text…> — attest to something you have seen them do\n" +
795
- " cello attestations issued — what happened to the ones you wrote\n" +
796
- "\n" +
797
- "What others wrote about YOU is 'cello attestation-consent'.",
798
- };
799
- }
800
- /**
801
- * `cello trust-signals` — inspect and manage the operator's trust-signal wallet.
802
- *
803
- * Subcommands:
804
- * list — tabular view of all signals (includes default column)
805
- * view <hash-prefix> — full decoded payload for one signal
806
- * enable <hash-prefix> — include signal in the default presentation bundle
807
- * disable <hash-prefix> — exclude signal from the default bundle
808
- * revoke <hash-prefix> — tombstone at the directory AND hard-delete locally
809
- */
810
- export async function trustSignals(celloDir, sub, args) {
811
- const lockFilePath = join(celloDir, "daemon.lock");
812
- const lock = await readLock(lockFilePath);
813
- if (!lock) {
814
- return { exitCode: 1, output: "No daemon running. Run 'cello login' first." };
815
- }
816
- if (sub === "list") {
817
- const showAll = args.includes("--all");
818
- try {
819
- const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_list_signals")));
820
- if (!result.ok) {
821
- return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason }, null, 2) };
822
- }
823
- const all = result.signals ?? [];
824
- const signals = showAll ? all : all.filter((s) => s.status === "active");
825
- const supersededCount = all.filter((s) => s.status !== "active").length;
826
- if (signals.length === 0 && all.length === 0) {
827
- return { exitCode: 0, output: "No trust signals in wallet." };
828
- }
829
- if (signals.length === 0) {
830
- return { exitCode: 0, output: `No active trust signals. ${supersededCount} superseded (run with --all to show).` };
831
- }
832
- // M10B / DOD-END-SURFACE-1. `status` is the DIRECTORY's answer (is the notarization live);
833
- // consent is the SUBJECT's answer (may it be shown at all). A signal needs both, and only
834
- // `accepted` is presentable — so anything else must never render as included, or the
835
- // operator's own list contradicts what presentation actually does.
836
- const presentable = (s) => s.consent_state === "accepted";
837
- const anyAwaiting = signals.some((s) => !presentable(s));
838
- const lines = signals.map((s) => {
839
- const date = new Date(s.issued_at * 1000).toISOString().slice(0, 10);
840
- const hash = s.signal_hash.slice(0, 12) + "…";
841
- const status = s.status === "active" ? "active" : s.status === "superseded" ? "superseded" : s.status;
842
- // ABSENT IS NOT FINE (§5a): an unset or unrecognised consent state reads as "awaiting", never
843
- // as presentable. The attacker never has to defeat this check — they omit what triggers it.
844
- const consent = s.consent_state === "accepted" ? "—"
845
- : s.consent_state === "pending" ? "PENDING"
846
- : s.consent_state === "refused" ? "refused"
847
- : "awaiting";
848
- const inc = presentable(s) ? (s.default_present ? "✓" : "–") : "✗";
849
- // M10B / DOD-END-COUNT-1 — MCP/CLI parity (DOD-END-SURFACE-1). The daemon returns
850
- // `same_operator` and the MCP surface shows it; without this column the CLI operator sees two
851
- // endorsements as identical when one is capped — a recipient's floor excludes a co-owned
852
- // endorsement from `min_count` — and that reads as the protocol behaving arbitrarily.
853
- const own = s.same_operator === true ? "own" : "—";
854
- return ` ${s.type.padEnd(22)} ${hash} ${status.padEnd(12)} ${consent.padEnd(9)} ${own.padEnd(5)} ${inc.padEnd(4)} ${date}`;
855
- });
856
- const header = ` ${"type".padEnd(22)} hash status consent co-own include issued`;
857
- const divider = " " + "─".repeat(92);
858
- const consentLegend = anyAwaiting
859
- ? `\n consent: PENDING = someone issued this ABOUT you and it awaits your decision — it is NOT\n shown to anyone until you accept. Run 'cello attestation-consent list' to read and decide.\n refused = you refused it; it stays inert. ✗ = not presentable, whatever 'include' says.`
860
- : "";
861
- // The co-own legend appears only when a co-owned signal is present: a line explaining a column
862
- // that reads "—" on every row is noise, and its APPEARANCE is what makes the operator look.
863
- const anyCoOwned = signals.some((s) => s.same_operator === true);
864
- const coOwnLegend = anyCoOwned
865
- ? `\n co-own: 'own' = the endorser and the subject are your own agents. Still shown to contacts,\n but it does NOT count toward a counterparty's minimum-endorsements requirement.`
866
- : "";
867
- const legend = `\n include: ✓ = presented to contacts by default – = excluded from presentation\n To change: 'cello trust-signals enable <hash>' or 'cello trust-signals disable <hash>'${coOwnLegend}${consentLegend}`;
868
- const footer = !showAll && supersededCount > 0
869
- ? `\n (${supersededCount} superseded not shown — run with --all to include)`
870
- : "";
871
- return { exitCode: 0, output: [header, divider, ...lines].join("\n") + legend + footer };
872
- }
873
- catch (err) {
874
- return { exitCode: 1, output: `Failed to list signals: ${err instanceof Error ? err.message : String(err)}` };
875
- }
876
- }
877
- if (sub === "view") {
878
- const prefix = args[0];
879
- if (!prefix) {
880
- return { exitCode: 1, output: "Usage: cello trust-signals view <hash-prefix>" };
881
- }
882
- try {
883
- const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_view_signal", { hash_prefix: prefix })));
884
- if (!result.ok) {
885
- return { exitCode: 1, output: result.guidance ?? result.reason ?? "signal not found" };
886
- }
887
- const lines = [
888
- `type: ${result.type}`,
889
- `signal_hash: ${result.signal_hash}`,
890
- `status: ${result.status}`,
891
- `default_present: ${result.default_present ? "yes" : "no"}`,
892
- `subject_kind: ${result.subject_kind}`,
893
- `subject: ${result.subject}`,
894
- `issuer_kind: ${result.issuer_kind}`,
895
- `issuer_pubkey: ${result.issuer_pubkey}`,
896
- `schema_version: ${result.schema_version}`,
897
- `issued_at: ${result.issued_at ? new Date(result.issued_at * 1000).toISOString() : "—"}`,
898
- `expires_at: ${result.expires_at ? new Date(result.expires_at * 1000).toISOString() : "—"}`,
899
- `supersedes: ${result.supersedes_hash ?? "—"}`,
900
- `payload: ${JSON.stringify(result.payload, null, 2)}`,
901
- ];
902
- // CO-OWNERSHIP, ON ITS OWN LINE. The portal also writes `co_ownership_note` into the payload,
903
- // which prints above — but inside a JSON blob, where the one fact that decides what this
904
- // endorsement is worth reads as another key. This line is driven by the ENVELOPE BOOLEAN, not
905
- // by the payload note, so it also fires for signals minted before the note existed.
906
- if (result.same_operator === true)
907
- lines.push(`co-ownership: ${CO_OWNERSHIP_NOTE}`);
908
- return { exitCode: 0, output: lines.join("\n") };
909
- }
910
- catch (err) {
911
- return { exitCode: 1, output: `Failed to view signal: ${err instanceof Error ? err.message : String(err)}` };
912
- }
913
- }
914
- if (sub === "enable") {
915
- const prefix = args[0];
916
- if (!prefix) {
917
- return { exitCode: 1, output: "Usage: cello trust-signals enable <hash-prefix>" };
918
- }
919
- try {
920
- const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_enable_signal", { hash_prefix: prefix })));
921
- if (!result.ok) {
922
- return { exitCode: 1, output: result.guidance ?? result.reason ?? "failed to enable signal" };
923
- }
924
- return { exitCode: 0, output: `Signal ${result.signal_hash} enabled (included in default presentation).` };
925
- }
926
- catch (err) {
927
- return { exitCode: 1, output: `Failed to enable signal: ${err instanceof Error ? err.message : String(err)}` };
928
- }
929
- }
930
- if (sub === "disable") {
931
- const prefix = args[0];
932
- if (!prefix) {
933
- return { exitCode: 1, output: "Usage: cello trust-signals disable <hash-prefix>" };
934
- }
935
- try {
936
- const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_disable_signal", { hash_prefix: prefix })));
937
- if (!result.ok) {
938
- return { exitCode: 1, output: result.guidance ?? result.reason ?? "failed to disable signal" };
939
- }
940
- return { exitCode: 0, output: `Signal ${result.signal_hash} disabled (excluded from default presentation).` };
941
- }
942
- catch (err) {
943
- return { exitCode: 1, output: `Failed to disable signal: ${err instanceof Error ? err.message : String(err)}` };
944
- }
945
- }
946
- if (sub === "revoke") {
947
- const prefix = args[0];
948
- if (!prefix) {
949
- return { exitCode: 1, output: "Usage: cello trust-signals revoke <hash-prefix>" };
950
- }
951
- try {
952
- const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_revoke_signal", { hash_prefix: prefix })));
953
- if (!result.ok) {
954
- return { exitCode: 1, output: result.guidance ?? result.reason ?? "failed to revoke signal" };
955
- }
956
- // QUEUED, NOT REVOKED — and the daemon's own guidance says what happens next.
957
- //
958
- // This printed `Revoked signal <hash>. directory unreachable — tombstone may be pending.` on
959
- // every success, at exit 0. Three untruths in one line: it was not revoked, no directory was
960
- // contacted from here, and `directory_results` had stopped existing so `every()` on undefined
961
- // fell to `false` and the failure branch ran unconditionally. It also DROPPED the daemon's
962
- // guidance, which is the only place the operator learns that their local copy is kept and how
963
- // to check the outcome.
964
- return {
965
- exitCode: 0,
966
- output: `Revocation queued for ${result.signal_hash}` +
967
- (result.submission_id ? ` (submission ${result.submission_id.slice(0, 12)}…)` : "") +
968
- (result.guidance ? `\n${result.guidance}` : ""),
969
- };
970
- }
971
- catch (err) {
972
- return { exitCode: 1, output: `Failed to revoke signal: ${err instanceof Error ? err.message : String(err)}` };
973
- }
974
- }
975
- return {
976
- exitCode: 1,
977
- output: "Usage:\n" +
978
- " cello trust-signals list [--all] — show active signals (--all includes superseded)\n" +
979
- " cello trust-signals view <hash> — decode and display a signal's full payload\n" +
980
- " cello trust-signals enable <hash> — include signal in the default presentation bundle\n" +
981
- " cello trust-signals disable <hash> — exclude signal from the default bundle\n" +
982
- " cello trust-signals revoke <hash> — ask the portal to retract a signal (queued; your copy is kept)\n" +
983
- "\n" +
984
- "<hash> can be a prefix (min 8 chars). See 'cello trust-signals list' for hashes.",
985
- };
986
- }
987
- export async function telegramSetToken(celloDir, botToken, chatId) {
988
- const lockFilePath = join(celloDir, "daemon.lock");
989
- const lock = await readLock(lockFilePath);
990
- if (!lock) {
991
- return { exitCode: 1, output: JSON.stringify({ daemon: "stopped" }, null, 2) };
992
- }
993
- try {
994
- const result = (await withIpc(lock.socketPath, async (client) => {
995
- await client.send("ipc.connect", { clientType: "cli" });
996
- return client.send("cello_telegram_set_token", { bot_token: botToken, allowlisted_chat_id: chatId });
997
- }));
998
- return { exitCode: result.ok ? 0 : 1, output: JSON.stringify(result, null, 2) };
999
- }
1000
- catch (err) {
1001
- const message = err instanceof Error ? err.message : String(err);
1002
- return { exitCode: 1, output: JSON.stringify({ daemon: "unreachable", error: message }, null, 2) };
1003
- }
1004
- }
1005
- //# sourceMappingURL=commands.js.map