@tomato414941/foundation 0.25.0 → 0.27.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 +47 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomato414941/foundation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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');
|
|
@@ -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 }) },
|
|
@@ -296,7 +337,10 @@ async function main() {
|
|
|
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
339
|
if (me.principal?.acts_for?.length) { console.log('Already approved on ' + url.origin + '.'); return; }
|
|
299
|
-
|
|
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' } }] });
|
|
300
344
|
console.log(JSON.stringify(answer, null, 2));
|
|
301
345
|
return;
|
|
302
346
|
}
|