1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
#!/usr/bin/env node
// Run an atlibs wasm module server side (javascript/src/server, no emdash, no npm deps):
// node javascript/src/server/cli.mjs run <handle>/<name>[@pin] <entry> ['<json>'] (or <did>/<name>, an at:// URI)
// node javascript/src/server/cli.mjs run <module.wasm> <module.json> <entry> ['<json>']
// The input JSON: an argument, or - (read from stdin) or @file, so secrets (a stdin input) stay out of ps.
// Exits 1 when the run failed (its error is in the printed result).
// Who it acts as, explicit only (no flag: signed out, DID-service reads only; the environment is never read for it):
// --app-password <handle> (--password-file <path|-> | --password-env <NAME>) [--pds https://bsky.social]
// (an app password: the simple / testing case; a user's OAuth session is a server's, javascript/src/oauth-server)
// env: ATLIBS_SERVER_STATE (default ~/.atlibs-server:
// store.db and secret.key, which seals the attestation keys and the saved session; keep it
// private and reuse it so bind_key and later signatures share a key, and so runs refresh
// the saved session instead of calling the rate-limited createSession).
// ATLIBS_BUDGET (JSON merged over runModule's BUDGET, for example
// '{"wallMs":300000,"events":5000}'; "unlimited" for calls, callsPerMinute, events, wallMs; ioBytes up to 16 MiB);
// Ctrl-C ends a run and still prints it.
// ATLIBS_TRANSPORTS (comma-separated module paths, each exporting `transport`: the host pseudo-calls
// this command line serves, e.g. atlibs-iroh's js/index.js; unset: none, so iroh.connect is refused;
// and/or `fetchers({ env, stateDir })`: { "iroh:": fn }, runModule's fetchers for http.fetch to those URLs).
// ATLIBS_IROH_KEY (the iroh transport's ssh private key; default <state>/iroh_ed25519).
// ATLIBS_ALLOW_PRIVATE=1 lets DID lookups reach http and private addresses (local dev only).
// ATLIBS_HTTP (JSON, or @file of it: the origins com.fedfork.exec.http.fetch may reach, each with the
// headers this host adds, e.g. '{"https://api.anthropic.com":{"headers":{"x-api-key":"..."}}}'; runModule's http).
// ATLIBS_HELD (JSON, or @file of it): { name: value }, the held values the run starts with (runModule's held).
// A module.json (or module record) with "didkey": { "services": [https origins] } gets a P-256
// did:key, sealed in the state dir per module identity: its at:// URI, or the wasm's absolute path
// (same did, same ingress host name, every run; never the name a module gives itself), and the
// com.fedfork.exec.didkey.* pseudo-calls (javascript/src/didkey).
// Prints runModule's { entry, result, error?, events, calls } as JSON on stdout.
import { readFileSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { attestHost } from "../attest/index.js";
import { didkeyCall, ingressHost } from "../didkey/index.js";
import { loadModule, localModule, runModule } from "../run-module/index.js";
import { contextOptions, createNodeContext, didkeyOf, fileFetch, hostCall, modulePolicy, openSession, secp256k1, sessionCall, sessionFlags } from "./index.js";
const USAGE = `usage: cli.mjs run <handle>/<name>[@pin] | <did>/<name> | <at://...> <entry> ['<json>' | - | @file] [--app-password <handle> (--password-file <path|-> | --password-env <NAME>) [--pds <url>]]
cli.mjs run <module.wasm> <module.json> <entry> ['<json>' | - | @file] [...]`;
// read: the input source (- is stdin, @path a file); swappable for tests.
export function parse(argv, read = (source) => readFileSync(source === "-" ? 0 : source.slice(1), "utf8")) {
const [command, ...rest] = argv;
if (command !== "run") throw new Error(USAGE);
// published: at://, or <handle|did>/<name> (Rust registry::is_remote); else <module.wasm> <module.json>
const head = /^([^/@\s]+)\/[^/@\s]+(@[A-Za-z0-9]+)?$/.exec(rest[0] ?? "")?.[1];
const remote = rest[0]?.startsWith("at://") || (head !== undefined && !rest[0].endsWith(".wasm") && (head.startsWith("did:") || (head.includes(".") && !head.startsWith("."))));
const [entry, inputJson = "{}", ...extra] = rest.slice(remote ? 1 : 2);
if (!entry || extra.length || (!remote && !rest[1])) throw new Error(USAGE);
const text = inputJson === "-" || inputJson.startsWith("@") ? read(inputJson) : inputJson;
let input;
try {
input = JSON.parse(text);
} catch {
throw new Error("input is not JSON"); // never echoed: it may hold a secret
}
if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error("input must be a JSON object");
return { ...(remote ? { uri: rest[0] } : { wasm: rest[0], manifest: rest[1] }), entry, input };
}
// transports: the host pseudo-calls this CLI was given, by NSID -> ({ env, stateDir }) => handler (hostCall's
// calls; a transport module's `transport`, ATLIBS_TRANSPORTS on the command line). Their NSIDs are what this host provides, checked when hooks load.
// A module that lists no calls may call anything, so it gets no transport (the host's keys stay out of it).
// One not given is refused: "<nsid>: not available on this host (built without <name>)".
// fetchers: those modules' fetchers exports, each ({ env, stateDir }) => { "<scheme>:": fn } (runModule's fetchers).
export async function main(argv, env = process.env, signal, transports = {}, fetchers = []) {
const { config, rest } = sessionFlags(argv, { env });
const { uri, wasm, manifest, entry, input } = parse(rest);
const stateDir = env.ATLIBS_SERVER_STATE || join(homedir(), ".atlibs-server");
const ctx = createNodeContext(contextOptions(stateDir));
ctx.allowPrivate = env.ATLIBS_ALLOW_PRIVATE === "1";
try {
// key: its rkey, or the wasm's file stem (rust/cli pair_lib); dir: where a local module.json's session record is looked up
const module = uri
? await loadModule(ctx, uri).then((mod) => ({ ...mod, key: mod.id.split("/").pop() }))
: { ...(await localModule(pathToFileURL(wasm), pathToFileURL(manifest), fileFetch)), id: await realpath(wasm), key: basename(wasm, ".wasm"), dir: dirname(manifest) };
const session = await openSession(ctx, config);
// JSON has no Infinity: "unlimited" is how ATLIBS_BUDGET says it (budgetValue keeps it only for UNLIMITED keys).
const budget = env.ATLIBS_BUDGET ? JSON.parse(env.ATLIBS_BUDGET, (key, value) => (value === "unlimited" ? Infinity : value)) : undefined;
const envJson = (name) => env[name] && JSON.parse(env[name].startsWith("@") ? readFileSync(env[name].slice(1), "utf8") : env[name]);
const http = envJson("ATLIBS_HTTP");
const held = envJson("ATLIBS_HELD");
const schemes = Object.assign({}, ...(await Promise.all(fetchers.map((make) => make({ env, stateDir })))));
const refuse = (nsid) => async () => Promise.reject(new Error(`${nsid} needs a module record that lists its calls`));
// The call one module runs on: the run's, and each hook default's own, never another's.
// A module that lists no calls may call anything, so it gets no signing key and no transport.
const callOf = async (module) => {
const attest = session && module.calls?.length ? attestHost(ctx, secp256k1, session.did) : undefined;
const calls = Object.fromEntries(Object.entries(transports).map(([nsid, make]) => [nsid, module.calls?.length ? make({ env, stateDir }) : refuse(nsid)]));
let call = hostCall({ ctx, xrpc: session?.xrpc, attest, pds: session?.pds, calls, fetchers: module.calls?.length ? schemes : {} });
// signed in: only what the module's own session record's scope allows
const key = module.key ?? basename(module.id ?? "", ".wasm");
if (session) call = sessionCall(await modulePolicy(ctx, key, module, module.dir), key, call);
if (module.didkey && module.calls?.length) {
const key = await didkeyOf(ctx, module.id);
for (const service of module.didkey.services ?? []) console.error(`${key.did}: https://${ingressHost(key.did, service)}/`);
call = didkeyCall(key, call, { services: module.didkey.services, lease: (name) => ctx.lease(name) });
}
return call;
};
const call = await callOf(module);
// A hook default of a local build: examples/rust/<key>/module.json and its wasm beside this one's.
const local = async (key) => {
const file = join(dirname(wasm), `${key.replaceAll("-", "_")}.wasm`);
const dir = join(dirname(manifest), "..", key);
return { ...(await localModule(pathToFileURL(file), pathToFileURL(join(dir, "module.json")), fileFetch)), id: await realpath(file), key, dir };
};
return await runModule(ctx, module, entry, { ...(session && { did: session.did }), ...input }, call, { budget, signal, callFor: callOf, provides: Object.keys(transports), http, fetchers: schemes, held, ...(!uri && { load: local }) });
} finally {
ctx.close();
}
}
if (import.meta.main) {
const stop = new AbortController();
process.once("SIGINT", () => stop.abort());
// ATLIBS_TRANSPORTS: the transport modules this command line loads (paths from the cwd); no core path imports one.
const transports = async () => {
const all = {};
const fetchers = [];
for (const path of (process.env.ATLIBS_TRANSPORTS ?? "").split(",").map((p) => p.trim()).filter(Boolean)) {
const plugin = await import(pathToFileURL(resolve(path)).href);
if (!plugin.transport && typeof plugin.fetchers !== "function") throw new Error(`ATLIBS_TRANSPORTS: ${path} exports no transport`);
Object.assign(all, plugin.transport);
if (typeof plugin.fetchers === "function") fetchers.push(plugin.fetchers);
}
return [all, fetchers];
};
transports().then(([given, fetchers]) => main(process.argv.slice(2), process.env, stop.signal, given, fetchers)).then(
(ran) => {
console.log(JSON.stringify(ran, null, "\t"));
if (ran.error !== undefined) process.exitCode = 1;
},
(error) => {
console.error(error.message);
process.exitCode = 1;
},
);
}