@tomato414941/foundation 0.27.0 → 1.0.0

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/package.json CHANGED
@@ -1,19 +1,16 @@
1
1
  {
2
2
  "name": "@tomato414941/foundation",
3
- "version": "0.27.0",
4
- "description": "Foundation CLI: give this machine a WebAuthn credential, and hand what is kept to a command without it passing through the agent.",
3
+ "version": "1.0.0",
4
+ "description": "Manage Foundation resources and deliver encrypted inputs to commands.",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=24"
8
8
  },
9
9
  "bin": {
10
- "foundation": "runtime.mjs"
10
+ "foundation": "dist/cli.mjs"
11
11
  },
12
12
  "files": [
13
- "runtime.mjs",
14
- "env-name.mjs",
15
- "webauthn.mjs",
16
- "envelope.mjs"
13
+ "dist/cli.mjs"
17
14
  ],
18
15
  "repository": {
19
16
  "type": "git",
package/env-name.mjs DELETED
@@ -1,9 +0,0 @@
1
- // A delivered key is exposed to the child process under a variable name. The
2
- // name must never override how the process itself starts.
3
- const RESERVED = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'PWD', 'OLDPWD', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'TERM', 'HOSTNAME', 'IFS', 'PS1', 'PS4', 'ENV', 'BASH_ENV', 'CDPATH', 'EDITOR', 'VISUAL',
4
- 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', 'PYTHONPATH', 'PYTHONSTARTUP', 'PYTHONHOME', 'PERL5OPT', 'PERL5LIB', 'RUBYOPT', 'RUBYLIB', 'JAVA_TOOL_OPTIONS', 'GOFLAGS', 'GOPATH', 'GEM_PATH', 'GEM_HOME']);
5
- const SYSTEM_PREFIX = /^(FOUNDATION_|LD_|DYLD_|LC_|XDG_|GIT_|SSH_)/;
6
-
7
- export function validEnvName(value) {
8
- return typeof value === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(value) && !RESERVED.has(value) && !SYSTEM_PREFIX.test(value);
9
- }
package/envelope.mjs DELETED
@@ -1,57 +0,0 @@
1
- import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto';
2
-
3
- // How a secret is kept so that only those it was handed to can open it. Each secret has a key of its own (K);
4
- // its bytes are sealed with K, and K is sealed once per recipient with that recipient's public key: an
5
- // envelope. Whoever holds the private key opens the envelope and then the bytes; nobody else, and not the
6
- // server, which keeps only sealed bytes and envelopes. A recipient's private key is itself kept sealed by
7
- // a key that only one of its WebAuthn credentials yields (its PRF output), or in a file it keeps itself.
8
- //
9
- // Keys are X25519; sealing is AES-256-GCM; the envelope key comes from HKDF-SHA256 over the ephemeral
10
- // agreement. Every form is bytes: iv (12) + tag (16) + ciphertext, with the ephemeral public key (32) in
11
- // front for an envelope. A browser does the same with Web Crypto.
12
- const SPKI = Buffer.from('302a300506032b656e032100', 'hex'), PKCS8 = Buffer.from('302e020100300506032b656e04220420', 'hex');
13
- const ENVELOPE_INFO = 'foundation-envelope', WRAP_INFO = 'foundation-key';
14
-
15
- export function generateKey() {
16
- const { privateKey, publicKey } = generateKeyPairSync('x25519');
17
- return { privateKey: privateKey.export({ type: 'pkcs8', format: 'der' }).subarray(-32), publicKey: publicKey.export({ type: 'spki', format: 'der' }).subarray(-32) };
18
- }
19
- export const publicKeyOf = privateKey => createPublicKey(toPrivate(privateKey)).export({ type: 'spki', format: 'der' }).subarray(-32);
20
- const toPublic = raw => createPublicKey({ key: Buffer.concat([SPKI, raw]), type: 'spki', format: 'der' });
21
- const toPrivate = raw => createPrivateKey({ key: Buffer.concat([PKCS8, raw]), type: 'pkcs8', format: 'der' });
22
-
23
- function sealWith(key, bytes, aad) {
24
- const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key, iv);
25
- if (aad) cipher.setAAD(aad);
26
- const content = Buffer.concat([cipher.update(bytes), cipher.final()]);
27
- return Buffer.concat([iv, cipher.getAuthTag(), content]);
28
- }
29
- function openWith(key, sealed, aad) {
30
- if (sealed.length < 28) throw new Error('sealed bytes are too short');
31
- const decipher = createDecipheriv('aes-256-gcm', key, sealed.subarray(0, 12));
32
- if (aad) decipher.setAAD(aad);
33
- decipher.setAuthTag(sealed.subarray(12, 28));
34
- return Buffer.concat([decipher.update(sealed.subarray(28)), decipher.final()]);
35
- }
36
- const agree = (privateKey, publicKey, info) => Buffer.from(hkdfSync('sha256', diffieHellman({ privateKey: toPrivate(privateKey), publicKey: toPublic(publicKey) }), '', info, 32));
37
-
38
- // The secret's own key, and its bytes sealed with it.
39
- export const newContentKey = () => randomBytes(32);
40
- export const sealContent = (contentKey, bytes) => sealWith(contentKey, bytes);
41
- export const openContent = (contentKey, sealed) => openWith(contentKey, sealed);
42
-
43
- // K sealed for one recipient: an ephemeral key agrees with the recipient's, and the recipient's public key
44
- // is bound in as associated data so an envelope opens only for whom it was made.
45
- export function seal(contentKey, recipientPublicKey) {
46
- const ephemeral = generateKey();
47
- return Buffer.concat([ephemeral.publicKey, sealWith(agree(ephemeral.privateKey, recipientPublicKey, ENVELOPE_INFO), contentKey, recipientPublicKey)]);
48
- }
49
- export function open(envelope, privateKey) {
50
- if (envelope.length < 32 + 28) throw new Error('envelope is too short');
51
- return openWith(agree(privateKey, envelope.subarray(0, 32), ENVELOPE_INFO), envelope.subarray(32), publicKeyOf(privateKey));
52
- }
53
-
54
- // A private key sealed with what a credential yields, so that credential alone unseals it.
55
- const wrapKey = yielded => Buffer.from(hkdfSync('sha256', yielded, '', WRAP_INFO, 32));
56
- export const wrap = (privateKey, yielded) => sealWith(wrapKey(yielded), privateKey);
57
- export const unwrap = (wrapped, yielded) => openWith(wrapKey(yielded), wrapped);
package/runtime.mjs DELETED
@@ -1,497 +0,0 @@
1
- #!/usr/bin/env node
2
- import { open, mkdir, stat, lstat, mkdtemp, writeFile, chmod, readFile, rename } from 'node:fs/promises';
3
- import { parseArgs } from 'node:util';
4
- import { rmSync, readdirSync, constants } from 'node:fs';
5
- import { spawn } from 'node:child_process';
6
- import { createHash } from 'node:crypto';
7
- import { homedir, hostname, tmpdir } from 'node:os';
8
- import { dirname, join, resolve, sep } from 'node:path';
9
- import { createRequire } from 'node:module';
10
- import { validEnvName } from './env-name.mjs';
11
- import { createCredential, answer } from './webauthn.mjs';
12
- import { newContentKey, sealContent, seal, open as openEnvelope, openContent, generateKey } from './envelope.mjs';
13
-
14
- // This program does only what the agent running it cannot do for itself.
15
- //
16
- // Everything Foundation offers is plain HTTP, and an agent with the key can call it directly; a command
17
- // wrapper around those calls would only narrow what the agent is allowed to think of. Two things are left:
18
- // init say which server, and make this machine's WebAuthn credential: the machine becomes a principal there, of
19
- // nobody's. Its private key has to exist as a private file before anything can be asked, and nothing prints it.
20
- // join ask a person to make this machine their agent, so that it may act for them.
21
- // token prove this machine with its credential and print a bearer token that lasts an hour, for calling the API.
22
- // exec hand what is kept to a command, or keep a file it creates, without the bytes passing through
23
- // the agent. If the agent fetched the values itself they would be in its context.
24
- // There is also `api`, which is for people and for scripts rather than for agents: it attaches the key to a
25
- // request and prints what comes back. One escape hatch, so that the API can grow without this program growing
26
- // a verb for every endpoint, and without deciding for an agent how it ought to use any of them.
27
- // The key file: this machine's WebAuthn credential - its id, whose it is, and its private key - kept private, and
28
- // the machine's own key for what is sealed for it (envelope.mjs). Both private halves are made here and never leave;
29
- // Foundation keeps only the public halves. A file may instead hold an access key Foundation issued - as handed out on the
30
- // web for a machine, or given to a lent machine - and, unless it is a lent machine's, it is replaced with a credential the
31
- // first time it is used.
32
- async function readKey(path, { missingOk = false } = {}) {
33
- let handle;
34
- try {
35
- handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
36
- const info = await handle.stat();
37
- if (!info.isFile() || info.size > 4096 || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('Runtime key file must be owned by the current user and private (mode 600).');
38
- const content = (await handle.readFile('utf8')).trim();
39
- if (/^fdn_[A-Za-z0-9_-]{43}$/.test(content)) return { token: content };
40
- let credential, own;
41
- try { ({ webauthn_credential: credential, key: own } = JSON.parse(content)); } catch {}
42
- if (typeof credential?.id !== 'string' || typeof credential.user !== 'string' || credential.private_key?.kty !== 'EC') throw new Error('Invalid runtime key file.');
43
- if (own !== undefined && !/^[A-Za-z0-9_-]{43}$/.test(own?.private_key ?? '')) throw new Error('Invalid runtime key file.');
44
- return { credential, own };
45
- } catch (error) {
46
- if (error.code === 'ENOENT') { if (missingOk) return null; throw new Error('No key yet. Run: foundation init'); }
47
- if (error.code === 'ELOOP') throw new Error('Runtime key file must not be a symbolic link.');
48
- throw error;
49
- } finally { await handle?.close(); }
50
- }
51
- async function writeKey(path, content, privateDirectory) {
52
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
53
- if (privateDirectory) {
54
- const directory = await stat(dirname(path));
55
- if ((directory.mode & 0o077) || (process.getuid && directory.uid !== process.getuid())) throw new Error('Foundation key directory must be owned by the current user and private (mode 700).');
56
- }
57
- const created = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
58
- try { await created.writeFile(content + '\n'); await created.sync(); } finally { await created.close(); }
59
- }
60
-
61
- const VERSION = createRequire(import.meta.url)('./package.json').version;
62
- // Which server this machine talks to is a setting, not part of the program: `init <url>` writes it here,
63
- // and FOUNDATION_URL, when set, wins for that one run.
64
- const configPath = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'foundation', 'config.json');
65
- async function savedUrl() {
66
- try { return JSON.parse(await readFile(configPath(), 'utf8')).url || ''; }
67
- catch (error) { if (error.code === 'ENOENT') return ''; throw new Error('Cannot read ' + configPath() + ': ' + error.message); }
68
- }
69
- async function saveUrl(origin) {
70
- const path = configPath();
71
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
72
- await writeFile(path + '.tmp', JSON.stringify({ url: origin }, null, 2) + '\n', { mode: 0o600 });
73
- await rename(path + '.tmp', path);
74
- }
75
- function serverUrl(value) {
76
- let url;
77
- try { url = new URL(value); } catch { throw new Error('No Foundation server yet. Run: foundation init <url>'); }
78
- if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname))) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('The Foundation URL must be an HTTPS origin (HTTP is allowed only on localhost).');
79
- return url;
80
- }
81
-
82
- const validFilename = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value);
83
- async function outputBytes(path) {
84
- let handle;
85
- try {
86
- const directory = await lstat(dirname(path));
87
- if (!directory.isDirectory() || (directory.mode & 0o077) || (process.getuid && directory.uid !== process.getuid())) throw new Error('Output directory must stay private (mode 700) and owned by the current user.');
88
- handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
89
- const info = await handle.stat(), limit = 1024 * 1024;
90
- if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('Output must be a private regular file owned by the current user (mode 600).');
91
- if (info.size > limit) throw new Error('Output must contain 1 byte to 1MB.');
92
- // Bound the read too: the file may grow after stat, or still have a writer.
93
- const bytes = Buffer.alloc(limit + 1);
94
- let size = 0;
95
- while (size < bytes.length) {
96
- const { bytesRead } = await handle.read(bytes, size, bytes.length - size, null);
97
- if (!bytesRead) break;
98
- size += bytesRead;
99
- }
100
- if (!size || size > limit) throw new Error('Output must contain 1 byte to 1MB.');
101
- return bytes.subarray(0, size);
102
- } catch (error) {
103
- if (error.code === 'ELOOP') throw new Error('Output must not be a symbolic link.');
104
- if (error.code === 'ENOENT') throw new Error('The command did not create its output file.');
105
- throw error;
106
- } finally { await handle?.close(); }
107
- }
108
-
109
- // Taking a principal's data out: what the server's export holds, and each stored object's bytes, fetched straight from
110
- // where they are kept by a time-limited URL so that they never pass through the server. The directory is new, private,
111
- // and written into only.
112
- async function exportTo({ directory, principal }, send) {
113
- try { await mkdir(directory, { mode: 0o700 }); }
114
- catch (error) { throw error.code === 'EEXIST' ? new Error('Choose a directory that does not exist yet: ' + directory) : error; }
115
- const base = '/v1/principals/' + encodeURIComponent(principal);
116
- await writeFile(join(directory, 'export.json'), JSON.stringify(await send(base + '/export', undefined, { method: 'GET' }), null, 2) + '\n', { mode: 0o600, flag: 'wx' });
117
- const { resources = [] } = await send(base + '/resources?kind=object', undefined, { method: 'GET', accept: data => data.error?.code === 'space_unavailable' });
118
- const kept = [];
119
- for (const object of resources) {
120
- // The server answers a burst of links with rate_limit; its window is a minute.
121
- let link;
122
- for (let attempt = 0; !link; attempt++) {
123
- const answered = await send('/v1/resources/' + object.id + '/link', {}, { accept: data => data.error?.code === 'rate_limit' && attempt < 5 });
124
- if (answered.error) await new Promise(done => setTimeout(done, Number(process.env.FOUNDATION_EXPORT_WAIT_MS ?? 15_000)));
125
- else link = answered;
126
- }
127
- const response = await fetch(link.url, { redirect: 'error', signal: AbortSignal.timeout(300_000) });
128
- if (!response.ok) throw new Error('Could not download ' + object.name + ' (' + response.status + ').');
129
- const bytes = Buffer.from(await response.arrayBuffer());
130
- // A name becomes a path under objects/ when every part of it is a plain name; otherwise, or where that path is
131
- // taken, the object is saved under its id.
132
- const parts = object.name.split('/');
133
- let file = parts.every(part => part && part !== '.' && part !== '..' && !part.includes(sep) && !/[\u0000-\u001f]/.test(part)) ? join(directory, 'objects', ...parts) : null;
134
- try { if (!file) throw new Error('unsafe'); await mkdir(dirname(file), { recursive: true, mode: 0o700 }); await writeFile(file, bytes, { mode: 0o600, flag: 'wx' }); }
135
- catch { file = join(directory, 'objects', object.id); await mkdir(dirname(file), { recursive: true, mode: 0o700 }); await writeFile(file, bytes, { mode: 0o600, flag: 'wx' }); }
136
- kept.push({ id: object.id, name: object.name, type: object.type ?? null, size: bytes.length, file: file.slice(directory.length + 1) });
137
- }
138
- await writeFile(join(directory, 'objects.json'), JSON.stringify(kept, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
139
- console.log(JSON.stringify({ directory, objects: kept.length }));
140
- }
141
-
142
- // --help describes the CLI. The server publishes its API contract at /openapi.json.
143
- const HELP = `Usage: foundation <command> [options]
144
-
145
- Commands:
146
- init [<url>] [--name <name>] Make this machine's credential: it becomes a principal of its own.
147
- With <url>, remember that Foundation server for later commands.
148
- join Ask a person to make this machine their agent.
149
- token Print a bearer token for the API, valid for an hour.
150
- api <METHOD> </path> [--json <body>] [--from <file>] [--type <media-type>]
151
- Send one request to the Foundation API as this machine.
152
- exec <ENV>=<name> [...] -- <command> [args...]
153
- Run a command with saved values in its environment.
154
- exec --inputs '<json>' -- <command> The same, with files, structured inputs, or a connection for a service by id.
155
- exec --output '<json>' -- <command> Also save a file the command writes.
156
- keep <name> --from <file> Save a file as a secret, sealed here for whoever may open it.
157
- read <name> Print a secret this machine was handed an envelope for.
158
- export <directory> [--principal <id>]
159
- Save a principal's data (this machine's by default) into a new directory:
160
- export.json as the server gives it, and every stored object under objects/.
161
- version Print the version.
162
-
163
- API specification:
164
- foundation api GET /openapi.json Read the server's OpenAPI specification; no key required.
165
-
166
- Environment:
167
- FOUNDATION_URL The server for this run (otherwise the one saved by init).
168
- FOUNDATION_AGENT Your name, such as claude or codex; gives each agent its own key file.
169
- FOUNDATION_RUNTIME_KEY_FILE Where the key file (this machine's credential) is.
170
- `;
171
-
172
- async function main() {
173
- const [action, ...args] = process.argv.slice(2);
174
- const agentName = (process.env.FOUNDATION_AGENT || '').trim();
175
- if (agentName && !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$/.test(agentName)) throw new Error('FOUNDATION_AGENT must be 1-40 characters of letters, digits, space, dot, underscore or hyphen.');
176
- if (action === '--version' || action === '-v' || action === 'version') { console.log(VERSION); return; }
177
- const configured = process.env.FOUNDATION_URL || await savedUrl();
178
- if (action === '--help' || action === '-h' || action === 'help' || !action) { console.log(HELP); return; }
179
- const separatorAt = args.indexOf('--'), command = separatorAt >= 0 ? args.slice(separatorAt + 1) : [];
180
- // Names remain literal. Inputs inject bytes; an optional output saves one generated file.
181
- let names = [], output;
182
- if (action === 'exec' && separatorAt > 0) {
183
- const parsed = parseArgs({ args: args.slice(0, separatorAt), options: { inputs: { type: 'string' }, output: { type: 'string' } }, strict: true, allowPositionals: true });
184
- if (parsed.values.inputs !== undefined && parsed.positionals.length) throw new Error('--inputs and ENV=name are alternatives.');
185
- if (parsed.values.inputs !== undefined) {
186
- try { names = JSON.parse(parsed.values.inputs); } catch { throw new Error('--inputs must be a JSON array of {name, as, filename?} or {id, output?, as?, filename?}.'); }
187
- } else names = parsed.positionals.map(value => {
188
- const at = value.indexOf('=');
189
- if (at < 1) throw new Error('Specify the environment variable explicitly: ENV=name');
190
- return { name: value.slice(at + 1), as: value.slice(0, at) };
191
- });
192
- // A connection for a service names its own variables, so an input may leave `as` out; a secret must say where it goes.
193
- if (!Array.isArray(names) || names.length > 16 || names.some(item => {
194
- if (!item || typeof item !== 'object' || Array.isArray(item) || Object.keys(item).some(key => !['name', 'id', 'output', 'as', 'filename'].includes(key))) return true;
195
- if (Object.hasOwn(item, 'name') === Object.hasOwn(item, 'id')) return true;
196
- if (Object.hasOwn(item, 'name') && (typeof item.name !== 'string' || !item.name || Object.hasOwn(item, 'output'))) return true;
197
- if (Object.hasOwn(item, 'id') && (typeof item.id !== 'string' || !/^[0-9a-f-]{36}$/.test(item.id))) return true;
198
- if (Object.hasOwn(item, 'output') && (typeof item.output !== 'string' || !item.output || item.output.length > 200)) return true;
199
- return item.as !== undefined && item.as !== null && !validEnvName(item.as);
200
- })) throw new Error('Each input needs exactly one of name or id. Use output with a connection id; as must be a non-reserved environment variable.');
201
- const chosen = names.map(item => item.as).filter(value => typeof value === 'string');
202
- if (new Set(chosen).size !== chosen.length) throw new Error('Each input needs a different environment variable.');
203
- if (parsed.values.output !== undefined) {
204
- try { output = JSON.parse(parsed.values.output); } catch { throw new Error('--output must be a JSON object {name, as, filename}.'); }
205
- if (!output || Array.isArray(output) || typeof output !== 'object' || Object.keys(output).some(key => !['name', 'as', 'filename'].includes(key))) throw new Error('--output must be a JSON object {name, as, filename}.');
206
- if (typeof output.name !== 'string' || !output.name.length || output.name.length > 200 || /[\u0000-\u001f\u007f-\u009f]/u.test(output.name) || !output.name.isWellFormed()) throw new Error('Output name must be 1-200 characters without control characters.');
207
- if (!validEnvName(output.as) || !validFilename(output.filename)) throw new Error('Output needs a non-reserved environment variable in as and a filename starting with a letter or digit (up to 64 letters, digits, dots, underscores or hyphens).');
208
- if (names.some(item => item.as === output.as)) throw new Error('Output needs a different environment variable from every input.');
209
- }
210
- }
211
- let call, serverGiven, name;
212
- if (action === 'init') {
213
- const parsed = parseArgs({ args, options: { name: { type: 'string' } }, strict: true, allowPositionals: true });
214
- if (parsed.positionals.length > 1) throw new Error('Usage: init [<url>] [--name <name>]');
215
- serverGiven = parsed.positionals[0];
216
- name = parsed.values.name;
217
- } else if (action === 'join') {
218
- if (args.length) throw new Error('Usage: join');
219
- } else if (action === 'api') {
220
- const parsed = parseArgs({ args, options: { json: { type: 'string' }, from: { type: 'string' }, type: { type: 'string' } }, strict: true, allowPositionals: true });
221
- if (parsed.positionals.length !== 2 || !/^(GET|POST|PUT|DELETE|PATCH)$/.test(parsed.positionals[0]) || !parsed.positionals[1].startsWith('/')) {
222
- throw new Error('Usage: api <GET|POST|PUT|DELETE|PATCH> </path> [--json <body>] [--from <file>] [--type <media-type>]');
223
- }
224
- if (parsed.values.json !== undefined && parsed.values.from !== undefined) throw new Error('--json and --from are alternatives.');
225
- // A request that carries nothing still says so in JSON, which is what the server asks of anything but a GET.
226
- const method = parsed.positionals[0];
227
- const content = parsed.values.from !== undefined ? await readFile(parsed.values.from) : parsed.values.json !== undefined ? Buffer.from(parsed.values.json) : method === 'GET' ? undefined : Buffer.from('{}');
228
- call = { method, target: parsed.positionals[1], body: content,
229
- type: parsed.values.type || (parsed.values.from !== undefined ? 'application/octet-stream' : 'application/json') };
230
- } else if (action === 'keep') {
231
- const parsed = parseArgs({ args, options: { from: { type: 'string' } }, strict: true, allowPositionals: true });
232
- if (parsed.positionals.length !== 1 || parsed.values.from === undefined) throw new Error('Usage: keep <name> --from <file>');
233
- const content = await readFile(parsed.values.from);
234
- if (!content.length || content.length > 1024 * 1024) throw new Error('A secret must contain 1 byte to 1MB.');
235
- call = { name: parsed.positionals[0], body: content };
236
- } else if (action === 'read') {
237
- if (args.length !== 1) throw new Error('Usage: read <name>');
238
- call = { name: args[0] };
239
- } else if (action === 'token') {
240
- if (args.length) throw new Error('Usage: token');
241
- } else if (action === 'export') {
242
- const parsed = parseArgs({ args, options: { principal: { type: 'string' } }, strict: true, allowPositionals: true });
243
- if (parsed.positionals.length !== 1 || (parsed.values.principal !== undefined && !/^[0-9a-f-]{36}$/.test(parsed.values.principal))) throw new Error('Usage: export <directory> [--principal <id>]');
244
- call = { directory: resolve(parsed.positionals[0]), principal: parsed.values.principal ?? 'me' };
245
- } else if (!(action === 'exec' && (names.length || output) && command.length)) {
246
- throw new Error('Usage: init [<url>] [--name <name>] | join | token | exec [<ENV>=<name> ... | --inputs <json>] [--output <json>] -- <command> [args...] | keep <name> --from <file> | read <name> | export <directory> [--principal <id>] | api <method> </path> [--json <body>] [--from <file>]');
247
- }
248
- const url = serverUrl(serverGiven ?? configured);
249
- const keyPath = process.env.FOUNDATION_RUNTIME_KEY_FILE || join(homedir(), '.local', 'state', 'foundation', createHash('sha256').update(url.origin).digest('hex').slice(0, 24) + (agentName ? '-' + agentName.toLowerCase().replace(/[^a-z0-9]+/g, '-') : '') + '.key');
250
- const publicSpec = action === 'api' && call.method === 'GET' && call.target === '/openapi.json';
251
- let key = publicSpec ? null : await readKey(keyPath, { missingOk: action === 'init' }), token = key?.token ?? null;
252
- // The credential proves this machine for an hour at a time: the challenge is answered for the server actually reached.
253
- async function prove() {
254
- const begin = await fetch(url.origin + '/v1/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'webauthn' }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
255
- const { options } = await begin.json();
256
- const response = await fetch(url.origin + '/v1/session', { method: 'PUT', headers: { 'content-type': 'application/json' },
257
- body: JSON.stringify({ kind: 'webauthn', credential: answer(options, key.credential, url.origin), session: 'token' }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
258
- const proven = await response.json();
259
- if (!response.ok || typeof proven.token !== 'string') throw new Error('Foundation did not accept this machine\'s credential (' + response.status + ', ' + (proven.error?.code || 'unknown') + ').');
260
- return proven.token;
261
- }
262
- async function send(target, payload, { accept, method = 'POST', type = 'application/json' } = {}) {
263
- const response = await fetch(url.origin + target, { method, headers: { authorization: 'Bearer ' + token, ...(payload === undefined ? {} : { 'content-type': type }) },
264
- body: payload === undefined ? undefined : type === 'application/json' ? JSON.stringify(payload) : payload, redirect: 'error', signal: AbortSignal.timeout(30_000) });
265
- const data = await response.json();
266
- if (!response.ok && !accept?.(data)) throw new Error('Foundation request failed (' + response.status + ', ' + (data.error?.code || 'unknown') + '). ' + (data.error?.message || 'Check the connection and runtime permission.'));
267
- return data;
268
- }
269
- // A key file holding an issued access key: register a WebAuthn credential with it, and keep the credential instead.
270
- // A lent machine's key stays as it is; it ends with the machine.
271
- async function upgrade(label) {
272
- const me = await send('/v1/principals/me', undefined, { method: 'GET' });
273
- if ((await send('/v1/session', undefined, { method: 'GET' })).current?.via.environment) return;
274
- const { options } = await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn' });
275
- const made = createCredential(options, url.origin);
276
- await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn', name: label, credential: made.response }, { method: 'PUT' });
277
- await writeKey(keyPath, JSON.stringify({ webauthn_credential: made.credential }), !process.env.FOUNDATION_RUNTIME_KEY_FILE);
278
- key = { credential: made.credential };
279
- }
280
- // This machine's own key, made and published once it is a principal here: what is sealed for it opens with this.
281
- // A key published elsewhere for this principal stays as it is; then nothing sealed for it opens here.
282
- async function publishKey() {
283
- if (key.own) return;
284
- const made = generateKey();
285
- const published = await send('/v1/principals/me/key', { public_key: made.publicKey.toString('base64url') }, { method: 'PUT', accept: data => data.error?.code === 'key_exists' });
286
- if (published.error) return;
287
- key.own = { private_key: made.privateKey.toString('base64url') };
288
- await writeKey(keyPath, JSON.stringify({ webauthn_credential: key.credential, key: key.own }), !process.env.FOUNDATION_RUNTIME_KEY_FILE);
289
- }
290
- if (key?.token && action !== 'init') { await upgrade(hostname() + ' の ' + (agentName || 'AI')); }
291
- // A credential this server no longer knows leaves initialising again; anything else needs it.
292
- if (key?.credential) {
293
- try { token = await prove(); await publishKey(); }
294
- catch (error) { if (action !== 'init') throw error; key = null; token = null; }
295
- }
296
- if (action === 'token') { console.log(token); return; }
297
- if (action === 'export') { await exportTo(call, send); return; }
298
- // One request, as this machine, and the answer printed as it came. Nothing here knows the endpoints.
299
- if (action === 'api') {
300
- const response = await fetch(url.origin + call.target, { method: call.method, headers: { ...(token ? { authorization: 'Bearer ' + token } : {}), ...(call.body === undefined ? {} : { 'content-type': call.type }) },
301
- ...(call.body === undefined ? {} : { body: call.body }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
302
- const bytes = Buffer.from(await response.arrayBuffer());
303
- process.stdout.write(bytes);
304
- if (bytes.length && !bytes.subarray(-1).equals(Buffer.from('\n'))) process.stdout.write('\n');
305
- if (!response.ok) process.exitCode = 1;
306
- return;
307
- }
308
- // Becoming a principal here: a key this server knows has nothing to make; initialising again only changes which
309
- // server is remembered. The key itself is never printed: it stays in the file.
310
- if (action === 'init') {
311
- const wanted = name ?? hostname() + ' の ' + (agentName || 'AI');
312
- let me = null;
313
- if (token) me = await send('/v1/principals/me', undefined, { method: 'GET', accept: data => data.error?.code === 'not_approved' });
314
- if (!token || me?.error) {
315
- // No key, or one this server does not know: a WebAuthn credential made here, proven by nobody, makes this machine a
316
- // principal there, of nobody's - the same call a browser's passkey makes.
317
- key = null; me = null;
318
- const post = (method, payload) => fetch(url.origin + '/v1/principals', { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), redirect: 'error', signal: AbortSignal.timeout(30_000) });
319
- const begun = await post('POST', { kind: 'webauthn', name: wanted });
320
- const { options, error } = await begun.json();
321
- if (!begun.ok) throw new Error('Foundation did not begin a credential (' + begun.status + ', ' + (error?.code || 'unknown') + ').');
322
- const made = createCredential(options, url.origin);
323
- const done = await post('PUT', { kind: 'webauthn', name: wanted, principal_name: wanted, credential: made.response, session: 'token' });
324
- const became = await done.json();
325
- if (!done.ok || typeof became.token !== 'string') throw new Error('Foundation did not accept this machine\'s credential (' + done.status + ', ' + (became.error?.code || 'unknown') + ').');
326
- key = { credential: made.credential }; token = became.token;
327
- await writeKey(keyPath, JSON.stringify({ webauthn_credential: key.credential }), !process.env.FOUNDATION_RUNTIME_KEY_FILE);
328
- await publishKey();
329
- } else if (key?.token) { await upgrade(wanted); token = await prove(); await publishKey(); }
330
- if (serverGiven !== undefined) await saveUrl(url.origin);
331
- if (!me) me = await send('/v1/principals/me', undefined, { method: 'GET' });
332
- console.log(JSON.stringify({ principal: { id: me.principal.id, name: me.principal.name, created_at: me.principal.created_at }, acts_for: me.principal.acts_for ?? [] }, null, 2));
333
- console.log('\nKey file: ' + keyPath + '\nServer: ' + url.origin + (serverGiven !== undefined ? ' (saved to ' + configPath() + ')' : '') + '\nTo act for someone: foundation join\nEverything else is HTTP: Authorization: Bearer $(foundation token)');
334
- return;
335
- }
336
- // Asking a person to make this machine their agent. One they already approved has nothing to ask.
337
- if (action === 'join') {
338
- const me = await send('/v1/principals/me', undefined, { method: 'GET', accept: data => data.error?.code === 'not_approved' });
339
- if (me.principal?.acts_for?.length) { console.log('Already approved on ' + url.origin + '.'); return; }
340
- // The one call it may ask for: a line from itself, agent, onto whoever answers (me, to them).
341
- // One someone already owns asks them; one nobody knows asks whoever opens the page it shows.
342
- const self = me.principal?.id ?? (await send('/v1/session', undefined, { method: 'GET' })).current?.principal_id, owner = me.principal?.owners?.[0];
343
- const answer = await send('/v1/requests', { ...(owner ? { to: owner } : {}), operations: [{ method: 'POST', path: '/v1/principals/' + encodeURIComponent(self) + '/relations', body: { relation: 'agent', object_type: 'principal', object_id: 'me' } }] });
344
- console.log(JSON.stringify(answer, null, 2));
345
- return;
346
- }
347
- // What others hold and this machine has a line onto: the things at the far end of the lines it is at the near end of.
348
- const shownToMe = async () => {
349
- const things = [];
350
- for (let after = null; ;) {
351
- const page = await send('/v1/principals/me/relations?direction=from&limit=200' + (after ? '&after=' + encodeURIComponent(after) : ''), undefined, { method: 'GET', accept: () => true });
352
- things.push(...(page.relations ?? []).filter(line => line.resource).map(line => line.resource));
353
- if (!(after = page.next)) return things;
354
- }
355
- };
356
- // Reading a secret this machine was handed an envelope for: its own, or one shown to it along a line.
357
- if (action === 'read') {
358
- if (!key.own) throw new Error('This machine has no key of its own here, so nothing sealed for it can be opened.');
359
- const own = await send('/v1/principals/me/resources?kind=secret&name=' + encodeURIComponent(call.name), undefined, { method: 'GET', accept: () => true });
360
- const resource = own.resource ?? (await shownToMe()).find(item => item.kind === 'secret' && item.name === call.name);
361
- if (!resource) throw new Error('No secret named ' + JSON.stringify(call.name) + ' is kept by this machine or shown to it.');
362
- const kept = await send('/v1/resources/' + resource.id + '/content', undefined, { method: 'GET' });
363
- if (!kept.envelope) throw new Error('No envelope was made for this machine: it may read about this secret, but was not handed its key.');
364
- const contentKey = openEnvelope(Buffer.from(kept.envelope, 'base64url'), Buffer.from(key.own.private_key, 'base64url'));
365
- process.stdout.write(openContent(contentKey, Buffer.from(kept.content, 'base64url')));
366
- return;
367
- }
368
- // Nothing runs before someone has accepted this key: a key that acts for nobody reaches only its own empty resources,
369
- // and the person it asked has yet to answer.
370
- const current = (await send('/v1/principals/me', undefined, { method: 'GET' })).principal;
371
- // A key given to a lent machine acts as its principal's own self. Any other key acts for someone once approved;
372
- // until then, whether waiting or refused, it has nothing to run with.
373
- const own = Boolean((await send('/v1/session', undefined, { method: 'GET' })).current?.via.environment);
374
- if (!own && !current.acts_for?.length) {
375
- const waiting = (await send('/v1/requests?status=pending', undefined, { method: 'GET', accept: () => true })).requests?.[0];
376
- throw new Error('Foundation request failed (401, not_approved). This key acts for nobody yet' + (waiting ? '; it is waiting for approval at ' + waiting.verification_uri : '') + '.');
377
- }
378
- // Whose resources a run reaches: the one this key acts for, the one named when it acts for several, or its own.
379
- const acting = current.acts_for ?? [];
380
- const owner = process.env.FOUNDATION_AS || (acting.length === 1 ? acting[0] : null);
381
- if (!owner && acting.length > 1) throw new Error('This key acts for several principals. Set FOUNDATION_AS=<principal id> to say which one this run is for.');
382
- const holder = '/v1/principals/' + encodeURIComponent(owner || 'me');
383
- // A secret is sealed here, with a key of its own, for each of the owner's recipients: the server keeps what it
384
- // cannot open. This machine is not among them; it places the bytes and does not read them back.
385
- const sealedFor = async bytes => {
386
- const { recipients } = await send(holder + '/recipients', undefined, { method: 'GET' });
387
- if (!Array.isArray(recipients) || !recipients.length) throw new Error('Nobody can open a secret kept for this owner yet: the owner needs a key, or Foundation needs to act for them.');
388
- const contentKey = newContentKey();
389
- return { content: sealContent(contentKey, bytes).toString('base64url'), envelopes: Object.fromEntries(recipients.map(item => [item.principal_id, seal(contentKey, Buffer.from(item.public_key, 'base64url')).toString('base64url')])) };
390
- };
391
- if (action === 'keep') {
392
- const saved = await send(holder + '/resources?kind=secret&name=' + encodeURIComponent(call.name), await sealedFor(call.body), { method: 'PUT' });
393
- try { await send('/v1/principals/me/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
394
- console.log(JSON.stringify(saved));
395
- return;
396
- }
397
- // A secret this machine was handed an envelope for is opened here, with its own key: the server keeps what it cannot
398
- // open, and nobody else has to be able to open it for this machine to use it. Everything else - a connection, a secret
399
- // not handed to this machine - is asked of the server, which hands over only what Foundation may open for the owner.
400
- const handed = { environment: {}, files: [] };
401
- let asked = names;
402
- if (key.own && names.some(item => typeof item.name === 'string')) {
403
- const shown = owner ? await shownToMe() : [];
404
- asked = [];
405
- for (const item of names) {
406
- let resource = null;
407
- if (typeof item.name === 'string') {
408
- resource = owner ? shown.find(row => row.kind === 'secret' && row.name === item.name && row.owner_id === owner)
409
- : (await send('/v1/principals/me/resources?kind=secret&name=' + encodeURIComponent(item.name), undefined, { method: 'GET', accept: () => true })).resource;
410
- }
411
- const kept = resource ? await send('/v1/resources/' + resource.id + '/content', undefined, { method: 'GET', accept: () => true }) : null;
412
- if (!kept?.envelope) { asked.push(item); continue; }
413
- const bytes = openContent(openEnvelope(Buffer.from(kept.envelope, 'base64url'), Buffer.from(key.own.private_key, 'base64url')), Buffer.from(kept.content, 'base64url'));
414
- if (item.filename !== undefined) handed.files.push({ env: item.as, filename: item.filename, content: bytes.toString('base64'), encoding: 'base64' });
415
- else handed.environment[item.as] = bytes.toString('utf8');
416
- }
417
- }
418
- let injection;
419
- if (asked.length) ({ injection } = await send(holder + '/injections', { names: asked }));
420
- else injection = { environment: {}, files: [] };
421
- if (!injection || typeof injection.environment !== 'object' || !Array.isArray(injection.files)) throw new Error('Foundation returned an invalid injection.');
422
- injection = { environment: { ...injection.environment, ...handed.environment }, files: [...injection.files, ...handed.files] };
423
- // What each of them sets is the server's to say; this applies it and refuses anything it may not set.
424
- const environment = { ...process.env };
425
- delete environment.FOUNDATION_RUNTIME_KEY_FILE;
426
- const assign = (name, value) => {
427
- if (!validEnvName(name)) throw new Error('Foundation named a reserved environment variable (' + name + ').');
428
- if (typeof value !== 'string' || /[\x00\r\n]/.test(value) || value.length > 16384) throw new Error('Foundation returned an invalid value for ' + name + '.');
429
- environment[name] = value;
430
- };
431
- for (const [name, value] of Object.entries(injection.environment)) assign(name, value);
432
- const fileNames = new Set(), variables = new Set(Object.keys(injection.environment));
433
- for (const file of injection.files) {
434
- if (typeof file.env !== 'string' || typeof file.content !== 'string' || !validFilename(file.filename) || !validEnvName(file.env) || fileNames.has(file.filename) || variables.has(file.env)) throw new Error('Foundation described an invalid file.');
435
- fileNames.add(file.filename); variables.add(file.env);
436
- }
437
- if (output && variables.has(output.as)) throw new Error('Output needs a different environment variable from every input.');
438
- environment.FOUNDATION_NAMES = JSON.stringify(names.map(item => item.name ?? item.id));
439
- // Injected inputs are always cleaned up. A completed output survives only an unconfirmed upload.
440
- let secretDir, outputDir, outputPath, child, interrupted = false, retainOutput = false;
441
- const cleanup = () => {
442
- if (secretDir) rmSync(secretDir, { recursive: true, force: true });
443
- if (outputDir) {
444
- if (!retainOutput) rmSync(outputDir, { recursive: true, force: true });
445
- else for (const entry of readdirSync(outputDir)) {
446
- if (entry !== output.filename) rmSync(join(outputDir, entry), { recursive: true, force: true });
447
- }
448
- }
449
- };
450
- const recovery = () => 'Foundation could not confirm the output was saved. The private output file is retained for recovery: ' + outputPath + '\nRetry with foundation keep <name> --from <file>, then remove that recovery file.';
451
- process.once('exit', cleanup);
452
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, () => {
453
- interrupted = true;
454
- if (child?.pid && child.exitCode === null && child.signalCode === null) {
455
- child.kill(signal);
456
- setTimeout(() => child.kill('SIGKILL'), 5_000).unref();
457
- return;
458
- }
459
- if (retainOutput) console.error(recovery());
460
- cleanup(); process.exit(1);
461
- });
462
- try {
463
- const temporaryDirectory = async () => {
464
- const directory = await mkdtemp(join(process.env.XDG_RUNTIME_DIR && (await stat(process.env.XDG_RUNTIME_DIR).catch(() => null))?.isDirectory() ? process.env.XDG_RUNTIME_DIR : tmpdir(), 'foundation-'));
465
- await chmod(directory, 0o700);
466
- return directory;
467
- };
468
- if (injection.files.length) {
469
- secretDir = await temporaryDirectory();
470
- for (const file of injection.files) {
471
- const target = join(secretDir, file.filename);
472
- await writeFile(target, file.encoding === 'base64' ? Buffer.from(file.content, 'base64') : file.content, { mode: 0o600, flag: 'wx' });
473
- assign(file.env, target);
474
- }
475
- }
476
- if (output) {
477
- outputDir = await temporaryDirectory();
478
- outputPath = join(outputDir, output.filename);
479
- await writeFile(outputPath, '', { mode: 0o600, flag: 'wx' });
480
- assign(output.as, outputPath);
481
- }
482
- child = spawn(command[0], command.slice(1), { stdio: 'inherit', env: environment, shell: false });
483
- process.exitCode = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (value, signal) => resolve(interrupted ? 1 : value ?? (signal ? 1 : 0))); });
484
- if (output && process.exitCode === 0) {
485
- const bytes = await outputBytes(outputPath);
486
- retainOutput = true;
487
- // The command wrote it; the agent never saw it, and keeps it that way: the line drawn for the one who kept it is declined.
488
- let saved;
489
- try { saved = await send(holder + '/resources?kind=secret&name=' + encodeURIComponent(output.name), await sealedFor(bytes), { method: 'PUT' }); }
490
- catch { throw new Error(recovery()); }
491
- try { await send('/v1/principals/me/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
492
- retainOutput = false;
493
- console.error('Saved output as ' + JSON.stringify(output.name) + '.');
494
- }
495
- } finally { cleanup(); }
496
- }
497
- main().catch((error) => { console.error(error instanceof TypeError ? 'Unable to connect. Check the Foundation URL and network access.' : error.message); process.exitCode = 1; });