@tomato414941/foundation 0.3.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.
Files changed (3) hide show
  1. package/guide.mjs +7 -5
  2. package/package.json +1 -1
  3. package/runtime.mjs +36 -22
package/guide.mjs CHANGED
@@ -28,15 +28,17 @@ 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: a secret of your own making, 43 URL-safe base64 characters after `fdn_`. Generate one, keep it private,',
32
- ' and never print it. If you can write a private file, keep it there; otherwise keep it wherever your secrets go.',
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: everything below answers as',
37
- ' your own, empty, holdings. Retry GET /v1/principals/me every few seconds and look at acts_for. Do not hammer it.',
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
- ' Acting for exactly one principal, their holdings are what the calls below reach. For several, name one: ?as=<id>.',
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
44
  ' GET /v1/relations every line you are on. GET /v1/records what was done in your name or to you.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomato414941/foundation",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Foundation CLI: make a key, 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
@@ -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, randomBytes } from 'node:crypto';
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
- async function runtimeKey(path, create, privateDirectory) {
25
- if (create) {
26
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
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
- const token = await runtimeKey(keyPath, action === 'connect', !process.env.FOUNDATION_RUNTIME_KEY_FILE);
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
- try { me = await send('/v1/principals/me', undefined, { method: 'GET' }); } catch {}
212
- const answer = me?.acts_for?.length ? null : await send('/v1/requests', { kind: 'actor', input: { name: name ?? hostname() + ' の ' + (agentName || 'AI') } });
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.
@@ -289,7 +303,7 @@ async function main() {
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.
291
305
  let saved;
292
- try { saved = await send('/v1/holdings?kind=secret&name=' + encodeURIComponent(output.name), bytes, { method: 'PUT', type: 'application/octet-stream' }); }
306
+ try { saved = await send(forHolder('/v1/holdings?kind=secret&name=' + encodeURIComponent(output.name)), bytes, { method: 'PUT', type: 'application/octet-stream' }); }
293
307
  catch { throw new Error(recovery()); }
294
308
  try { await send('/v1/relations', { relation: 'editor', object_type: 'holding', object_id: saved.holding.id }, { method: 'DELETE' }); } catch {}
295
309
  retainOutput = false;