@tomato414941/foundation 0.24.0 → 0.26.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 +1 -1
- package/runtime.mjs +52 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomato414941/foundation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Foundation CLI: give this machine a WebAuthn credential, and hand what is kept to a command without it passing through the agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
package/runtime.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { rmSync, readdirSync, constants } from 'node:fs';
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { homedir, hostname, tmpdir } from 'node:os';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
8
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
10
|
import { validEnvName } from './env-name.mjs';
|
|
11
11
|
import { createCredential, answer } from './webauthn.mjs';
|
|
@@ -106,6 +106,39 @@ async function outputBytes(path) {
|
|
|
106
106
|
} finally { await handle?.close(); }
|
|
107
107
|
}
|
|
108
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
|
+
|
|
109
142
|
// --help describes the CLI. The server publishes its API contract at /openapi.json.
|
|
110
143
|
const HELP = `Usage: foundation <command> [options]
|
|
111
144
|
|
|
@@ -122,6 +155,9 @@ Commands:
|
|
|
122
155
|
exec --output '<json>' -- <command> Also save a file the command writes.
|
|
123
156
|
keep <name> --from <file> Save a file as a secret, sealed here for whoever may open it.
|
|
124
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/.
|
|
125
161
|
version Print the version.
|
|
126
162
|
|
|
127
163
|
API specification:
|
|
@@ -202,8 +238,12 @@ async function main() {
|
|
|
202
238
|
call = { name: args[0] };
|
|
203
239
|
} else if (action === 'token') {
|
|
204
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' };
|
|
205
245
|
} else if (!(action === 'exec' && (names.length || output) && command.length)) {
|
|
206
|
-
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> | api <method> </path> [--json <body>] [--from <file>]');
|
|
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>]');
|
|
207
247
|
}
|
|
208
248
|
const url = serverUrl(serverGiven ?? configured);
|
|
209
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');
|
|
@@ -230,7 +270,7 @@ async function main() {
|
|
|
230
270
|
// A lent machine's key stays as it is; it ends with the machine.
|
|
231
271
|
async function upgrade(label) {
|
|
232
272
|
const me = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
233
|
-
if (
|
|
273
|
+
if ((await send('/v1/session', undefined, { method: 'GET' })).current?.via.environment) return;
|
|
234
274
|
const { options } = await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn' });
|
|
235
275
|
const made = createCredential(options, url.origin);
|
|
236
276
|
await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn', name: label, credential: made.response }, { method: 'PUT' });
|
|
@@ -254,6 +294,7 @@ async function main() {
|
|
|
254
294
|
catch (error) { if (action !== 'init') throw error; key = null; token = null; }
|
|
255
295
|
}
|
|
256
296
|
if (action === 'token') { console.log(token); return; }
|
|
297
|
+
if (action === 'export') { await exportTo(call, send); return; }
|
|
257
298
|
// One request, as this machine, and the answer printed as it came. Nothing here knows the endpoints.
|
|
258
299
|
if (action === 'api') {
|
|
259
300
|
const response = await fetch(url.origin + call.target, { method: call.method, headers: { ...(token ? { authorization: 'Bearer ' + token } : {}), ...(call.body === undefined ? {} : { 'content-type': call.type }) },
|
|
@@ -288,14 +329,14 @@ async function main() {
|
|
|
288
329
|
} else if (key?.token) { await upgrade(wanted); token = await prove(); await publishKey(); }
|
|
289
330
|
if (serverGiven !== undefined) await saveUrl(url.origin);
|
|
290
331
|
if (!me) me = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
291
|
-
console.log(JSON.stringify({ principal: me.principal, acts_for: me.acts_for ?? [] }, null, 2));
|
|
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));
|
|
292
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)');
|
|
293
334
|
return;
|
|
294
335
|
}
|
|
295
336
|
// Asking a person to make this machine their agent. One they already approved has nothing to ask.
|
|
296
337
|
if (action === 'join') {
|
|
297
338
|
const me = await send('/v1/principals/me', undefined, { method: 'GET', accept: data => data.error?.code === 'not_approved' });
|
|
298
|
-
if (me.acts_for?.length) { console.log('Already approved on ' + url.origin + '.'); return; }
|
|
339
|
+
if (me.principal?.acts_for?.length) { console.log('Already approved on ' + url.origin + '.'); return; }
|
|
299
340
|
const answer = await send('/v1/requests', { authorization_details: [{ type: 'relation', relation: 'agent' }] });
|
|
300
341
|
console.log(JSON.stringify(answer, null, 2));
|
|
301
342
|
return;
|
|
@@ -323,11 +364,14 @@ async function main() {
|
|
|
323
364
|
}
|
|
324
365
|
// Nothing runs before someone has accepted this key: a key that acts for nobody reaches only its own empty resources,
|
|
325
366
|
// and the person it asked has yet to answer.
|
|
326
|
-
const current = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
367
|
+
const current = (await send('/v1/principals/me', undefined, { method: 'GET' })).principal;
|
|
327
368
|
// A key given to a lent machine acts as its principal's own self. Any other key acts for someone once approved;
|
|
328
369
|
// until then, whether waiting or refused, it has nothing to run with.
|
|
329
|
-
const own = Boolean(current.
|
|
330
|
-
if (!own && !current.acts_for?.length)
|
|
370
|
+
const own = Boolean((await send('/v1/session', undefined, { method: 'GET' })).current?.via.environment);
|
|
371
|
+
if (!own && !current.acts_for?.length) {
|
|
372
|
+
const waiting = (await send('/v1/requests?status=pending', undefined, { method: 'GET', accept: () => true })).requests?.[0];
|
|
373
|
+
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 : '') + '.');
|
|
374
|
+
}
|
|
331
375
|
// Whose resources a run reaches: the one this key acts for, the one named when it acts for several, or its own.
|
|
332
376
|
const acting = current.acts_for ?? [];
|
|
333
377
|
const owner = process.env.FOUNDATION_AS || (acting.length === 1 ? acting[0] : null);
|