shadok-ai 0.17.0 → 0.17.2
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/README.md +56 -0
- package/context/agents-skill/SKILL.md +32 -0
- package/context/agents-skill/pilotctl.mjs +49 -1
- package/context/agents-skill/test/mock-server.mjs +21 -7
- package/context/agents-skill/test/talk.test.mjs +48 -0
- package/context/ledger-skill/SKILL.md +15 -0
- package/context/ledger-skill/ledger-core.d.mts +2 -0
- package/context/ledger-skill/ledger-core.mjs +19 -1
- package/context/ledger-skill/ledger.mjs +135 -45
- package/dist/accounts.d.ts +16 -0
- package/dist/accounts.js +39 -0
- package/dist/accounts.js.map +1 -1
- package/dist/capability.d.ts +43 -0
- package/dist/capability.js +62 -0
- package/dist/capability.js.map +1 -0
- package/dist/channels.d.ts +24 -5
- package/dist/channels.js +68 -6
- package/dist/channels.js.map +1 -1
- package/dist/claude-bin.d.ts +13 -0
- package/dist/claude-bin.js +22 -0
- package/dist/claude-bin.js.map +1 -1
- package/dist/extract.d.ts +5 -5
- package/dist/extract.js +11 -11
- package/dist/extract.js.map +1 -1
- package/dist/first-agent.d.ts +9 -1
- package/dist/first-agent.js +8 -2
- package/dist/first-agent.js.map +1 -1
- package/dist/groups.d.ts +323 -0
- package/dist/groups.js +511 -0
- package/dist/groups.js.map +1 -0
- package/dist/ledger.d.ts +66 -2
- package/dist/ledger.js +78 -3
- package/dist/ledger.js.map +1 -1
- package/dist/migrate-groups.d.ts +161 -0
- package/dist/migrate-groups.js +189 -0
- package/dist/migrate-groups.js.map +1 -0
- package/dist/osusers.d.ts +316 -0
- package/dist/osusers.js +497 -0
- package/dist/osusers.js.map +1 -0
- package/dist/secrets.d.ts +177 -17
- package/dist/secrets.js +327 -32
- package/dist/secrets.js.map +1 -1
- package/dist/server.d.ts +28 -0
- package/dist/server.js +1627 -79
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +39 -0
- package/dist/session.js +39 -2
- package/dist/session.js.map +1 -1
- package/dist/setup-token.d.ts +71 -0
- package/dist/setup-token.js +239 -0
- package/dist/setup-token.js.map +1 -0
- package/dist/tail.d.ts +10 -3
- package/dist/tail.js +13 -6
- package/dist/tail.js.map +1 -1
- package/dist/talk.d.ts +57 -0
- package/dist/talk.js +72 -0
- package/dist/talk.js.map +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +39 -3
- package/dist/telegram.js.map +1 -1
- package/dist/tmux.d.ts +58 -0
- package/dist/tmux.js +81 -4
- package/dist/tmux.js.map +1 -1
- package/package.json +1 -1
- package/public/auth-expiry.js +28 -0
- package/public/index.html +942 -115
- package/public/secrets-panel.js +119 -0
package/README.md
CHANGED
|
@@ -434,6 +434,62 @@ supplies the reaper. And either way it is decided when the container is
|
|
|
434
434
|
**created**: `docker restart` empties the table — pid 1 restarts, the kernel
|
|
435
435
|
drops the lot — but the leak starts over, so a recreate is what makes it stop.
|
|
436
436
|
|
|
437
|
+
### Groups and deployment modes
|
|
438
|
+
|
|
439
|
+
An agent lives in a **group**, and a group is both what is isolated and who can
|
|
440
|
+
see it:
|
|
441
|
+
|
|
442
|
+
- **Private** — only you. One per person, sealed, invisible even to an admin.
|
|
443
|
+
- **Restricted** — your team: a member list, any member can invite.
|
|
444
|
+
- **Public** — everyone. `Main` is the default public group.
|
|
445
|
+
|
|
446
|
+
You see and drive only your own groups plus the public ones; that covers the
|
|
447
|
+
agent list, secrets, the shared ledger and messaging between agents. Private and
|
|
448
|
+
restricted groups are real walls: each agent runs as its **own OS user** with its
|
|
449
|
+
own home and secrets, never as root. Crossing a wall is always a deliberate act:
|
|
450
|
+
drop a file in the shared `~/shared/` folder, record a ledger line with
|
|
451
|
+
`--public`, push a branch, or message an agent (public groups freely, restricted
|
|
452
|
+
ones only with a handle they gave you; private groups cannot be messaged).
|
|
453
|
+
|
|
454
|
+
Real isolation needs two things, checked once at boot:
|
|
455
|
+
|
|
456
|
+
1. the server can create OS users (it runs as root or has passwordless `sudo` —
|
|
457
|
+
the official Docker image or a VPS deploy), **and**
|
|
458
|
+
2. a GUI password is set (`SHADOK_GUI_PASSWORD`), because accounts are who the
|
|
459
|
+
walls protect.
|
|
460
|
+
|
|
461
|
+
Both → **multi-tenant**. Anything else — `npx shadok-ai` on your machine, Docker
|
|
462
|
+
without the password — stays **single-tenant**, exactly as before: it says so and
|
|
463
|
+
refuses to create a private or restricted group rather than drawing one that
|
|
464
|
+
protects nothing. You don't configure this; it follows from how you run it. The
|
|
465
|
+
boot log prints `deployment mode: …`.
|
|
466
|
+
|
|
467
|
+
An existing single-tenant instance that comes up capable is migrated
|
|
468
|
+
automatically: what you have becomes the public `Main` group, each account gets
|
|
469
|
+
a private group, and agents restart one at a time. A banner shows progress. Once
|
|
470
|
+
migration completes, private and restricted agents run as their own non-root
|
|
471
|
+
user, and `Main` agents run as a shared non-root `main` user. Until migration
|
|
472
|
+
completes (or if it fails), agents keep running as before, so the non-root
|
|
473
|
+
guarantee does not yet hold for them. On a **brand-new** instance one migration
|
|
474
|
+
step — seeding the shared Claude credential — can only run once you have signed
|
|
475
|
+
in to Claude, which happens after boot; the banner stays up until then and the
|
|
476
|
+
migration **finishes on its own the moment you sign in** (no restart needed).
|
|
477
|
+
**One-time provisioning:** isolated-group agents authenticate with a
|
|
478
|
+
subscription token injected into their environment, not a shared credential
|
|
479
|
+
file. Until it is set, group spawns are refused with a message naming this step,
|
|
480
|
+
and the cockpit shows a red "no subscription token" banner. Provide it from the
|
|
481
|
+
cockpit itself — **no shell needed**: as an admin, click **Provide token** on
|
|
482
|
+
that banner (or **Replace subscription token…** in the version menu to rotate
|
|
483
|
+
it), authorise on your Claude subscription in the browser, and paste the code
|
|
484
|
+
back; the server mints and stores it. From Telegram, `/mttoken` then
|
|
485
|
+
`/mtcode <code>` do the same. The token is captured and vaulted server-side and
|
|
486
|
+
is never shown. It is valid for about a year; re-run the card (or `/mttoken`)
|
|
487
|
+
before it expires. The shell path still works as a fallback: `claude setup-token`
|
|
488
|
+
and store the result as the vault secret `MT_CLAUDE_OAUTH_TOKEN`.
|
|
489
|
+
|
|
490
|
+
Telegram stays single-owner, and there are no
|
|
491
|
+
disk quotas yet. Design: `docs/superpowers/specs/2026-10-29-multi-tenant-scoped-isolation-design.md`.
|
|
492
|
+
|
|
437
493
|
### Exposing it beyond this machine
|
|
438
494
|
|
|
439
495
|
The cockpit runs arbitrary commands on the host by design, so it binds
|
|
@@ -31,6 +31,8 @@ and automatically starts the shadok-ai server when it is not running (port
|
|
|
31
31
|
| `stop <id>` | ends the session (for ALL its clients) |
|
|
32
32
|
| `screen <id>` | raw TUI screen (debug) |
|
|
33
33
|
| `profile-prompt "<text>" [--name NAME] [--readonly]` | rewrites a profile's **system prompt**: your own by default; any of them (and creation with `--name`) under the lead profile |
|
|
34
|
+
| `talk-handle` | prints **your own** handle — the "here is a handle to me" you share so another agent can send you a letter → `{handle}` |
|
|
35
|
+
| `talk <handle> "message"` | sends a one-way **letter** to the agent that handle points to → `{ok, delivered}`. A letter, NOT a drive: it is read in the recipient's own context and you get no transcript back (see below) |
|
|
34
36
|
|
|
35
37
|
## Choosing a profile (`--profile`)
|
|
36
38
|
|
|
@@ -113,6 +115,36 @@ time, and the information comes to you.
|
|
|
113
115
|
`--parent none` spawns an agent deliberately unattached; `--parent <id>`
|
|
114
116
|
attaches it elsewhere.
|
|
115
117
|
|
|
118
|
+
## Talking across groups: a letter, not a remote control (`talk`)
|
|
119
|
+
|
|
120
|
+
When agents belong to different **groups** (teams sharing one cockpit), you
|
|
121
|
+
cannot `prompt`/`drive` another group's agent — see/drive stay to your own group
|
|
122
|
+
and to public ones. `talk` is the one narrow, consensual exception: you send a
|
|
123
|
+
one-way **letter** the recipient handles in ITS OWN context, under its own
|
|
124
|
+
guardrails. You never receive its transcript, stream or files back — `talk`
|
|
125
|
+
returns only that the letter was accepted and delivered.
|
|
126
|
+
|
|
127
|
+
The ACL, enforced server-side:
|
|
128
|
+
|
|
129
|
+
- an agent in a **public** group — messageable freely, no handle needed;
|
|
130
|
+
- an agent in another **restricted** group — ONLY if you hold its **handle** (an
|
|
131
|
+
opaque token it shared with you). There is no roster to browse: you can only
|
|
132
|
+
write to an agent whose handle reached you out of band;
|
|
133
|
+
- an agent in a **private** group — **sealed inbound**: no letter gets in, even
|
|
134
|
+
with a handle. (But a private-group agent may still send OUTWARD.)
|
|
135
|
+
|
|
136
|
+
To let someone write to you, give them your handle:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
node ~/.claude/skills/shadok-ai-agents/pilotctl.mjs talk-handle # → {"handle":"…"}
|
|
140
|
+
# share that string (a human or a prompt relays it), then they run:
|
|
141
|
+
node ~/.claude/skills/shadok-ai-agents/pilotctl.mjs talk <handle> "please review PR 42"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
There is no automatic reply channel — if you want an answer, include YOUR handle
|
|
145
|
+
inside the message. (In mono-tenant / single-team cockpits everything is one
|
|
146
|
+
public commons, so `talk` is simply unrestricted.)
|
|
147
|
+
|
|
116
148
|
## Being prompted BY another agent
|
|
117
149
|
|
|
118
150
|
The other direction: a prompt from an agent arrives under a context header
|
|
@@ -567,6 +567,50 @@ async function cmdProfilePrompt(text, flags) {
|
|
|
567
567
|
return out;
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
// Cross-group TALK: send another agent a one-way LETTER, the sanctioned
|
|
571
|
+
// server-mediated channel (there is no native SendMessage). This is NOT a drive:
|
|
572
|
+
// the recipient reads the message in its OWN context under its own guardrails,
|
|
573
|
+
// and you never receive its transcript/stream back — the call returns only that
|
|
574
|
+
// the letter was accepted and dispatched.
|
|
575
|
+
//
|
|
576
|
+
// ACL (enforced server-side): a PUBLIC-group agent is messageable freely; an
|
|
577
|
+
// agent in another RESTRICTED group only if you hold its HANDLE (an opaque token
|
|
578
|
+
// it shared — there is no roster to browse); a PRIVATE group is SEALED inbound.
|
|
579
|
+
// A private-group agent may still send OUTWARD.
|
|
580
|
+
//
|
|
581
|
+
// `handle` identifies the recipient. Get your OWN handle to share with
|
|
582
|
+
// `pilotctl talk-handle`; obtaining someone else's is out of band (a human or a
|
|
583
|
+
// prompt relays it) — pilotctl deliberately offers no way to enumerate handles.
|
|
584
|
+
async function cmdTalk(handle, message) {
|
|
585
|
+
const key = process.env.SHADOK_SESSION_KEY;
|
|
586
|
+
if (!key) throw new Error("no SHADOK_SESSION_KEY in env — not running as a piloted agent");
|
|
587
|
+
if (!handle) throw new Error("usage: pilotctl talk <handle> <message>");
|
|
588
|
+
if (!message || !message.trim()) throw new Error("a message is required");
|
|
589
|
+
const r = await fetch(`${httpBase()}/talk`, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
592
|
+
body: JSON.stringify({ key, handle, message }),
|
|
593
|
+
});
|
|
594
|
+
const out = await r.json();
|
|
595
|
+
if (!r.ok) throw new Error(out.error ?? `HTTP ${r.status}`);
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Your OWN talk handle — the "here is a handle to me" an agent shares so another
|
|
600
|
+
// agent can send it a letter. Scoped to yourself by the session key: there is no
|
|
601
|
+
// way to mint or look up someone else's.
|
|
602
|
+
async function cmdTalkHandle() {
|
|
603
|
+
const key = process.env.SHADOK_SESSION_KEY;
|
|
604
|
+
if (!key) throw new Error("no SHADOK_SESSION_KEY in env — not running as a piloted agent");
|
|
605
|
+
const r = await fetch(`${httpBase()}/talk/handle`, {
|
|
606
|
+
method: "GET",
|
|
607
|
+
headers: { ...authHeaders() },
|
|
608
|
+
});
|
|
609
|
+
const out = await r.json();
|
|
610
|
+
if (!r.ok) throw new Error(out.error ?? `HTTP ${r.status}`);
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
|
|
570
614
|
async function cmdScreen(id, flags) {
|
|
571
615
|
await ensureServer();
|
|
572
616
|
const st = readState(id);
|
|
@@ -579,7 +623,7 @@ async function cmdScreen(id, flags) {
|
|
|
579
623
|
}
|
|
580
624
|
|
|
581
625
|
const HELP =
|
|
582
|
-
"usage: pilotctl <spawn|prompt|dialog|choose|toggle|confirm|freetext|list|diff|stop|screen|profile-prompt|peer> …\n" +
|
|
626
|
+
"usage: pilotctl <spawn|prompt|dialog|choose|toggle|confirm|freetext|list|diff|stop|screen|profile-prompt|talk|talk-handle|peer> …\n" +
|
|
583
627
|
" add --peer <alias> to any command to run it against another instance's agents\n" +
|
|
584
628
|
" pilotctl peer <add <alias> <url> <ticket> [--token]|list|rm <alias>>";
|
|
585
629
|
|
|
@@ -685,6 +729,10 @@ export async function run(argv) {
|
|
|
685
729
|
return cmdScreen(pos[0], flags);
|
|
686
730
|
case "profile-prompt":
|
|
687
731
|
return cmdProfilePrompt(pos[0], flags);
|
|
732
|
+
case "talk":
|
|
733
|
+
return cmdTalk(pos[0], pos[1]);
|
|
734
|
+
case "talk-handle":
|
|
735
|
+
return cmdTalkHandle();
|
|
688
736
|
default:
|
|
689
737
|
throw new Error(HELP);
|
|
690
738
|
}
|
|
@@ -8,15 +8,28 @@ export function startMockServer(script = {}) {
|
|
|
8
8
|
// Cookie headers seen on HTTP requests and on the WS upgrade — lets a test
|
|
9
9
|
// assert pilotctl presents the SHADOK_AUTH cookie when a password is set.
|
|
10
10
|
const cookies = { http: [], ws: [] };
|
|
11
|
+
// Per-route HTTP requests seen, with their parsed JSON body and the session-key
|
|
12
|
+
// header — lets a test assert what pilotctl actually sent (e.g. `talk`).
|
|
13
|
+
const http_ = [];
|
|
11
14
|
const app = http.createServer((req, res) => {
|
|
12
15
|
if (req.headers.cookie) cookies.http.push(req.headers.cookie);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
);
|
|
19
|
-
|
|
16
|
+
const chunks = [];
|
|
17
|
+
req.on("data", (c) => chunks.push(c));
|
|
18
|
+
req.on("end", () => {
|
|
19
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
20
|
+
let body;
|
|
21
|
+
try { body = raw ? JSON.parse(raw) : undefined; } catch { body = raw; }
|
|
22
|
+
http_.push({ method: req.method, url: req.url, body, sessionKey: req.headers["x-shadok-session-key"] });
|
|
23
|
+
res.setHeader("content-type", "application/json");
|
|
24
|
+
if (req.url.startsWith("/sessions")) return res.end(JSON.stringify(script.sessions ?? []));
|
|
25
|
+
if (req.url.startsWith("/diff"))
|
|
26
|
+
return res.end(
|
|
27
|
+
JSON.stringify(script.diff ?? { status: "", diff: "", branch: null, error: "no such session" }),
|
|
28
|
+
);
|
|
29
|
+
if (req.url.startsWith("/talk/handle")) return res.end(JSON.stringify(script.talkHandle ?? { handle: "H" }));
|
|
30
|
+
if (req.url.startsWith("/talk")) return res.end(JSON.stringify(script.talk ?? { ok: true, delivered: "abcd" }));
|
|
31
|
+
res.end("{}");
|
|
32
|
+
});
|
|
20
33
|
});
|
|
21
34
|
const wss = new WebSocketServer({ server: app, path: "/ws" });
|
|
22
35
|
const received = [];
|
|
@@ -33,6 +46,7 @@ export function startMockServer(script = {}) {
|
|
|
33
46
|
resolve({
|
|
34
47
|
port: app.address().port,
|
|
35
48
|
received,
|
|
49
|
+
http: http_,
|
|
36
50
|
cookies,
|
|
37
51
|
close: () => new Promise((r) => { wss.close(); app.close(r); }),
|
|
38
52
|
}),
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { startMockServer } from "./mock-server.mjs";
|
|
7
|
+
|
|
8
|
+
process.env.SHADOK_STATE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "pilotctl-talk-"));
|
|
9
|
+
process.env.SHADOK_NO_HOLDER = "1";
|
|
10
|
+
process.env.SHADOK_NO_AUTOSTART = "1";
|
|
11
|
+
const { run } = await import("../pilotctl.mjs");
|
|
12
|
+
|
|
13
|
+
test("talk without a session key is refused (not running as a piloted agent)", async () => {
|
|
14
|
+
delete process.env.SHADOK_SESSION_KEY;
|
|
15
|
+
await assert.rejects(() => run(["talk", "H", "hi"]), /SHADOK_SESSION_KEY/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("talk POSTs the session key, handle and message, and returns the server's reply", async () => {
|
|
19
|
+
const mock = await startMockServer({ talk: { ok: true, delivered: "ef01" } });
|
|
20
|
+
process.env.SHADOK_PORT = String(mock.port);
|
|
21
|
+
process.env.SHADOK_SESSION_KEY = "sk-key-123";
|
|
22
|
+
try {
|
|
23
|
+
const r = await run(["talk", "handle-xyz", "please review PR 42"]);
|
|
24
|
+
assert.deepEqual(r, { ok: true, delivered: "ef01" });
|
|
25
|
+
const call = mock.http.find((h) => h.url === "/talk" && h.method === "POST");
|
|
26
|
+
assert.ok(call, "pilotctl did not POST /talk");
|
|
27
|
+
assert.equal(call.sessionKey, "sk-key-123", "the session key header must be sent");
|
|
28
|
+
assert.equal(call.body.handle, "handle-xyz");
|
|
29
|
+
assert.equal(call.body.message, "please review PR 42");
|
|
30
|
+
} finally {
|
|
31
|
+
await mock.close();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("talk-handle returns the caller's own handle", async () => {
|
|
36
|
+
const mock = await startMockServer({ talkHandle: { handle: "my-handle" } });
|
|
37
|
+
process.env.SHADOK_PORT = String(mock.port);
|
|
38
|
+
process.env.SHADOK_SESSION_KEY = "sk-key-123";
|
|
39
|
+
try {
|
|
40
|
+
const r = await run(["talk-handle"]);
|
|
41
|
+
assert.deepEqual(r, { handle: "my-handle" });
|
|
42
|
+
const call = mock.http.find((h) => h.url.startsWith("/talk/handle"));
|
|
43
|
+
assert.ok(call, "pilotctl did not GET /talk/handle");
|
|
44
|
+
assert.equal(call.sessionKey, "sk-key-123");
|
|
45
|
+
} finally {
|
|
46
|
+
await mock.close();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
@@ -115,6 +115,21 @@ node ~/.claude/skills/shadok-ledger/ledger.mjs record \
|
|
|
115
115
|
--id <id> --status resolved --note "<one line>"
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
## Who sees a row: your group by default, everyone with `--public`
|
|
119
|
+
|
|
120
|
+
On an instance with several groups, a row you record is **private to your
|
|
121
|
+
group**: only agents of your group see it in `check`, `list` and the pushed
|
|
122
|
+
block. That is the safe default — forgetting a flag can never leak anything. To
|
|
123
|
+
share a resolution with every group, say so on purpose:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
… record --entity "<name>" --status resolved --public
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
It is the twin of dropping a file into `~/shared/` to make it public. Publish
|
|
130
|
+
only what is meant for the other groups. `--id <id> --public` publishes an
|
|
131
|
+
existing row. (On a single-group instance nothing changes.)
|
|
132
|
+
|
|
118
133
|
## To consult the whole table
|
|
119
134
|
|
|
120
135
|
```
|
|
@@ -12,12 +12,14 @@ export interface LedgerCoreRow {
|
|
|
12
12
|
source?: string;
|
|
13
13
|
updatedAt?: number;
|
|
14
14
|
verifiedAt?: number;
|
|
15
|
+
scope?: "group" | "public";
|
|
15
16
|
}
|
|
16
17
|
export type LedgerKind = "task" | "fact" | "decision";
|
|
17
18
|
export const KINDS: LedgerKind[];
|
|
18
19
|
export const LABEL_MAX: number;
|
|
19
20
|
export function normEntity(s: unknown): string;
|
|
20
21
|
export function normKind(k: unknown): LedgerKind | null;
|
|
22
|
+
export function normScope(sc: unknown): "group" | "public" | null;
|
|
21
23
|
export function rowKind(entry: unknown): LedgerKind;
|
|
22
24
|
export function resolveId<T extends LedgerCoreRow>(entries: T[], idOrPrefix: unknown): T | null;
|
|
23
25
|
export function upsertEntry<T extends LedgerCoreRow>(
|
|
@@ -86,6 +86,21 @@ export function rowKind(entry) {
|
|
|
86
86
|
return normEntity(entry?.status) === "decided" ? "decision" : "task";
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* A row's SCOPE: `group` (visible to the writer's own group only) or `public`
|
|
91
|
+
* (the commons, every group). The DIRECTION OF THE DEFAULT is the invariant:
|
|
92
|
+
* absent means `group`, so forgetting a flag can only ever keep something
|
|
93
|
+
* private. Public-by-default would reopen the cross-group leak on the first
|
|
94
|
+
* oversight — publishing is a deliberate act (`--public`), the twin of dropping
|
|
95
|
+
* a file into `~/shared/`.
|
|
96
|
+
*/
|
|
97
|
+
export function normScope(sc) {
|
|
98
|
+
const s = String(sc ?? "").trim().toLowerCase();
|
|
99
|
+
if (!s) return null;
|
|
100
|
+
if (s !== "group" && s !== "public") throw new Error(`unknown scope "${sc}" — use group or public`);
|
|
101
|
+
return s;
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
/**
|
|
90
105
|
* Upsert a row, two ways:
|
|
91
106
|
* - `patch.id` present → UPDATE that exact row in place (resolved via resolveId),
|
|
@@ -114,6 +129,7 @@ export function rowKind(entry) {
|
|
|
114
129
|
* was last observed, which is the truth and makes the migration lazy, per row.
|
|
115
130
|
*/
|
|
116
131
|
export function upsertEntry(entries, patch, now, newId) {
|
|
132
|
+
const scope = normScope(patch.scope);
|
|
117
133
|
const kind = normKind(patch.kind); // refuses an unknown kind before anything is written
|
|
118
134
|
const statusGiven = patch.status != null && String(patch.status).trim() !== "";
|
|
119
135
|
|
|
@@ -131,7 +147,7 @@ export function upsertEntry(entries, patch, now, newId) {
|
|
|
131
147
|
? KIND_STATUS[kind]
|
|
132
148
|
: cur.status;
|
|
133
149
|
const edited =
|
|
134
|
-
statusGiven || kind != null || patch.note != null || patch.source != null || patch.entity != null;
|
|
150
|
+
statusGiven || kind != null || patch.note != null || patch.source != null || patch.entity != null || scope != null;
|
|
135
151
|
const observed = statusGiven || patch.verified === true || status !== cur.status;
|
|
136
152
|
const row = {
|
|
137
153
|
id: cur.id,
|
|
@@ -139,6 +155,7 @@ export function upsertEntry(entries, patch, now, newId) {
|
|
|
139
155
|
patch.entity != null && String(patch.entity).trim() ? String(patch.entity).trim() : cur.entity,
|
|
140
156
|
status,
|
|
141
157
|
...(effKind ? { kind: effKind } : {}),
|
|
158
|
+
scope: scope ?? normScope(cur.scope) ?? "group",
|
|
142
159
|
...((patch.note ?? cur.note) ? { note: String(patch.note ?? cur.note).trim() } : {}),
|
|
143
160
|
...((patch.source ?? cur.source) ? { source: String(patch.source ?? cur.source).trim() } : {}),
|
|
144
161
|
updatedAt: edited ? now : (cur.updatedAt ?? now),
|
|
@@ -160,6 +177,7 @@ export function upsertEntry(entries, patch, now, newId) {
|
|
|
160
177
|
entity: String(patch.entity).trim(),
|
|
161
178
|
status,
|
|
162
179
|
...(effKind ? { kind: effKind } : {}),
|
|
180
|
+
scope: scope ?? normScope(existing?.scope) ?? "group",
|
|
163
181
|
...(patch.note ? { note: String(patch.note).trim() } : {}),
|
|
164
182
|
...(patch.source ? { source: String(patch.source).trim() } : {}),
|
|
165
183
|
updatedAt: now,
|
|
@@ -2,34 +2,52 @@
|
|
|
2
2
|
// shadok-ledger CLI — a tiny state table so agents verify a status before they
|
|
3
3
|
// assert or act on it, and record what they resolve/decide.
|
|
4
4
|
//
|
|
5
|
-
// Store:
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// Store: ONE ledger table per instance, owned by the server. On an instance,
|
|
6
|
+
// `record`/`check`/`list` go THROUGH the server (POST /ledger/record, GET
|
|
7
|
+
// /ledger), authenticated with the per-session key — exactly like the
|
|
8
|
+
// `shadok-secrets` skill reaches the vault. The server stamps the row's
|
|
9
|
+
// AUTHORING GROUP from the session (never from here) and filters reads to that
|
|
10
|
+
// group ∪ public, so a group's private rows never leak and no write crosses the
|
|
11
|
+
// OS-user wall (the file lives 0600 in the server HOME).
|
|
12
|
+
//
|
|
13
|
+
// FALLBACK: with no server reachable (a hand-run CLI, or a mono-tenant instance
|
|
14
|
+
// with no port/key), it reads and writes the local file directly —
|
|
15
|
+
// SHADOK_LEDGER_FILE if the server handed one, else the legacy global file.
|
|
16
|
+
// Nothing about the single-group / off-server case changes.
|
|
9
17
|
import fs from "node:fs";
|
|
10
18
|
import os from "node:os";
|
|
11
19
|
import path from "node:path";
|
|
12
20
|
import crypto from "node:crypto";
|
|
13
21
|
import { upsertEntry, searchEntries, ageDays, resolveId, normEntity, rowKind, KINDS, LABEL_MAX } from "./ledger-core.mjs";
|
|
14
22
|
|
|
23
|
+
const PORT = (process.env.SHADOK_PORT || "").trim();
|
|
24
|
+
const KEY = (process.env.SHADOK_SESSION_KEY || "").trim();
|
|
25
|
+
const AUTH = (process.env.SHADOK_AUTH || "").trim();
|
|
26
|
+
// On-server: a port AND a session key. The key is what proves WHICH agent calls,
|
|
27
|
+
// and it is what the server scopes the authoring group by — without it there is
|
|
28
|
+
// no identity, so we fall back to the local file.
|
|
29
|
+
const useServer = !!PORT && !!KEY;
|
|
30
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
31
|
+
const TIMEOUT_MS = 15_000;
|
|
32
|
+
|
|
15
33
|
const FILE =
|
|
16
34
|
(process.env.SHADOK_LEDGER_FILE || "").trim() ||
|
|
17
35
|
path.join(os.homedir(), ".shadok-ai", "ledger.json");
|
|
18
36
|
|
|
19
|
-
function
|
|
37
|
+
function loadFile(file) {
|
|
20
38
|
try {
|
|
21
|
-
const j = JSON.parse(fs.readFileSync(
|
|
39
|
+
const j = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
22
40
|
return Array.isArray(j) ? j : [];
|
|
23
41
|
} catch {
|
|
24
42
|
return []; // absent or unreadable → empty table, never a crash
|
|
25
43
|
}
|
|
26
44
|
}
|
|
27
45
|
|
|
28
|
-
function
|
|
29
|
-
fs.mkdirSync(path.dirname(
|
|
30
|
-
const tmp =
|
|
46
|
+
function saveFile(file, rows) {
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
48
|
+
const tmp = file + ".tmp";
|
|
31
49
|
fs.writeFileSync(tmp, JSON.stringify(rows, null, 2), { mode: 0o600 });
|
|
32
|
-
fs.renameSync(tmp,
|
|
50
|
+
fs.renameSync(tmp, file); // atomic: a concurrent reader never sees half a file
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
/** A short, unique handle (4 hex): quotable from the pushed ledger block. */
|
|
@@ -42,9 +60,44 @@ function mintId(rows) {
|
|
|
42
60
|
return crypto.randomBytes(4).toString("hex");
|
|
43
61
|
}
|
|
44
62
|
|
|
63
|
+
/** Call the server. Exits with a clear message on a transport/HTTP failure —
|
|
64
|
+
* the same shape as the secrets skill. */
|
|
65
|
+
async function api(method, p, body) {
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await fetch(BASE + p, {
|
|
69
|
+
method,
|
|
70
|
+
headers: {
|
|
71
|
+
"content-type": "application/json",
|
|
72
|
+
...(AUTH ? { Cookie: AUTH } : {}),
|
|
73
|
+
// The durable credential: the cookie in the env expires after a week,
|
|
74
|
+
// the session key does not (invariant 33).
|
|
75
|
+
...(KEY ? { "x-shadok-session-key": KEY } : {}),
|
|
76
|
+
},
|
|
77
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
78
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
79
|
+
});
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.error(`cannot reach the cockpit on ${BASE}: ${e.message}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const raw = await res.text();
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
let msg = raw.slice(0, 300);
|
|
87
|
+
try {
|
|
88
|
+
msg = JSON.parse(raw).error ?? msg;
|
|
89
|
+
} catch {
|
|
90
|
+
/* non-JSON body — keep the raw slice */
|
|
91
|
+
}
|
|
92
|
+
console.error(`ledger API error ${res.status}: ${msg}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
return raw ? JSON.parse(raw) : {};
|
|
96
|
+
}
|
|
97
|
+
|
|
45
98
|
/** Flags that take NO value, listed rather than guessed: inferring "the next arg
|
|
46
99
|
* starts with -- so this one is a boolean" would misread `--note "--foo"`. */
|
|
47
|
-
const BOOL_FLAGS = new Set(["verified"]);
|
|
100
|
+
const BOOL_FLAGS = new Set(["verified", "public"]);
|
|
48
101
|
|
|
49
102
|
/** `--key value` pairs → object. Values may be quoted by the shell already. */
|
|
50
103
|
function parseFlags(args) {
|
|
@@ -70,23 +123,29 @@ function fmt(e, now) {
|
|
|
70
123
|
// noise on every line. The two that change how a status must be READ are named.
|
|
71
124
|
const tag = kind === "task" ? "" : ` · ${kind}`;
|
|
72
125
|
const handle = e.id ? `[${e.id}] ` : "";
|
|
73
|
-
const
|
|
126
|
+
const pub = e.scope === "public" ? " · public" : "";
|
|
127
|
+
const head = `• ${handle}${e.entity} — ${e.status}${tag}${pub} (${stamp}${e.source ? ` · ${e.source}` : ""})`;
|
|
74
128
|
return e.note ? `${head}\n ${e.note}` : head;
|
|
75
129
|
}
|
|
76
130
|
|
|
77
|
-
|
|
78
|
-
|
|
131
|
+
/** The rows a read works over: on-server, the server-scoped table (own group ∪
|
|
132
|
+
* public); off-server, the local file. */
|
|
133
|
+
async function visibleRows() {
|
|
134
|
+
if (useServer) return (await api("GET", "/ledger")).entries ?? [];
|
|
135
|
+
return loadFile(FILE);
|
|
136
|
+
}
|
|
79
137
|
|
|
80
|
-
|
|
138
|
+
async function doCheck(rest) {
|
|
81
139
|
const q = rest.join(" ").trim();
|
|
82
140
|
if (!q) {
|
|
83
141
|
console.error('usage: ledger check "<topic or entity>"');
|
|
84
142
|
process.exit(2);
|
|
85
143
|
}
|
|
144
|
+
const now = Date.now();
|
|
86
145
|
// Two tiers, and the difference is the whole point: `strong` answers the
|
|
87
146
|
// question, `weak` merely shares words with it. Collapsing them would make a
|
|
88
147
|
// loosely-related row read as a status — the opposite of what this gates.
|
|
89
|
-
const { strong, weak } = searchEntries(
|
|
148
|
+
const { strong, weak } = searchEntries(await visibleRows(), q);
|
|
90
149
|
if (strong.length) {
|
|
91
150
|
console.log(strong.map((e) => fmt(e, now)).join("\n"));
|
|
92
151
|
} else {
|
|
@@ -99,51 +158,75 @@ if (cmd === "check") {
|
|
|
99
158
|
console.log(weak.map((e) => fmt(e, now)).join("\n"));
|
|
100
159
|
}
|
|
101
160
|
}
|
|
102
|
-
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function doList() {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const rows = searchEntries(await visibleRows(), "").strong;
|
|
166
|
+
console.log(rows.length ? rows.map((e) => fmt(e, now)).join("\n") : "(ledger empty)");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function doRecord(rest) {
|
|
103
170
|
const f = parseFlags(rest);
|
|
104
171
|
// Two shapes: create/supersede by --entity (+ --status), or update an existing
|
|
105
172
|
// row by its --id handle (change any of status/note/source; entity optional).
|
|
106
173
|
const byId = f.id != null && String(f.id).trim();
|
|
107
174
|
const idHasNoFields =
|
|
108
175
|
f.status == null && f.note == null && f.source == null && f.entity == null &&
|
|
109
|
-
f.kind == null && f.verified == null;
|
|
176
|
+
f.kind == null && f.verified == null && f.public == null;
|
|
110
177
|
// A fact or a decision has no lifecycle, so `--kind` stands in for `--status`:
|
|
111
178
|
// requiring one would only invite an invented word.
|
|
112
179
|
const hasStatus = !!f.status || !!f.kind;
|
|
113
180
|
if (byId ? idHasNoFields : !f.entity || !hasStatus) {
|
|
114
181
|
console.error(
|
|
115
|
-
'usage: ledger record --entity "<name>" --status <resolved|open|in-progress|decided> [--note "<line>"] [--source "<ref>"]\n' +
|
|
182
|
+
'usage: ledger record --entity "<name>" --status <resolved|open|in-progress|decided> [--note "<line>"] [--source "<ref>"] [--public]\n' +
|
|
116
183
|
` or: ledger record --entity "<name>" --kind <${KINDS.join("|")}> [--note "<line>"] [--source "<ref>"]\n` +
|
|
117
|
-
' or: ledger record --id <handle> [--status <…>] [--kind <…>] [--note "<line>"] [--source "<ref>"]\n' +
|
|
184
|
+
' or: ledger record --id <handle> [--status <…>] [--kind <…>] [--note "<line>"] [--source "<ref>"] [--public]\n' +
|
|
118
185
|
' or: ledger record --id <handle> --verified (still true/current — nothing else changed)',
|
|
119
186
|
);
|
|
120
187
|
process.exit(2);
|
|
121
188
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
189
|
+
|
|
190
|
+
const patch = {
|
|
191
|
+
id: f.id,
|
|
192
|
+
entity: f.entity,
|
|
193
|
+
status: f.status,
|
|
194
|
+
kind: f.kind,
|
|
195
|
+
verified: f.verified === true,
|
|
196
|
+
note: f.note,
|
|
197
|
+
source: f.source,
|
|
198
|
+
// Group-private unless asked: `--public` is the only way out.
|
|
199
|
+
public: f.public === true ? true : undefined,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
let row;
|
|
203
|
+
if (useServer) {
|
|
204
|
+
// The server stamps the authoring group (from the session) and scopes the
|
|
205
|
+
// upsert to it, so a cross-group clobber is impossible by construction.
|
|
206
|
+
row = (await api("POST", "/ledger/record", patch)).row;
|
|
207
|
+
} else {
|
|
208
|
+
// Off-server fallback: write the local file directly (mono-tenant / hand-run).
|
|
209
|
+
const rows0 = loadFile(FILE);
|
|
210
|
+
let next;
|
|
211
|
+
try {
|
|
212
|
+
next = upsertEntry(
|
|
213
|
+
rows0,
|
|
214
|
+
{ ...patch, scope: patch.public === true ? "public" : undefined },
|
|
215
|
+
Date.now(),
|
|
216
|
+
mintId(rows0),
|
|
217
|
+
);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.error(String(e?.message ?? e));
|
|
220
|
+
process.exit(2);
|
|
221
|
+
}
|
|
222
|
+
saveFile(FILE, next);
|
|
223
|
+
row = byId ? resolveId(next, f.id) : next.find((r) => normEntity(r.entity) === normEntity(f.entity));
|
|
142
224
|
}
|
|
143
|
-
|
|
144
|
-
const row = byId ? resolveId(rows, f.id) : rows.find((r) => normEntity(r.entity) === normEntity(f.entity));
|
|
225
|
+
|
|
145
226
|
const k = rowKind(row);
|
|
146
|
-
console.log(
|
|
227
|
+
console.log(
|
|
228
|
+
`recorded: ${row.entity} — ${row.status}${k === "task" ? "" : ` · ${k}`}${row.scope === "public" ? " · public" : ""} [${row.id}]`,
|
|
229
|
+
);
|
|
147
230
|
// Say it, don't enforce it: the row is already saved (a refused write is a
|
|
148
231
|
// lost fact) and the full label stays in the table. But the delta pushed to
|
|
149
232
|
// siblings only carries the first LABEL_MAX characters of it, and a label
|
|
@@ -154,9 +237,16 @@ if (cmd === "check") {
|
|
|
154
237
|
`Only the first ${LABEL_MAX} reach the ⟦ledger⟧ block your siblings are pushed.`,
|
|
155
238
|
);
|
|
156
239
|
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
243
|
+
|
|
244
|
+
if (cmd === "check") {
|
|
245
|
+
await doCheck(rest);
|
|
246
|
+
} else if (cmd === "record") {
|
|
247
|
+
await doRecord(rest);
|
|
157
248
|
} else if (cmd === "list") {
|
|
158
|
-
|
|
159
|
-
console.log(rows.length ? rows.map((e) => fmt(e, now)).join("\n") : "(ledger empty)");
|
|
249
|
+
await doList();
|
|
160
250
|
} else {
|
|
161
251
|
console.error("usage: ledger <check|record|list> …");
|
|
162
252
|
process.exit(2);
|
package/dist/accounts.d.ts
CHANGED
|
@@ -155,3 +155,19 @@ export declare function promptAuthor(origin: string | undefined, sessionName: st
|
|
|
155
155
|
export declare function signSessionKey(sessionId: string, secret: Buffer): string;
|
|
156
156
|
/** Pure: the session id a key attests to, or null if it does not verify. */
|
|
157
157
|
export declare function readSessionKey(key: string, secret: Buffer): string | null;
|
|
158
|
+
/**
|
|
159
|
+
* A shareable TALK HANDLE for a session — an opaque token that lets ANOTHER
|
|
160
|
+
* agent address a one-way LETTER to this one (cross-group talk, `src/talk.ts`),
|
|
161
|
+
* and nothing more.
|
|
162
|
+
*
|
|
163
|
+
* It is DELIBERATELY NOT a session key. `signSessionKey` authenticates AS the
|
|
164
|
+
* agent and can DRIVE it; a handle must only ever resolve to "who to deliver a
|
|
165
|
+
* letter to", leaving the talk ACL to decide if the letter may pass. They share
|
|
166
|
+
* the id-inside-the-token shape but are DOMAIN-SEPARATED (`talk:` vs `session:`
|
|
167
|
+
* in the HMAC input), so a handle can never be replayed as a session key and a
|
|
168
|
+
* leaked session key is not a valid handle — a handle shared freely grants no
|
|
169
|
+
* drive capability.
|
|
170
|
+
*/
|
|
171
|
+
export declare function signTalkHandle(sessionId: string, secret: Buffer): string;
|
|
172
|
+
/** Pure: the session id a talk handle points to, or null if it does not verify. */
|
|
173
|
+
export declare function readTalkHandle(handle: string, secret: Buffer): string | null;
|