@amkentech/agent-channel 0.5.1
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 +48 -0
- package/bin/agent-channel.mjs +46 -0
- package/db/ledger.sql +85 -0
- package/db/supabase-ca.pem +24 -0
- package/hooks/claude-status.mjs +31 -0
- package/hooks/inbox.mjs +285 -0
- package/hooks/notify.mjs +18 -0
- package/hooks/statusline.mjs +60 -0
- package/lib/adapters.mjs +170 -0
- package/lib/artifacts.mjs +29 -0
- package/lib/codex-push.mjs +43 -0
- package/lib/crypto.mjs +100 -0
- package/lib/inspect.mjs +0 -0
- package/lib/paths.mjs +45 -0
- package/package.json +16 -0
- package/scripts/artifact.mjs +126 -0
- package/scripts/audit-verify.mjs +63 -0
- package/scripts/cli.mjs +16 -0
- package/scripts/codex-probe.mjs +33 -0
- package/scripts/export-conversation.mjs +128 -0
- package/scripts/inbox-view.mjs +64 -0
- package/scripts/join.mjs +29 -0
- package/scripts/listen.mjs +137 -0
- package/scripts/setup.mjs +234 -0
- package/scripts/share.mjs +67 -0
- package/scripts/verify.mjs +87 -0
package/lib/adapters.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Runtime adapters. Agent Channel is a conduit between AI workspaces; the workspace is not ours. Each adapter says how one
|
|
2
|
+
// runtime (Claude Code, Codex, ...) is wired and how it behaves, so hooks/setup/listener never hardcode a runtime and a
|
|
3
|
+
// third runtime is a new entry here, not a rewrite. Capabilities are probed where possible and degrade loudly.
|
|
4
|
+
//
|
|
5
|
+
// Contract per adapter:
|
|
6
|
+
// key short name used in owner.<key> markers, .tok.<key>.json, hook argv
|
|
7
|
+
// runtime the runtime string stored on the agent (claude-code, codex, ...)
|
|
8
|
+
// tokenEnv env var the hook/listener read the token from
|
|
9
|
+
// rendersSystemMessage does the runtime show hook `systemMessage` to the human? (Claude Code yes, Codex no)
|
|
10
|
+
// blocksPrompt can a UserPromptSubmit hook block the prompt with a visible reason? (Claude Code yes)
|
|
11
|
+
// supportsFileChanged Claude Code's FileChanged hook (idle notifications)
|
|
12
|
+
// supportsStatusLine Claude Code statusLine
|
|
13
|
+
// hooksFile / mcp where the wiring lives, and how to write it
|
|
14
|
+
// transcripts where session transcripts live (for export-conversation)
|
|
15
|
+
import { homedir, platform } from "node:os";
|
|
16
|
+
import { join, dirname as dirnameOf } from "node:path";
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const H = homedir();
|
|
21
|
+
// hook command lines run through a shell on every OS; forward slashes work on Windows too, and a path with spaces
|
|
22
|
+
// ("/Users/Jo Smith/.agentchan/client") must stay quoted
|
|
23
|
+
const nodeCmd = (repo, rel, ...a) => { const p = join(repo, rel).replace(/\\/g, "/"); return ["node", /\s/.test(p) ? '"' + p + '"' : p, ...a].join(" "); };
|
|
24
|
+
|
|
25
|
+
export const ADAPTERS = {
|
|
26
|
+
claude: {
|
|
27
|
+
key: "claude", runtime: "claude-code", label: "Claude Code", tokenEnv: "AGENTCHAN_TOKEN",
|
|
28
|
+
rendersSystemMessage: true, blocksPrompt: true, supportsFileChanged: true, supportsStatusLine: true,
|
|
29
|
+
hooksFile: join(H, ".claude", "settings.json"),
|
|
30
|
+
transcripts: { dir: join(H, ".claude", "projects"), note: "one folder per cwd slug, <session>.jsonl" },
|
|
31
|
+
detect: () => existsSync(join(H, ".claude")) || !!which("claude"),
|
|
32
|
+
mcpWire: ({ url, token }) => ({
|
|
33
|
+
command: 'claude mcp add --transport http --scope user agent-channel ' + url + '/mcp --header "Authorization: Bearer ' + token + '"',
|
|
34
|
+
apply: () => { const c = which("claude"); if (!c) return { ok: false, why: "claude CLI not on PATH; run the command yourself" };
|
|
35
|
+
try { execFileSync(c, ["mcp", "remove", "--scope", "user", "agent-channel"], { stdio: "ignore" }); } catch {}
|
|
36
|
+
try { execFileSync(c, ["mcp", "add", "--transport", "http", "--scope", "user", "agent-channel", url + "/mcp", "--header", "Authorization: Bearer " + token], { stdio: "pipe" }); return { ok: true }; }
|
|
37
|
+
catch (e) { return { ok: false, why: String(e.stderr || e.message).trim().slice(0, 200) }; } },
|
|
38
|
+
check: () => { const c = which("claude"); if (!c) return null; try { return execFileSync(c, ["mcp", "list"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).includes("agent-channel"); } catch { return null; } },
|
|
39
|
+
}),
|
|
40
|
+
hooksWire: ({ repo }) => ({
|
|
41
|
+
// merged into settings.json; existing hooks for other purposes are preserved (setup.mjs dedups by command substring)
|
|
42
|
+
hooks: {
|
|
43
|
+
SessionStart: [{ hooks: [{ type: "command", command: nodeCmd(repo, "hooks/inbox.mjs", "claude", "SessionStart"), timeout: 8, statusMessage: "Checking Agent Channel..." }, { type: "command", command: nodeCmd(repo, "hooks/claude-status.mjs", "working"), timeout: 6 }] }],
|
|
44
|
+
UserPromptSubmit: [{ hooks: [{ type: "command", command: nodeCmd(repo, "hooks/inbox.mjs", "claude", "UserPromptSubmit"), timeout: 8, statusMessage: "Checking Agent Channel..." }] }],
|
|
45
|
+
Stop: [{ hooks: [{ type: "command", command: nodeCmd(repo, "hooks/claude-status.mjs", "idle"), timeout: 6 }] }],
|
|
46
|
+
SessionEnd: [{ hooks: [{ type: "command", command: nodeCmd(repo, "hooks/claude-status.mjs", "offline"), timeout: 6 }] }],
|
|
47
|
+
FileChanged: [{ matcher: "agentchan_notify", hooks: [{ type: "command", command: nodeCmd(repo, "hooks/notify.mjs", "claude"), timeout: 5 }] }],
|
|
48
|
+
},
|
|
49
|
+
statusLine: { type: "command", command: nodeCmd(repo, "hooks/statusline.mjs", "claude"), refreshInterval: 2 },
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
codex: {
|
|
53
|
+
key: "codex", runtime: "codex", label: "Codex CLI", tokenEnv: "AGENTCHAN_CODEX_TOKEN",
|
|
54
|
+
rendersSystemMessage: false, blocksPrompt: false, supportsFileChanged: false, supportsStatusLine: false,
|
|
55
|
+
hooksFile: join(H, ".codex", "hooks.json"),
|
|
56
|
+
configFile: join(H, ".codex", "config.toml"),
|
|
57
|
+
transcripts: { dir: join(H, ".codex", "sessions"), note: "YYYY/MM/DD/rollout-*.jsonl" },
|
|
58
|
+
detect: () => existsSync(join(H, ".codex")) || !!which("codex"),
|
|
59
|
+
// Windows: setup.mjs sets AGENTCHAN_CODEX_TOKEN with setx, so the env-var form works. macOS/Linux have no per-user env
|
|
60
|
+
// store the Codex app reads, so the header goes into config.toml itself (same trust level as ~/.claude.json for Claude Code).
|
|
61
|
+
mcpWire: ({ url, token }) => {
|
|
62
|
+
const block = platform() === "win32" || !token
|
|
63
|
+
? "[mcp_servers.agent_channel]\nurl = \"" + url + "/mcp\"\nbearer_token_env_var = \"AGENTCHAN_CODEX_TOKEN\"\n"
|
|
64
|
+
: "[mcp_servers.agent_channel]\nurl = \"" + url + "/mcp\"\nhttp_headers = { Authorization = \"Bearer " + token + "\" }\n";
|
|
65
|
+
return {
|
|
66
|
+
command: "add to ~/.codex/config.toml:\n" + block.replace(/Bearer ac_[^"]+/, "Bearer <your token>"),
|
|
67
|
+
apply: () => {
|
|
68
|
+
const f = join(H, ".codex", "config.toml");
|
|
69
|
+
let cur = existsSync(f) ? readFileSync(f, "utf8") : "";
|
|
70
|
+
if (cur.includes("[mcp_servers.agent_channel]")) return { ok: true, note: "already present" };
|
|
71
|
+
mkdirSync(join(H, ".codex"), { recursive: true });
|
|
72
|
+
writeFileSync(f, cur + (cur.endsWith("\n") || !cur ? "" : "\n") + "\n" + block);
|
|
73
|
+
return { ok: true };
|
|
74
|
+
},
|
|
75
|
+
check: () => { const f = join(H, ".codex", "config.toml"); return existsSync(f) ? readFileSync(f, "utf8").includes("[mcp_servers.agent_channel]") : false; },
|
|
76
|
+
}; },
|
|
77
|
+
hooksWire: ({ repo }) => ({
|
|
78
|
+
hooks: {
|
|
79
|
+
SessionStart: [{ hooks: [{ type: "command", command: nodeCmd(repo, "hooks/inbox.mjs", "codex", "SessionStart"), timeout: 8, statusMessage: "Checking Agent Channel..." }] }],
|
|
80
|
+
UserPromptSubmit: [{ hooks: [{ type: "command", command: nodeCmd(repo, "hooks/inbox.mjs", "codex", "UserPromptSubmit"), timeout: 8, statusMessage: "Checking Agent Channel..." }] }],
|
|
81
|
+
},
|
|
82
|
+
note: "Codex asks you to trust hooks once via /hooks. It does not render systemMessage, so the model relays messages to you.",
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
"claude-desktop": {
|
|
86
|
+
key: "claude-desktop", runtime: "claude-desktop", label: "Claude Desktop", tokenEnv: "AGENTCHAN_TOKEN",
|
|
87
|
+
rendersSystemMessage: false, blocksPrompt: false, supportsFileChanged: false, supportsStatusLine: false,
|
|
88
|
+
configFile: platform() === "win32" ? join(process.env.APPDATA || join(H, "AppData", "Roaming"), "Claude", "claude_desktop_config.json")
|
|
89
|
+
: platform() === "darwin" ? join(H, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
90
|
+
: join(H, ".config", "Claude", "claude_desktop_config.json"),
|
|
91
|
+
detect() { return existsSync(dirnameOf(this.configFile)); },
|
|
92
|
+
// Desktop launches stdio servers only, so bridge with mcp-remote. With a token: static header. Without: mcp-remote runs the OAuth flow in the browser.
|
|
93
|
+
mcpWire({ url, token, oauth }) {
|
|
94
|
+
const file = this.configFile;
|
|
95
|
+
const args = ["-y", "mcp-remote", url + "/mcp", ...(token && !oauth ? ["--header", "Authorization: Bearer " + token] : [])];
|
|
96
|
+
return {
|
|
97
|
+
command: "add to " + file + ':\n"mcpServers": { "agent-channel": { "command": "npx", "args": ' + JSON.stringify(args) + " } }\n(or in Desktop: Settings > Connectors > Add custom connector > " + url + "/mcp and sign in)",
|
|
98
|
+
apply: () => {
|
|
99
|
+
let cur = {}; try { cur = JSON.parse(readFileSync(file, "utf8")); } catch {}
|
|
100
|
+
cur.mcpServers = { ...(cur.mcpServers || {}), "agent-channel": { command: "npx", args } };
|
|
101
|
+
mkdirSync(dirnameOf(file), { recursive: true });
|
|
102
|
+
writeFileSync(file, JSON.stringify(cur, null, 2));
|
|
103
|
+
return { ok: true, note: "restart Claude Desktop" };
|
|
104
|
+
},
|
|
105
|
+
check: () => { try { return !!JSON.parse(readFileSync(file, "utf8")).mcpServers?.["agent-channel"]; } catch { return false; } },
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
hooksWire: () => ({ note: "Claude Desktop has no hooks: typed @handle messages go through the model (send_message); inbound arrives via my_inbox or the listener's toast." }),
|
|
109
|
+
},
|
|
110
|
+
// JSON-config MCP clients (no hooks; the model does the messaging and reads my_inbox). Each writes one entry into the
|
|
111
|
+
// client's mcpServers file; tokens go in a header exactly like Claude Code's `claude mcp add --header`.
|
|
112
|
+
cursor: jsonMcpAdapter({ key: "cursor", runtime: "cursor", label: "Cursor", file: join(H, ".cursor", "mcp.json"), shape: "url" }),
|
|
113
|
+
gemini: jsonMcpAdapter({ key: "gemini", runtime: "gemini-cli", label: "Gemini CLI", file: join(H, ".gemini", "settings.json"), shape: "httpUrl" }),
|
|
114
|
+
windsurf: jsonMcpAdapter({ key: "windsurf", runtime: "windsurf", label: "Windsurf", file: join(H, ".codeium", "windsurf", "mcp_config.json"), shape: "serverUrl" }),
|
|
115
|
+
generic: {
|
|
116
|
+
key: "generic", runtime: "other", label: "Any MCP client", tokenEnv: "AGENTCHAN_TOKEN",
|
|
117
|
+
rendersSystemMessage: false, blocksPrompt: false, supportsFileChanged: false, supportsStatusLine: false,
|
|
118
|
+
detect: () => true,
|
|
119
|
+
mcpWire: ({ url, token }) => ({ command: "Streamable HTTP MCP: " + url + "/mcp with header Authorization: Bearer " + token, apply: () => ({ ok: false, why: "wire it in your client's MCP settings" }), check: () => null }),
|
|
120
|
+
hooksWire: () => ({ note: "No hook system: use scripts/cli.mjs and the listener; type-to-send needs a UserPromptSubmit-style hook in your client." }),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function jsonMcpAdapter({ key, runtime, label, file, shape }) {
|
|
125
|
+
const entry = (url, token) => shape === "httpUrl" ? { httpUrl: url + "/mcp", headers: { Authorization: "Bearer " + token } }
|
|
126
|
+
: shape === "serverUrl" ? { serverUrl: url + "/mcp", headers: { Authorization: "Bearer " + token } }
|
|
127
|
+
: { url: url + "/mcp", headers: { Authorization: "Bearer " + token } };
|
|
128
|
+
return {
|
|
129
|
+
key, runtime, label, tokenEnv: "AGENTCHAN_TOKEN",
|
|
130
|
+
rendersSystemMessage: false, blocksPrompt: false, supportsFileChanged: false, supportsStatusLine: false,
|
|
131
|
+
configFile: file,
|
|
132
|
+
detect: () => existsSync(dirnameOf(file)),
|
|
133
|
+
mcpWire: ({ url, token }) => ({
|
|
134
|
+
command: "add to " + file + ':\n"mcpServers": { "agent-channel": ' + JSON.stringify(entry(url, "<your token>")) + " }",
|
|
135
|
+
apply: () => {
|
|
136
|
+
let cur = {}; try { cur = JSON.parse(readFileSync(file, "utf8")); } catch {}
|
|
137
|
+
cur.mcpServers = { ...(cur.mcpServers || {}), "agent-channel": entry(url, token) };
|
|
138
|
+
mkdirSync(dirnameOf(file), { recursive: true });
|
|
139
|
+
writeFileSync(file, JSON.stringify(cur, null, 2));
|
|
140
|
+
return { ok: true, note: "restart " + label };
|
|
141
|
+
},
|
|
142
|
+
check: () => { try { return !!JSON.parse(readFileSync(file, "utf8")).mcpServers?.["agent-channel"]; } catch { return false; } },
|
|
143
|
+
}),
|
|
144
|
+
hooksWire: () => ({ note: label + " has no hook system: typed @handle messages go through the model (send_message); inbound arrives when the model reads my_inbox (the server instructions tell it to at session start)." }),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const adapterFor = (nameOrRuntime) => {
|
|
149
|
+
const k = String(nameOrRuntime || "").toLowerCase().replace(/-code$/, "").replace(/-cli$/, "");
|
|
150
|
+
return ADAPTERS[k] || (k === "claude-code" ? ADAPTERS.claude : null) || ADAPTERS.generic;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export function which(bin) {
|
|
154
|
+
try { return execFileSync(platform() === "win32" ? "where" : "which", [bin], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split(/\r?\n/).filter(Boolean)[0] || null; }
|
|
155
|
+
catch { return null; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Merge our hook entries into an existing hooks JSON without clobbering unrelated hooks (dedup by command substring 'agent-channel/hooks'). */
|
|
159
|
+
export function mergeHooks(existing, ours) {
|
|
160
|
+
const out = { ...(existing || {}) };
|
|
161
|
+
out.hooks = { ...(out.hooks || {}) };
|
|
162
|
+
for (const [ev, entries] of Object.entries(ours.hooks || {})) {
|
|
163
|
+
const cur = Array.isArray(out.hooks[ev]) ? out.hooks[ev] : [];
|
|
164
|
+
const isOurs = (h) => /(agent-channel|\.agentchan[\\/]client)[\\/]hooks[\\/]/i.test(h.command || "");
|
|
165
|
+
const kept = cur.map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !isOurs(h)) })).filter((g) => (g.hooks || []).length || g.matcher);
|
|
166
|
+
out.hooks[ev] = [...kept.filter((g) => (g.hooks || []).length), ...entries];
|
|
167
|
+
}
|
|
168
|
+
if (ours.statusLine && !out.statusLine) out.statusLine = ours.statusLine;
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Fetch + decrypt + inspect one artifact onto this machine. Shared by the CLI and the resident listener.
|
|
2
|
+
import { writeFileSync, mkdirSync, appendFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { decryptWith, loadLocalKeys, sha256hex } from "./crypto.mjs";
|
|
6
|
+
import { inspectArtifact, safeName } from "./inspect.mjs";
|
|
7
|
+
|
|
8
|
+
export async function fetchArtifact({ base, token, handle, id, quiet = false }) {
|
|
9
|
+
const r = await fetch(base + "/artifacts/" + id, { headers: { authorization: "Bearer " + token } });
|
|
10
|
+
const a = await r.json().catch(() => ({}));
|
|
11
|
+
if (!r.ok) throw new Error("/artifacts/" + id + " -> " + r.status + " " + (a.error || ""));
|
|
12
|
+
const keys = loadLocalKeys(handle);
|
|
13
|
+
const plain = decryptWith(keys, a.envelope, a.ciphertext);
|
|
14
|
+
const actual = sha256hex(plain);
|
|
15
|
+
const report = inspectArtifact({ filename: a.filename, bytes: plain, declaredSha256: a.sha256, actualSha256: actual });
|
|
16
|
+
const sub = report.verdict === "danger" ? "quarantine" : "inbox";
|
|
17
|
+
const dir = join(homedir(), ".agentchan", handle, sub, id.slice(0, 8));
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
const file = join(dir, safeName(a.filename));
|
|
20
|
+
writeFileSync(file, plain);
|
|
21
|
+
const rec = { id, from: a.from, filename: a.filename, size: plain.length, sha256: actual, note: a.note, verdict: report.verdict, findings: report.findings, path: file, received_at: new Date().toISOString() };
|
|
22
|
+
writeFileSync(join(dir, "report.json"), JSON.stringify(rec, null, 2));
|
|
23
|
+
appendFileSync(join(homedir(), ".agentchan", handle, "artifacts.jsonl"), JSON.stringify(rec) + "\n");
|
|
24
|
+
if (!quiet) {
|
|
25
|
+
console.log((report.verdict === "danger" ? "QUARANTINED " : report.verdict === "warn" ? "WARN " : "ok ") + a.filename + " from " + a.from + " (" + plain.length + " bytes) -> " + file);
|
|
26
|
+
for (const f of report.findings) console.log(" [" + f.level + "] " + f.what + (f.detail ? " :: " + f.detail : ""));
|
|
27
|
+
}
|
|
28
|
+
return rec;
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Push an Agent Channel event INTO a live Codex TUI thread, via the Codex app-server protocol.
|
|
2
|
+
// Requires: `codex app-server --listen ws://127.0.0.1:4517` running (run_codex_appserver.cmd) and the TUI
|
|
3
|
+
// started with `codex --remote ws://127.0.0.1:4517` so its thread is loaded on that server.
|
|
4
|
+
//
|
|
5
|
+
// mode "inject": thread/inject_items — appended to the model-visible history, no turn, no cost; the model
|
|
6
|
+
// sees it on the human's next prompt (may not render in the TUI).
|
|
7
|
+
// mode "turn": turn/start — a real user turn carrying the message; renders in the TUI immediately and the
|
|
8
|
+
// model relays it in one line. Costs one (short) model turn. Use when the human wants to SEE it.
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
|
|
11
|
+
export async function pushToCodex({ url = process.env.AGENTCHAN_CODEX_WS || "ws://127.0.0.1:4517", text, mode = "turn", timeoutMs = 8000 }) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let done = false;
|
|
14
|
+
const finish = (r) => { if (!done) { done = true; try { ws.close(); } catch {} resolve(r); } };
|
|
15
|
+
const ws = new WebSocket(url);
|
|
16
|
+
let id = 0; const pending = new Map();
|
|
17
|
+
const call = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, method, params })); });
|
|
18
|
+
const t = setTimeout(() => finish({ ok: false, reason: "timeout" }), timeoutMs);
|
|
19
|
+
ws.on("error", (e) => { clearTimeout(t); finish({ ok: false, reason: e.message }); });
|
|
20
|
+
ws.on("message", (b) => {
|
|
21
|
+
let m; try { m = JSON.parse(b.toString()); } catch { return; }
|
|
22
|
+
if (m.id !== undefined && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(JSON.stringify(m.error))) : p.res(m.result); }
|
|
23
|
+
});
|
|
24
|
+
ws.on("open", async () => {
|
|
25
|
+
try {
|
|
26
|
+
await call("initialize", { clientInfo: { name: "agent-channel", title: "Agent Channel", version: "0.2.0" }, capabilities: {} });
|
|
27
|
+
ws.send(JSON.stringify({ jsonrpc: "2.0", method: "initialized", params: {} }));
|
|
28
|
+
const loaded = await call("thread/loaded/list", {});
|
|
29
|
+
const threads = loaded?.data || [];
|
|
30
|
+
if (!threads.length) { clearTimeout(t); return finish({ ok: false, reason: "no loaded thread (start Codex with --remote " + url + ")" }); }
|
|
31
|
+
// most recently active loaded thread
|
|
32
|
+
threads.sort((a, b) => (b.recencyAt || b.updatedAt || 0) - (a.recencyAt || a.updatedAt || 0));
|
|
33
|
+
const tid = threads[0].id;
|
|
34
|
+
if (mode === "inject") {
|
|
35
|
+
await call("thread/inject_items", { threadId: tid, items: [{ type: "message", role: "user", content: [{ type: "input_text", text }] }] });
|
|
36
|
+
clearTimeout(t); return finish({ ok: true, mode, threadId: tid });
|
|
37
|
+
}
|
|
38
|
+
const turn = await call("turn/start", { threadId: tid, input: [{ type: "text", text }] });
|
|
39
|
+
clearTimeout(t); return finish({ ok: true, mode, threadId: tid, turnId: turn?.turn?.id || turn?.turnId || null });
|
|
40
|
+
} catch (e) { clearTimeout(t); finish({ ok: false, reason: e.message }); }
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
package/lib/crypto.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// End-to-end artifact encryption. Runs on the sender's and receiver's machines only.
|
|
2
|
+
// Scheme (v1): random 256-bit content key -> AES-256-GCM over the file.
|
|
3
|
+
// For each recipient X25519 public key: ephemeral X25519 keypair -> ECDH -> HKDF-SHA256 -> AES-256-GCM wrap of the content key.
|
|
4
|
+
// The server stores the envelope (public data) and the ciphertext; it never holds a private key or the content key.
|
|
5
|
+
|
|
6
|
+
import { generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, hkdfSync, createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
|
|
11
|
+
export const ALG = "x25519-hkdf-sha256-aes256gcm-v1";
|
|
12
|
+
const INFO = Buffer.from("agentchan-artifact-v1");
|
|
13
|
+
|
|
14
|
+
export const sha256hex = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
15
|
+
|
|
16
|
+
/** New X25519 keypair as base64 SPKI/PKCS8 DER. */
|
|
17
|
+
export function generateKeypair() {
|
|
18
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
19
|
+
return {
|
|
20
|
+
public_key: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
|
21
|
+
private_key: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const pub = (b64) => createPublicKey({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
|
|
25
|
+
const priv = (b64) => createPrivateKey({ key: Buffer.from(b64, "base64"), format: "der", type: "pkcs8" });
|
|
26
|
+
|
|
27
|
+
function kek(sharedSecret, ephPubB64, recipPubB64) {
|
|
28
|
+
const salt = Buffer.concat([Buffer.from(ephPubB64, "base64"), Buffer.from(recipPubB64, "base64")]);
|
|
29
|
+
return Buffer.from(hkdfSync("sha256", sharedSecret, salt, INFO, 32));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Encrypt plaintext for a list of recipient keys [{id, public_key}]. Returns { envelope, ciphertext(base64) }. */
|
|
33
|
+
export function encryptFor(recipients, plaintext) {
|
|
34
|
+
if (!recipients?.length) throw new Error("no recipient keys");
|
|
35
|
+
const ck = randomBytes(32);
|
|
36
|
+
const iv = randomBytes(12);
|
|
37
|
+
const c = createCipheriv("aes-256-gcm", ck, iv);
|
|
38
|
+
const body = Buffer.concat([c.update(plaintext), c.final()]);
|
|
39
|
+
const tag = c.getAuthTag();
|
|
40
|
+
const keys = recipients.map((r) => {
|
|
41
|
+
const eph = generateKeyPairSync("x25519");
|
|
42
|
+
const ephPub = eph.publicKey.export({ type: "spki", format: "der" }).toString("base64");
|
|
43
|
+
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: pub(r.public_key) });
|
|
44
|
+
const k = kek(shared, ephPub, r.public_key);
|
|
45
|
+
const wiv = randomBytes(12);
|
|
46
|
+
const wc = createCipheriv("aes-256-gcm", k, wiv);
|
|
47
|
+
const wrapped = Buffer.concat([wc.update(ck), wc.final()]);
|
|
48
|
+
return { key_id: r.id, eph_pub: ephPub, iv: wiv.toString("base64"), tag: wc.getAuthTag().toString("base64"), wrapped: wrapped.toString("base64") };
|
|
49
|
+
});
|
|
50
|
+
const envelope = { v: 1, alg: ALG, iv: iv.toString("base64"), tag: tag.toString("base64"), keys };
|
|
51
|
+
return { envelope, ciphertext: body.toString("base64") };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Decrypt with one of our local keys [{key_id, public_key, private_key}]. Returns Buffer or throws. */
|
|
55
|
+
export function decryptWith(localKeys, envelope, ciphertextB64) {
|
|
56
|
+
if (envelope?.alg !== ALG) throw new Error("unknown envelope alg " + envelope?.alg);
|
|
57
|
+
for (const lk of localKeys) {
|
|
58
|
+
const slot = envelope.keys.find((k) => k.key_id === lk.key_id);
|
|
59
|
+
if (!slot) continue;
|
|
60
|
+
const shared = diffieHellman({ privateKey: priv(lk.private_key), publicKey: pub(slot.eph_pub) });
|
|
61
|
+
const k = kek(shared, slot.eph_pub, lk.public_key);
|
|
62
|
+
const wd = createDecipheriv("aes-256-gcm", k, Buffer.from(slot.iv, "base64"));
|
|
63
|
+
wd.setAuthTag(Buffer.from(slot.tag, "base64"));
|
|
64
|
+
const ck = Buffer.concat([wd.update(Buffer.from(slot.wrapped, "base64")), wd.final()]);
|
|
65
|
+
const d = createDecipheriv("aes-256-gcm", ck, Buffer.from(envelope.iv, "base64"));
|
|
66
|
+
d.setAuthTag(Buffer.from(envelope.tag, "base64"));
|
|
67
|
+
return Buffer.concat([d.update(Buffer.from(ciphertextB64, "base64")), d.final()]);
|
|
68
|
+
}
|
|
69
|
+
throw new Error("this artifact was not encrypted to any key on this machine (" + localKeys.length + " local keys)");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- local key store: ~/.agentchan/<handle>/keys/<label>.json { key_id, public_key, private_key, label, created_at } ----
|
|
73
|
+
export const keyDir = (handle) => join(homedir(), ".agentchan", handle, "keys");
|
|
74
|
+
export function loadLocalKeys(handle) {
|
|
75
|
+
const dir = keyDir(handle);
|
|
76
|
+
if (!existsSync(dir)) return [];
|
|
77
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => { try { return JSON.parse(readFileSync(join(dir, f), "utf8")); } catch { return null; } }).filter((k) => k && k.private_key);
|
|
78
|
+
}
|
|
79
|
+
export function saveLocalKey(handle, label, key) {
|
|
80
|
+
const dir = keyDir(handle); mkdirSync(dir, { recursive: true });
|
|
81
|
+
const f = join(dir, label.replace(/[^a-z0-9_-]/gi, "_") + ".json");
|
|
82
|
+
writeFileSync(f, JSON.stringify({ ...key, label, created_at: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
83
|
+
return f;
|
|
84
|
+
}
|
|
85
|
+
export function findLocalKey(handle, label) {
|
|
86
|
+
return loadLocalKeys(handle).find((k) => k.label === label) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Ensure this (handle, label) has a registered key on the server. Returns the local key. */
|
|
90
|
+
export async function ensureKey({ base, token, handle, label }) {
|
|
91
|
+
let key = findLocalKey(handle, label);
|
|
92
|
+
if (key && key.key_id) return key;
|
|
93
|
+
const kp = key || generateKeypair();
|
|
94
|
+
const r = await fetch(base + "/keys", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer " + token }, body: JSON.stringify({ public_key: kp.public_key, label }) });
|
|
95
|
+
if (!r.ok) throw new Error("key registration failed: " + r.status + " " + await r.text());
|
|
96
|
+
const { key_id } = await r.json();
|
|
97
|
+
key = { ...kp, key_id };
|
|
98
|
+
saveLocalKey(handle, label, key);
|
|
99
|
+
return key;
|
|
100
|
+
}
|
package/lib/inspect.mjs
ADDED
|
Binary file
|
package/lib/paths.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Where the client keeps things, independent of where the code runs from.
|
|
2
|
+
//
|
|
3
|
+
// The client (hooks, listener, setup, artifact, share) can run from a git checkout of the repo, from a global install, from
|
|
4
|
+
// the persistent copy `setup.mjs join` makes under ~/.agentchan/client, or from an npx cache that vanishes. Tokens therefore
|
|
5
|
+
// live in the HOME store, not next to the code:
|
|
6
|
+
// ~/.agentchan/tok.<runtime>.json { token, runtime, base, handle } (0600 where the OS honours it)
|
|
7
|
+
// with the old in-repo `.tok.<runtime>.json` still read as a fallback so existing installs keep working.
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join, dirname, resolve } from "node:path";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const HOME_STORE = join(homedir(), ".agentchan");
|
|
14
|
+
export const CLIENT_HOME = join(HOME_STORE, "client"); // persistent copy of the client package (setup.mjs join)
|
|
15
|
+
export const CLIENT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // wherever this code runs from
|
|
16
|
+
export const BASE = (process.env.AGENTCHAN_URL || "https://agent-channel-production.up.railway.app").replace(/\/mcp$/, "");
|
|
17
|
+
export const IN_NPX_CACHE = /[\\/]_npx[\\/]|[\\/]\.npm[\\/]_cacache|[\\/]npm-cache[\\/]/i.test(CLIENT_ROOT);
|
|
18
|
+
|
|
19
|
+
export const tokFileHome = (key) => join(HOME_STORE, "tok." + key + ".json");
|
|
20
|
+
export const tokFileRepo = (key) => join(CLIENT_ROOT, ".tok." + key + ".json");
|
|
21
|
+
|
|
22
|
+
const parseTok = (t) => { try { return JSON.parse(t); } catch { const m = t.match(/"token"\s*:\s*"([^"]+)"/); return m ? { token: m[1], handle: t.match(/"(?:person|handle)"\s*:\s*"([^"]+)"/)?.[1] } : null; } };
|
|
23
|
+
|
|
24
|
+
/** Read the saved token record for a runtime key (claude | codex | claude-desktop), home store first, then the repo file. */
|
|
25
|
+
export function readTok(key) {
|
|
26
|
+
for (const f of [tokFileHome(key), tokFileRepo(key), join(homedir(), "agent-channel", ".tok." + key + ".json")]) {
|
|
27
|
+
try { if (existsSync(f)) { const r = parseTok(readFileSync(f, "utf8")); if (r?.token) return { ...r, file: f }; } } catch {}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
export function saveTok(key, obj) {
|
|
32
|
+
mkdirSync(HOME_STORE, { recursive: true });
|
|
33
|
+
const f = tokFileHome(key);
|
|
34
|
+
writeFileSync(f, JSON.stringify(obj, null, 2));
|
|
35
|
+
try { chmodSync(f, 0o600); } catch {}
|
|
36
|
+
return f;
|
|
37
|
+
}
|
|
38
|
+
/** Token for a runtime: env first (AGENTCHAN_TOKEN / AGENTCHAN_CODEX_TOKEN), then the saved record. */
|
|
39
|
+
export function tokenFor(key) {
|
|
40
|
+
const k = String(key || "claude").toLowerCase();
|
|
41
|
+
const env = k === "codex" ? (process.env.AGENTCHAN_CODEX_TOKEN || null) : (process.env.AGENTCHAN_TOKEN || null);
|
|
42
|
+
if (env) return env;
|
|
43
|
+
const r = readTok(k) || (k === "claude-desktop" ? readTok("claude") : null);
|
|
44
|
+
return r?.token || null;
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@amkentech/agent-channel",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Send your Claude Code or Codex session, or a file, to another person in one line: encrypted read-only links (no account), or into a teammate's inbox via hooks + a remote MCP server. The server is a separate, private service.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "agent-channel": "bin/agent-channel.mjs" },
|
|
7
|
+
"files": ["bin", "hooks", "lib", "scripts", "db", "README.md"],
|
|
8
|
+
"engines": { "node": ">=22" },
|
|
9
|
+
"keywords": ["mcp", "agents", "claude-code", "codex", "collaboration"],
|
|
10
|
+
"homepage": "https://agent-channel-production.up.railway.app/",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.17.0",
|
|
14
|
+
"ws": "^8.21.3"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// End-to-end encrypted artifact exchange. The server only ever sees ciphertext + an envelope.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/artifact.mjs send @handle <path> [--note "why"] encrypt to every key @handle has registered, upload
|
|
5
|
+
// node scripts/artifact.mjs fetch <artifact_id> download, decrypt with a local key, inspect, save to ~/.agentchan/<me>/inbox/
|
|
6
|
+
// node scripts/artifact.mjs fetch --all fetch everything waiting for me
|
|
7
|
+
// node scripts/artifact.mjs keygen [--label name] create + register a key for this token (listener does this automatically)
|
|
8
|
+
// node scripts/artifact.mjs keys [@handle] list registered public keys
|
|
9
|
+
//
|
|
10
|
+
// Token: AGENTCHAN_TOKEN (or --runtime codex -> AGENTCHAN_CODEX_TOKEN). URL: AGENTCHAN_URL.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, statSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
|
|
13
|
+
import { basename, join, resolve, dirname } from "node:path";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { encryptFor, ensureKey, sha256hex } from "../lib/crypto.mjs";
|
|
17
|
+
import { fetchArtifact } from "../lib/artifacts.mjs";
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
21
|
+
const has = (name) => args.includes(name);
|
|
22
|
+
const runtime = (flag("--runtime") || process.env.AGENTCHAN_RUNTIME || "claude").toLowerCase();
|
|
23
|
+
const BASE = (process.env.AGENTCHAN_URL || "https://agent-channel-production.up.railway.app").replace(/\/mcp$/, "");
|
|
24
|
+
let token = process.env.AGENTCHAN_TOKEN;
|
|
25
|
+
if (runtime === "codex" && process.env.AGENTCHAN_CODEX_TOKEN) token = process.env.AGENTCHAN_CODEX_TOKEN;
|
|
26
|
+
if (!token) { const { tokenFor } = await import("../lib/paths.mjs"); token = tokenFor(runtime); }
|
|
27
|
+
if (!token) { console.error("AGENTCHAN_TOKEN required"); process.exit(1); }
|
|
28
|
+
const H = { authorization: "Bearer " + token, "content-type": "application/json" };
|
|
29
|
+
|
|
30
|
+
const api = async (path, init = {}) => {
|
|
31
|
+
const r = await fetch(BASE + path, { ...init, headers: { ...H, ...(init.headers || {}) } });
|
|
32
|
+
const j = await r.json().catch(() => ({}));
|
|
33
|
+
if (!r.ok) throw new Error(path + " -> " + r.status + " " + (j.error || JSON.stringify(j)));
|
|
34
|
+
return j;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function myHandle() {
|
|
38
|
+
// /feed carries all agents; find ours by matching token is impossible, so use the MCP-free route: /keys/<x> requires handle. Use /status? No.
|
|
39
|
+
// Simplest: the server tells us in the WebSocket hello, but here we do one MCP whoami call.
|
|
40
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
41
|
+
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
42
|
+
const c = new Client({ name: "artifact", version: "0.0.1" });
|
|
43
|
+
await c.connect(new StreamableHTTPClientTransport(new URL(BASE + "/mcp"), { requestInit: { headers: { authorization: "Bearer " + token } } }));
|
|
44
|
+
const r = await c.callTool({ name: "whoami", arguments: {} });
|
|
45
|
+
await c.close();
|
|
46
|
+
const j = JSON.parse(r.content[0].text);
|
|
47
|
+
return { handle: j.person.handle, agent: j.agent.name, runtime: j.agent.runtime };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cmd = args[0];
|
|
51
|
+
// ---- key pins: ~/.agentchan/pins/<handle>.json [{id, public_key, label, runtime, first_seen}] ----
|
|
52
|
+
const pinDir = join(homedir(), ".agentchan", "pins");
|
|
53
|
+
const pinFile = (h) => join(pinDir, String(h).replace(/^@/, "").toLowerCase() + ".json");
|
|
54
|
+
const fp = (pub) => createHash("sha256").update(String(pub)).digest("hex").match(/.{4}/g).slice(0, 8).join(" ");
|
|
55
|
+
function checkPins(to, keys) {
|
|
56
|
+
let pinned = []; try { pinned = JSON.parse(readFileSync(pinFile(to), "utf8")); } catch {}
|
|
57
|
+
const fresh = keys.filter((k) => !pinned.some((p) => p.id === k.id || p.public_key === k.public_key));
|
|
58
|
+
return { pinned, fresh };
|
|
59
|
+
}
|
|
60
|
+
function savePins(to, keys) {
|
|
61
|
+
let cur = []; try { cur = JSON.parse(readFileSync(pinFile(to), "utf8")); } catch {}
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
for (const k of keys) if (!cur.some((p) => p.id === k.id || p.public_key === k.public_key)) cur.push({ id: k.id, public_key: k.public_key, label: k.label, runtime: k.runtime, fingerprint: fp(k.public_key), first_seen: now });
|
|
64
|
+
mkdirSync(pinDir, { recursive: true }); writeFileSync(pinFile(to), JSON.stringify(cur, null, 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
if (cmd === "send") {
|
|
69
|
+
const to = args[1], path = args[2];
|
|
70
|
+
if (!to || !path) throw new Error("usage: artifact.mjs send @handle <path> [--note text]");
|
|
71
|
+
const abs = resolve(path);
|
|
72
|
+
const st = statSync(abs);
|
|
73
|
+
if (st.size > 8 * 1024 * 1024) throw new Error("file is " + st.size + " bytes; 8 MB max");
|
|
74
|
+
const bytes = readFileSync(abs);
|
|
75
|
+
const { keys } = await api("/keys/" + to.replace(/^@/, ""));
|
|
76
|
+
if (!keys.length) throw new Error(to + " has no registered keys yet (their listener registers one on first connect). Ask them to run: node scripts/artifact.mjs keygen");
|
|
77
|
+
// Key pinning (trust on first use). The server hands out the recipient's public keys; a malicious operator could add one
|
|
78
|
+
// and read everything encrypted from then on. So remember the keys we have seen per handle, and refuse to encrypt to a
|
|
79
|
+
// NEW key until the human says so (--trust-new-keys), after comparing fingerprints with the other person out of band.
|
|
80
|
+
const { pinned, fresh } = checkPins(to, keys);
|
|
81
|
+
if (fresh.length && pinned.length && !has("--trust-new-keys")) {
|
|
82
|
+
console.error("REFUSED: " + to + " has " + fresh.length + " key(s) you have never encrypted to before:\n" + fresh.map((k) => " " + fp(k.public_key) + " " + (k.label || "") + " (" + (k.runtime || "?") + ")").join("\n") +
|
|
83
|
+
"\nAsk " + to + " to confirm these fingerprints (they run: node scripts/artifact.mjs keys), then re-run with --trust-new-keys. Or --only-pinned to encrypt to the known keys only.");
|
|
84
|
+
process.exit(3);
|
|
85
|
+
}
|
|
86
|
+
const useKeys = has("--only-pinned") && pinned.length ? keys.filter((k) => pinned.some((p) => p.id === k.id)) : keys;
|
|
87
|
+
if (!pinned.length) {
|
|
88
|
+
console.error("first send to " + to + ": " + keys.length + " key(s) the server reports for them:\n" + keys.map((k) => " " + fp(k.public_key) + " " + (k.label || "") + " (" + (k.runtime || "?") + ")").join("\n") + "\nThese get pinned; later changes are refused until you pass --trust-new-keys. If this file matters, confirm the fingerprints with " + to + " out of band (they run: node scripts/artifact.mjs keys).");
|
|
89
|
+
if (process.stdin.isTTY && !has("--trust-new-keys") && !has("--yes")) {
|
|
90
|
+
const { createInterface } = await import("node:readline/promises");
|
|
91
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
92
|
+
const ans = (await rl.question("Encrypt to these keys and pin them? [y/N] ")).trim().toLowerCase(); rl.close();
|
|
93
|
+
if (ans !== "y" && ans !== "yes") { console.error("not sent."); process.exit(3); }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (fresh.length) console.error("trusting " + fresh.length + " new key(s) for " + to + " as instructed: " + fresh.map((k) => fp(k.public_key)).join(", "));
|
|
97
|
+
savePins(to, useKeys);
|
|
98
|
+
const { envelope, ciphertext } = encryptFor(useKeys, bytes);
|
|
99
|
+
const r = await api("/artifacts", { method: "POST", body: JSON.stringify({ to, filename: basename(abs), size_bytes: st.size, sha256: sha256hex(bytes), note: flag("--note"), envelope, ciphertext }) });
|
|
100
|
+
console.log("sent " + basename(abs) + " (" + st.size + " bytes) to " + r.to + ", encrypted to " + keys.length + " key(s), artifact " + r.artifact_id + ", expires " + r.expires_at);
|
|
101
|
+
} else if (cmd === "fetch") {
|
|
102
|
+
const me = await myHandle();
|
|
103
|
+
if (has("--all") || !args[1]) {
|
|
104
|
+
const list = await api("/artifacts");
|
|
105
|
+
if (!list.artifacts.length) console.log("nothing waiting");
|
|
106
|
+
for (const a of list.artifacts) await fetchArtifact({ base: BASE, token, handle: me.handle, id: a.id });
|
|
107
|
+
} else {
|
|
108
|
+
await fetchArtifact({ base: BASE, token, handle: me.handle, id: args[1] });
|
|
109
|
+
}
|
|
110
|
+
} else if (cmd === "keygen") {
|
|
111
|
+
const me = await myHandle();
|
|
112
|
+
const label = flag("--label") || (me.agent + "-" + me.runtime + "-" + (process.env.COMPUTERNAME || process.env.HOSTNAME || "host")).toLowerCase();
|
|
113
|
+
const k = await ensureKey({ base: BASE, token, handle: me.handle, label });
|
|
114
|
+
console.log("key registered for @" + me.handle + " label=" + label + " key_id=" + k.key_id);
|
|
115
|
+
} else if (cmd === "pins") {
|
|
116
|
+
const f = pinFile(args[1] || "");
|
|
117
|
+
console.log(args[1] ? (existsSync(f) ? readFileSync(f, "utf8") : "no pins for " + args[1]) : readdirSync(dirname(f)).filter((x) => x.endsWith(".json")).join("\n"));
|
|
118
|
+
} else if (cmd === "keys") {
|
|
119
|
+
const who = args[1] ? args[1].replace(/^@/, "") : (await myHandle()).handle;
|
|
120
|
+
const r = await api("/keys/" + who);
|
|
121
|
+
console.log(JSON.stringify({ ...r, keys: r.keys.map((k) => ({ fingerprint: fp(k.public_key), ...k })) }, null, 2));
|
|
122
|
+
} else {
|
|
123
|
+
console.log("usage: artifact.mjs send @handle <path> [--note text] [--trust-new-keys|--only-pinned] | fetch <id>|--all | keygen [--label x] | keys [@handle] | pins [@handle]");
|
|
124
|
+
process.exit(cmd ? 1 : 0);
|
|
125
|
+
}
|
|
126
|
+
} catch (e) { console.error("artifact: " + e.message); process.exit(2); }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Offline verifier for Agent Channel exports. No database access; optionally one fetch for the public key.
|
|
3
|
+
// node scripts/audit-verify.mjs export.json audit_trail mode=export (signed wrapper or bare)
|
|
4
|
+
// node scripts/audit-verify.mjs --record record.json export_contract / GET /c/:id/record.json
|
|
5
|
+
// options: --pubkey <pem file> | --pubkey-url <url> (default: the server named in the export), --no-sig (skip signature)
|
|
6
|
+
// Checks: every ledger row's hash from its canonical string, every visible chain link, and (if present) the server's Ed25519
|
|
7
|
+
// signature over the sha256 of the canonical JSON body, against a public key you supply or fetch. Pin the key out of band
|
|
8
|
+
// if this matters to you: a key fetched from the same server proves only that the server signed it.
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { createHash, createPublicKey, verify as edVerify } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const opt = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null; };
|
|
14
|
+
const file = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--pubkey" && args[i - 1] !== "--pubkey-url");
|
|
15
|
+
if (!file) { console.error("usage: audit-verify.mjs [--record] <file.json> [--pubkey file.pem | --pubkey-url url | --no-sig]"); process.exit(1); }
|
|
16
|
+
let doc = JSON.parse(readFileSync(file, "utf8"));
|
|
17
|
+
if (doc.content?.[0]?.text) doc = JSON.parse(doc.content[0].text); // raw MCP tool result
|
|
18
|
+
if (doc.record && doc.digest_sha256 === undefined && doc.signature) doc = { body: doc.record, digest_sha256: doc.digest_sha256, signature: doc.signature }; // export_contract tool output
|
|
19
|
+
const wrapped = doc.body ? doc : null; // signed wrapper { body, digest_sha256, signature }
|
|
20
|
+
const body = wrapped ? wrapped.body : doc;
|
|
21
|
+
let problems = 0;
|
|
22
|
+
|
|
23
|
+
// ---- 1. ledger rows (audit export: body.entries; contract record: body.timeline has hash+prev_hash but no canonical) ----
|
|
24
|
+
const canonJson = (v) => v === null || v === undefined || typeof v !== "object" ? JSON.stringify(v === undefined ? null : v) : v instanceof Date ? JSON.stringify(v.toISOString()) : Array.isArray(v) ? "[" + v.map((x) => (x === undefined ? "null" : canonJson(x))).join(",") + "]" : "{" + Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => JSON.stringify(k) + ":" + canonJson(v[k])).join(",") + "}";
|
|
25
|
+
const canonical = (e) => e.canonical ?? ((e.prev_hash ?? "") + "|" + String(e.seq) + "|" + e.at_canon + "|" + (e.actor_person ?? "") + "|" + (e.actor_agent ?? "") + "|" + (e.subject_person ?? "") + "|" + e.action + "|" + (e.object_type ?? "") + "|" + (e.object_id ?? "") + "|" + e.payload_text);
|
|
26
|
+
const entries = (body.entries || body.timeline || []).slice().sort((a, b) => Number(a.seq) - Number(b.seq));
|
|
27
|
+
if (entries.length) {
|
|
28
|
+
let bad = 0, links = 0, gaps = 0, hashed = 0;
|
|
29
|
+
for (let i = 0; i < entries.length; i++) {
|
|
30
|
+
const e = entries[i];
|
|
31
|
+
if (e.canonical || e.payload_text !== undefined) { hashed++; const h = createHash("sha256").update(canonical(e), "utf8").digest("hex"); if (h !== e.hash) { bad++; console.log("HASH MISMATCH seq " + e.seq + " (" + e.action + ")"); } }
|
|
32
|
+
if (i > 0) { const prev = entries[i - 1]; if (Number(e.seq) === Number(prev.seq) + 1) { links++; if (e.prev_hash !== prev.hash) { bad++; console.log("BROKEN LINK " + prev.seq + " -> " + e.seq); } } else gaps++; }
|
|
33
|
+
}
|
|
34
|
+
problems += bad;
|
|
35
|
+
console.log((bad ? "LEDGER FAIL" : "ledger ok") + ": " + entries.length + " rows, " + hashed + " hashes recomputed, " + links + " adjacent links checked, " + gaps + " gaps (other objects' rows between; expected)" + (bad ? ", " + bad + " problems" : ""));
|
|
36
|
+
console.log(" range: seq " + entries[0].seq + " (" + entries[0].at + ") .. seq " + entries.at(-1).seq + " (" + entries.at(-1).at + ")" + (body.for ? " for " + body.for : body.contract ? " for contract " + body.contract.id : ""));
|
|
37
|
+
if (body.chain) console.log(" server-side chain check over that range: " + (body.chain.intact === true ? "intact" : body.chain.intact === false ? "BROKEN at " + JSON.stringify(body.chain.detail) : "n/a"));
|
|
38
|
+
} else console.log("no ledger rows in this file");
|
|
39
|
+
|
|
40
|
+
// ---- 2. signature ----
|
|
41
|
+
if (wrapped && !args.includes("--no-sig")) {
|
|
42
|
+
const digest = createHash("sha256").update(canonJson(wrapped.body)).digest("hex");
|
|
43
|
+
if (digest !== wrapped.digest_sha256) { problems++; console.log("DIGEST MISMATCH: the body was altered after signing (computed " + digest.slice(0, 16) + "…, file says " + String(wrapped.digest_sha256).slice(0, 16) + "…)"); }
|
|
44
|
+
else if (!wrapped.signature) console.log("digest ok; no signature (the server had no signing key when this was exported)");
|
|
45
|
+
else {
|
|
46
|
+
let pem = null, from = "";
|
|
47
|
+
if (opt("--pubkey")) { pem = readFileSync(opt("--pubkey"), "utf8"); from = opt("--pubkey"); }
|
|
48
|
+
else {
|
|
49
|
+
const url = opt("--pubkey-url") || ((body.server || "https://agent-channel-production.up.railway.app").replace(/\/$/, "") + "/.well-known/agentchan-signing-key.json");
|
|
50
|
+
try { const j = await (await fetch(url, { signal: AbortSignal.timeout(10000) })).json(); pem = j.public_key_pem; from = url + " (kid " + j.kid + ")"; if (body.signing_key?.public_key_pem && body.signing_key.public_key_pem !== pem) console.log("note: the key embedded in the export differs from the one the server publishes now (rotation?)"); }
|
|
51
|
+
catch (e) { console.log("could not fetch the public key (" + e.message + "); pass --pubkey <pem> or --no-sig"); }
|
|
52
|
+
}
|
|
53
|
+
if (pem) {
|
|
54
|
+
const ok = edVerify(null, Buffer.from(digest, "hex"), createPublicKey(pem), Buffer.from(wrapped.signature.sig, "base64url"));
|
|
55
|
+
if (!ok) problems++;
|
|
56
|
+
console.log((ok ? "signature ok" : "SIGNATURE FAIL") + ": Ed25519 " + wrapped.signature.kid + " signed " + wrapped.signature.signed_at + ", key from " + from);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else if (wrapped) console.log("signature check skipped (--no-sig)");
|
|
60
|
+
else console.log("unsigned export format (no wrapper); only the ledger rows were checked");
|
|
61
|
+
|
|
62
|
+
console.log(problems ? "FAIL: " + problems + " problem(s)" : "OK");
|
|
63
|
+
process.exit(problems ? 2 : 0);
|
package/scripts/cli.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Call one Agent Channel tool from the command line.
|
|
2
|
+
// Usage: AGENTCHAN_TOKEN=ac_... node scripts/cli.mjs <tool> '<json args>'
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
|
+
|
|
6
|
+
const BASE = (process.env.AGENTCHAN_URL || "https://agent-channel-production.up.railway.app").replace(/\/mcp$/, "");
|
|
7
|
+
const token = process.env.AGENTCHAN_TOKEN;
|
|
8
|
+
const [tool, argsJson] = process.argv.slice(2);
|
|
9
|
+
if (!token || !tool) { console.error("usage: AGENTCHAN_TOKEN=ac_... cli.mjs <tool> '<json>'"); process.exit(1); }
|
|
10
|
+
|
|
11
|
+
const client = new Client({ name: "agentchan-cli", version: "0.0.1" });
|
|
12
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(BASE + "/mcp"), { requestInit: { headers: { authorization: "Bearer " + token } } }));
|
|
13
|
+
const r = await client.callTool({ name: tool, arguments: argsJson ? JSON.parse(argsJson) : {} });
|
|
14
|
+
console.log(r.content?.[0]?.text ?? JSON.stringify(r));
|
|
15
|
+
if (r.isError) process.exitCode = 2;
|
|
16
|
+
await client.close();
|