@tomato414941/foundation 0.2.0 → 0.4.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/guide.mjs +27 -19
- package/package.json +1 -1
- package/runtime.mjs +40 -23
package/guide.mjs
CHANGED
|
@@ -28,26 +28,33 @@ export function guide(connectors) {
|
|
|
28
28
|
'Never ask the owner to paste passwords, keys or tokens into chat. Use a storage request or OAuth URL instead.', '',
|
|
29
29
|
'WHO YOU ARE',
|
|
30
30
|
' Everything that comes to Foundation is a principal: a person, an AI, an app. You are one, and your key is what',
|
|
31
|
-
' proves it
|
|
32
|
-
'
|
|
31
|
+
' proves it. Foundation issues it, once, and shows it once: keep it private and never print it. If you can write a',
|
|
32
|
+
' private file, keep it there; otherwise keep it wherever your secrets go.',
|
|
33
|
+
' POST /v1/principals {"name": "<what to call you>"} with no credential at all. Makes you, and answers with token.',
|
|
33
34
|
' What you may reach follows from lines between principals: whoever you act for (actor), whoever made you (owner).',
|
|
34
35
|
' POST /v1/requests {"kind": "actor", "input": {"name": "<what to call you>"}} with your key as the bearer.',
|
|
35
36
|
' Asks whoever opens it to let you act for them. Returns verification_uri and confirmation_code. Give the person',
|
|
36
|
-
' both; they open the URL and type the code. Until they do you act for nobody
|
|
37
|
-
'
|
|
37
|
+
' both; they open the URL and type the code. Until they do you act for nobody. Retry GET /v1/principals/me every',
|
|
38
|
+
' few seconds and look at acts_for. Do not hammer it.',
|
|
38
39
|
' GET /v1/principals/me who you are: principal, acts_for (whom you act for), owners, credentials, your open requests.',
|
|
39
|
-
'
|
|
40
|
+
' Every call below reaches your own holdings unless you name whose: ?as=<id of one you act for>. The foundation CLI',
|
|
41
|
+
' and the MCP tool add ?as= for you when you act for exactly one; over raw HTTP, add it yourself.',
|
|
40
42
|
' PATCH /v1/principals/me {"name"} DELETE /v1/principals/me a new name; leaving (your key stops working).',
|
|
41
43
|
' GET /v1/requests/<id> one of your requests and what happened at its page (events). DELETE cancels it.',
|
|
42
|
-
' GET /v1/relations every line you are on. GET /v1/records what was done in your name or to you.',
|
|
44
|
+
' GET /v1/relations every line you are on. GET /v1/records what was done in your name or to you.',
|
|
45
|
+
' Everything kept has an id, shown in every listing. The holder shows one thing to another principal with',
|
|
46
|
+
' POST /v1/relations {"subject":"<principal id>","relation":"viewer"|"editor","object_type":"holding","object_id":"<id>"} DELETE takes it back.',
|
|
47
|
+
' GET /v1/holdings what others have shown you. GET /v1/holdings/<id> one thing, whoever holds it.',
|
|
48
|
+
' GET /v1/holdings/<id>/content reads it; PUT writes it, as editor. Lines point at the id, so renaming changes nothing.', '',
|
|
43
49
|
'WHAT IS KEPT',
|
|
44
|
-
' PUT /v1/
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
' GET /v1/
|
|
49
|
-
' GET /v1/
|
|
50
|
-
'
|
|
50
|
+
' PUT /v1/holdings?kind=secret&name=<name> body: raw bytes, up to 1MB. The same exact name replaces that value; the answer carries its id.',
|
|
51
|
+
' What you keep for the holder you may read back (an editor line is drawn for you). What the holder kept, or a',
|
|
52
|
+
' connection handed over, you may deliver into a command but not read, unless the holder draws you a line.',
|
|
53
|
+
' Do not print delivered values into your context.',
|
|
54
|
+
' GET /v1/holdings?kind=secret ids, names, sizes and timestamps, not contents. &prefix=<literal prefix> narrows by text, not directory.',
|
|
55
|
+
' GET /v1/holdings?kind=secret&name=<name> the one thing by name: its id and metadata. 404 if no such name.',
|
|
56
|
+
' GET /v1/holdings/<id>/content bytes, as written; 403 forbidden without a line to it. PUT writes them.',
|
|
57
|
+
' PATCH /v1/holdings/<id> {"name"} a new name for the same thing. DELETE /v1/holdings/<id> removes it.',
|
|
51
58
|
' URL-encode the name. A name is any text, which is why it travels as a query and not as a path. Names are 1-200 characters without control characters; case, spaces, slashes and punctuation',
|
|
52
59
|
' remain literal. No normalization, hierarchy, service ownership or automatic renewal is inferred.',
|
|
53
60
|
' The owner can read, rename or delete any saved value. Stored copies survive OAuth disconnection.', '',
|
|
@@ -60,7 +67,7 @@ export function guide(connectors) {
|
|
|
60
67
|
' Calling this into an agent context exposes values. Use exec when the intent is to run a local command.', '',
|
|
61
68
|
'FILLING THE STORE',
|
|
62
69
|
'',
|
|
63
|
-
'1. Put it there yourself. Anything you obtained or wrote: PUT /v1/
|
|
70
|
+
'1. Put it there yourself. Anything you obtained or wrote: PUT /v1/holdings?kind=secret&name=<name>, above.',
|
|
64
71
|
'',
|
|
65
72
|
'2. ASKING THE OWNER, for what only they can fetch -- an API token, a key, a certificate they must go and create.',
|
|
66
73
|
' POST /v1/requests {"kind":"store", "input":{"fields":[{...}]}, "purpose":"...", "steps":["..."], "valid_minutes":30}',
|
|
@@ -68,7 +75,7 @@ export function guide(connectors) {
|
|
|
68
75
|
' fields[].label what they are being asked for, in their language. It titles the screen and names the field.',
|
|
69
76
|
|
|
70
77
|
' fields[].site the page where they make it, offered as a link',
|
|
71
|
-
' fields[].
|
|
78
|
+
' fields[].readable true asks to read it back afterwards (a viewer line); the default is that you cannot',
|
|
72
79
|
' fields[].multiline true for something like a PEM',
|
|
73
80
|
' fields[].replace true to put the value in place of the one already kept under that exact name (rotation).',
|
|
74
81
|
' Refused at creation: name_taken when the name exists and replace is not declared, name_missing',
|
|
@@ -109,9 +116,10 @@ export function guide(connectors) {
|
|
|
109
116
|
' reconnect_required (the service rejected it) / already_connected. A key approval shows its own events at',
|
|
110
117
|
' GET /v1/requests/<id>: confirmation_required (a wrong code) / confirmation_locked (5 tries).', '',
|
|
111
118
|
'A PLACE FOR FILES (object storage the owner did not have to sign up for)',
|
|
112
|
-
' PUT /v1/
|
|
113
|
-
' GET /v1/
|
|
114
|
-
'
|
|
119
|
+
' PUT /v1/holdings?kind=object&name=<key> body is the bytes; the Content-Type you send is what a reader gets back. Up to 25MB.',
|
|
120
|
+
' GET /v1/holdings?kind=object&prefix=<p> lists what is there, with ids. &name=<key> finds one.',
|
|
121
|
+
' GET /v1/holdings/<id>/content the bytes. PUT writes them. DELETE /v1/holdings/<id> removes it. PATCH renames it.',
|
|
122
|
+
' POST /v1/holdings/<id>/link {"minutes":n} a time-limited URL anyone can read, for a thing that only takes a URL.',
|
|
115
123
|
' Keys look like a path (a/b/c.txt) but name one whole object: no renaming, no directories, no partial reads or writes.',
|
|
116
124
|
' This is not a filesystem. If the owner needs one, they need a machine to mount it on.',
|
|
117
125
|
' Where the bytes live is the owner\'s business: a space lent to them now, a bucket of their own later. Nothing you call changes.', '',
|
|
@@ -143,7 +151,7 @@ export function guide(connectors) {
|
|
|
143
151
|
' Use this instead of POST /v1/deliveries whenever the point is to run something.',
|
|
144
152
|
` foundation exec --output '{"name":"login config","as":"AUTH_FILE","filename":"auth.json"}' -- <command>`,
|
|
145
153
|
' Creates one empty private file and sets as to its path. Tell the command to write its authentication result there.',
|
|
146
|
-
' After exit 0, saves the file bytes under the exact name
|
|
154
|
+
' After exit 0, saves the file bytes under the exact name, then removes the temporary file.',
|
|
147
155
|
' An existing value at that name is replaced. Output must be a private regular file containing 1 byte to 1MB.',
|
|
148
156
|
' May be combined with inputs, using a different environment variable. A failed command never saves its output.',
|
|
149
157
|
' If upload cannot be confirmed, exits with an error and reports the retained private file for recovery; inputs are',
|
package/package.json
CHANGED
package/runtime.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { open, mkdir, stat, lstat, mkdtemp, writeFile, chmod, readFile, rename }
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { rmSync, readdirSync, constants } from 'node:fs';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
-
import { createHash
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
7
|
import { homedir, hostname, tmpdir } from 'node:os';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
@@ -21,21 +21,9 @@ import { guide } from './guide.mjs';
|
|
|
21
21
|
// There is also `api`, which is for people and for scripts rather than for agents: it attaches the key to a
|
|
22
22
|
// request and prints what comes back. One escape hatch, so that the API can grow without this program growing
|
|
23
23
|
// a verb for every endpoint, and without deciding for an agent how it ought to use any of them.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (privateDirectory) {
|
|
28
|
-
const directory = await stat(dirname(path));
|
|
29
|
-
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).');
|
|
30
|
-
}
|
|
31
|
-
let created;
|
|
32
|
-
try {
|
|
33
|
-
created = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
34
|
-
await created.writeFile('fdn_' + randomBytes(32).toString('base64url') + '\n');
|
|
35
|
-
await created.sync();
|
|
36
|
-
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
37
|
-
finally { await created?.close(); }
|
|
38
|
-
}
|
|
24
|
+
// The key file: what Foundation issued, kept private. Nothing here makes a key; Foundation does, once, when this
|
|
25
|
+
// machine becomes a principal, and the file is the only place it lives afterwards.
|
|
26
|
+
async function readKey(path, { missingOk = false } = {}) {
|
|
39
27
|
let handle;
|
|
40
28
|
try {
|
|
41
29
|
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
@@ -45,11 +33,20 @@ async function runtimeKey(path, create, privateDirectory) {
|
|
|
45
33
|
if (!/^fdn_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error('Invalid runtime key file.');
|
|
46
34
|
return token;
|
|
47
35
|
} catch (error) {
|
|
48
|
-
if (error.code === 'ENOENT') throw new Error('No key yet. Run: foundation connect');
|
|
36
|
+
if (error.code === 'ENOENT') { if (missingOk) return null; throw new Error('No key yet. Run: foundation connect'); }
|
|
49
37
|
if (error.code === 'ELOOP') throw new Error('Runtime key file must not be a symbolic link.');
|
|
50
38
|
throw error;
|
|
51
39
|
} finally { await handle?.close(); }
|
|
52
40
|
}
|
|
41
|
+
async function writeKey(path, token, privateDirectory) {
|
|
42
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
43
|
+
if (privateDirectory) {
|
|
44
|
+
const directory = await stat(dirname(path));
|
|
45
|
+
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).');
|
|
46
|
+
}
|
|
47
|
+
const created = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
|
|
48
|
+
try { await created.writeFile(token + '\n'); await created.sync(); } finally { await created.close(); }
|
|
49
|
+
}
|
|
53
50
|
|
|
54
51
|
const VERSION = createRequire(import.meta.url)('./package.json').version;
|
|
55
52
|
// Which server this machine talks to is a setting, not part of the program: `connect <url>` writes it here,
|
|
@@ -185,7 +182,7 @@ async function main() {
|
|
|
185
182
|
}
|
|
186
183
|
const url = serverUrl(connectTo ?? configured);
|
|
187
184
|
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');
|
|
188
|
-
|
|
185
|
+
let token = await readKey(keyPath, { missingOk: action === 'connect' });
|
|
189
186
|
async function send(target, payload, { accept, method = 'POST', type = 'application/json' } = {}) {
|
|
190
187
|
const response = await fetch(url.origin + target, { method, headers: { authorization: 'Bearer ' + token, ...(payload === undefined ? {} : { 'content-type': type }) },
|
|
191
188
|
body: payload === undefined ? undefined : type === 'application/json' ? JSON.stringify(payload) : payload, redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
@@ -195,6 +192,10 @@ async function main() {
|
|
|
195
192
|
}
|
|
196
193
|
// One request, with the key attached and the answer printed as it came. Nothing here knows the endpoints.
|
|
197
194
|
if (action === 'api') {
|
|
195
|
+
if (!/[?&]as=/.test(call.target)) {
|
|
196
|
+
const me = await send('/v1/principals/me', undefined, { method: 'GET', accept: () => true });
|
|
197
|
+
if (me.acts_for?.length === 1) call.target += (call.target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(me.acts_for[0].id);
|
|
198
|
+
}
|
|
198
199
|
const response = await fetch(url.origin + call.target, { method: call.method, headers: { authorization: 'Bearer ' + token, ...(call.body === undefined ? {} : { 'content-type': call.type }) },
|
|
199
200
|
...(call.body === undefined ? {} : { body: call.body }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
200
201
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
@@ -207,9 +208,18 @@ async function main() {
|
|
|
207
208
|
// A key the owner already approved has nothing to ask; connecting again only changes which server is remembered.
|
|
208
209
|
if (action === 'connect') {
|
|
209
210
|
// A key someone already accepted has nothing to ask; connecting again only changes which server is remembered.
|
|
211
|
+
// No key, or one this server does not know: become a principal there first, and keep what it issues.
|
|
212
|
+
const wanted = name ?? hostname() + ' の ' + (agentName || 'AI');
|
|
210
213
|
let me = null;
|
|
211
|
-
|
|
212
|
-
|
|
214
|
+
if (token) me = await send('/v1/principals/me', undefined, { method: 'GET', accept: data => data.error?.code === 'not_approved' });
|
|
215
|
+
if (!token || me?.error) {
|
|
216
|
+
const response = await fetch(url.origin + '/v1/principals', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: wanted }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
217
|
+
const made = await response.json();
|
|
218
|
+
if (!response.ok || !/^fdn_[A-Za-z0-9_-]{43}$/.test(made.token ?? '')) throw new Error('Foundation did not issue a key (' + response.status + ', ' + (made.error?.code || 'unknown') + ').');
|
|
219
|
+
await writeKey(keyPath, made.token, !process.env.FOUNDATION_RUNTIME_KEY_FILE);
|
|
220
|
+
token = made.token; me = null;
|
|
221
|
+
}
|
|
222
|
+
const answer = me?.acts_for?.length ? null : await send('/v1/requests', { kind: 'actor', input: { name: wanted } });
|
|
213
223
|
if (connectTo !== undefined) await saveUrl(url.origin);
|
|
214
224
|
console.log(answer === null ? 'Already approved on ' + url.origin + '.' : JSON.stringify(answer, null, 2));
|
|
215
225
|
console.log('\nKey file: ' + keyPath + '\nServer: ' + url.origin + (connectTo !== undefined ? ' (saved to ' + configPath() + ')' : '') + '\nEverything else is HTTP: Authorization: Bearer <the contents of that file>');
|
|
@@ -219,8 +229,12 @@ async function main() {
|
|
|
219
229
|
// and the person it asked has yet to answer.
|
|
220
230
|
const current = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
221
231
|
if (!current.acts_for?.length) throw new Error('Foundation request failed (401, not_approved). This key acts for nobody yet' + (current.requests?.[0] ? '; it is waiting for approval at ' + current.requests[0].verification_uri : '') + '.');
|
|
232
|
+
// Whose holdings a run reaches: the one this key acts for, or the one named when it acts for several.
|
|
233
|
+
const holder = process.env.FOUNDATION_AS || (current.acts_for.length === 1 ? current.acts_for[0].id : null);
|
|
234
|
+
if (!holder) throw new Error('This key acts for several principals. Set FOUNDATION_AS=<principal id> to say which one this run is for.');
|
|
235
|
+
const forHolder = target => target + (target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(holder);
|
|
222
236
|
let delivery;
|
|
223
|
-
if (names.length) ({ delivery } = await send('/v1/deliveries', { names }));
|
|
237
|
+
if (names.length) ({ delivery } = await send(forHolder('/v1/deliveries'), { names }));
|
|
224
238
|
else delivery = { environment: {}, files: [] };
|
|
225
239
|
if (!delivery || typeof delivery.environment !== 'object' || !Array.isArray(delivery.files)) throw new Error('Foundation returned an invalid delivery.');
|
|
226
240
|
// What each of them sets is the server's to say; this applies it and refuses anything it may not set.
|
|
@@ -250,7 +264,7 @@ async function main() {
|
|
|
250
264
|
}
|
|
251
265
|
}
|
|
252
266
|
};
|
|
253
|
-
const recovery = () => 'Foundation could not confirm the output was saved. The private output file is retained for recovery: ' + outputPath + '\nRetry with foundation api PUT "/v1/
|
|
267
|
+
const recovery = () => 'Foundation could not confirm the output was saved. The private output file is retained for recovery: ' + outputPath + '\nRetry with foundation api PUT "/v1/holdings?kind=secret&name=<URL-encoded-name>" --from <file>, then remove that recovery file.';
|
|
254
268
|
process.once('exit', cleanup);
|
|
255
269
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, () => {
|
|
256
270
|
interrupted = true;
|
|
@@ -287,8 +301,11 @@ async function main() {
|
|
|
287
301
|
if (output && process.exitCode === 0) {
|
|
288
302
|
const bytes = await outputBytes(outputPath);
|
|
289
303
|
retainOutput = true;
|
|
290
|
-
|
|
304
|
+
// 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.
|
|
305
|
+
let saved;
|
|
306
|
+
try { saved = await send(forHolder('/v1/holdings?kind=secret&name=' + encodeURIComponent(output.name)), bytes, { method: 'PUT', type: 'application/octet-stream' }); }
|
|
291
307
|
catch { throw new Error(recovery()); }
|
|
308
|
+
try { await send('/v1/relations', { relation: 'editor', object_type: 'holding', object_id: saved.holding.id }, { method: 'DELETE' }); } catch {}
|
|
292
309
|
retainOutput = false;
|
|
293
310
|
console.error('Saved output as ' + JSON.stringify(output.name) + '.');
|
|
294
311
|
}
|