@tomato414941/foundation 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tomato414941
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/env-name.mjs ADDED
@@ -0,0 +1,9 @@
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/guide.mjs ADDED
@@ -0,0 +1,159 @@
1
+ // Printed by `foundation guide` (and served to MCP clients): the one page an agent reads before using any of this.
2
+ //
3
+ // What Foundation offers is an HTTP API, and that is what this describes. Not every agent can run a
4
+ // command, so nothing here assumes a shell; where one exists there is a small program that adds the one
5
+ // thing HTTP cannot do, and it is described last.
6
+ //
7
+ // This text is read by the agent, not by the owner, so it is English; everything the owner reads
8
+ // (purposes, steps, the dashboard) stays in the owner's language.
9
+ function connectorLines(connectors) {
10
+ if (!connectors) return [' GET /v1/connectors lists what this server can obtain itself.'];
11
+ const lines = [];
12
+ for (const connector of connectors.filter(item => item.available)) {
13
+ lines.push(' ' + connector.id + ' ' + (connector.service?.name || '') + ' / ' + connector.access.name + (connector.variables.length ? ' outputs: ' + connector.variables.join(', ') : ''));
14
+ if (connector.ai) lines.push(' ' + connector.ai);
15
+ }
16
+ const unavailable = connectors.filter(item => !item.available);
17
+ if (unavailable.length) lines.push(' Currently unavailable: ' + unavailable.map(item => item.id).join(', '));
18
+ return lines;
19
+ }
20
+
21
+ export function guide(connectors) {
22
+ return ['Foundation keeps user-controlled values and objects, and runs explicitly requested built-in functions.',
23
+ 'A saved name identifies bytes. It does not select a service, a connection, a function, or an environment variable.',
24
+ 'Storage never invokes a provider: a value saved from a function is an independent snapshot, not a live connection.', '',
25
+ 'Everything below is HTTP with your approved key: Authorization: Bearer fdn_...',
26
+ 'Nothing here needs a shell. With one, use foundation api to attach the key and foundation exec to deliver values',
27
+ 'to a command. api prints responses: use function save options when output should not enter your context.',
28
+ 'Never ask the owner to paste passwords, keys or tokens into chat. Use a storage request or OAuth URL instead.', '',
29
+ 'YOUR KEY',
30
+ ' Your key is a secret of your own making: 43 URL-safe base64 characters after `fdn_`. Generate one, keep it private,',
31
+ ' and never print it. If you can write a private file, keep it there; otherwise keep it wherever your secrets go.',
32
+ ' POST /v1/keys {"name": "<what to call you>"} with your key as the bearer.',
33
+ ' Returns verification_uri and confirmation_code. Give the owner both; they open the URL and type the code.',
34
+ ' Until they do, every other call answers 401 not_approved. Retry GET /v1/keys/current every few seconds. Do not hammer it.',
35
+ ' GET /v1/keys/current your request and what happened at its page; once approved, also who you are (key).',
36
+ ' Approval also has kind=approve, status=done and result.key_id. A current key, not request completion, authorizes use.',
37
+ ' Revoking a key stops Foundation access; credentials already obtained must be revoked at their provider.',
38
+ ' PATCH /v1/keys/current {"name"} DELETE /v1/keys/current cancels the request, or revokes your approved key.', '',
39
+ 'WHAT IS KEPT',
40
+ ' PUT /v1/secrets?name=<name>&secret=true body: raw bytes, up to 1MB. The same exact name replaces that value.',
41
+ ' secret=true blocks direct GET by an access key. The owner can read it; authorized delivery can still return it.',
42
+ ' This is not a boundary against an agent with the approved key. Do not print delivered values into its context.',
43
+ ' GET /v1/secrets names, sizes, read permissions and timestamps, not contents.',
44
+ ' GET /v1/secrets?prefix=<literal prefix> optional case-sensitive text filtering, not a directory.',
45
+ ' GET /v1/secrets?name=<name> bytes, as written; 403 write_only for a secret.',
46
+ ' DELETE /v1/secrets?name=<name>',
47
+ ' 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',
48
+ ' remain literal. No normalization, hierarchy, service ownership or automatic renewal is inferred.',
49
+ ' The owner can read, rename or delete any saved value. Stored copies survive OAuth disconnection.', '',
50
+ 'HANDING IT TO A COMMAND',
51
+ ' POST /v1/deliveries {"names": [{"name": "build token", "as": "GH_TOKEN"}]}',
52
+ ' Each item is {name, as, filename?}. as is required: it names the environment variable, independently of name.',
53
+ ' filename makes the bytes a temporary file instead; as holds its local path. Use this for binary or multiline data.',
54
+ ' Up to 16 inputs. Variables and filenames must be distinct; reserved system variables are refused.',
55
+ ' Returns delivery: {environment, files}. No provider check or refresh occurs; expiry of stored bytes is unknown.',
56
+ ' Calling this into an agent context exposes values. Use exec when the intent is to run a local command.', '',
57
+ 'FILLING THE STORE',
58
+ '',
59
+ '1. Put it there yourself. Anything you obtained or wrote: PUT /v1/secrets?name=<name>, above.',
60
+ '',
61
+ '2. ASKING THE OWNER, for what only they can fetch -- an API token, a key, a certificate they must go and create.',
62
+ ' POST /v1/requests {"kind":"store", "input":{"fields":[{...}]}, "purpose":"...", "steps":["..."], "valid_minutes":30}',
63
+ ' fields[].name suggested name; result.names returns the names the owner chose, result.replaced those replaced',
64
+ ' fields[].label what they are being asked for, in their language. It titles the screen and names the field.',
65
+
66
+ ' fields[].site the page where they make it, offered as a link',
67
+ ' fields[].secret false lets you read it back afterwards; the default is that you cannot',
68
+ ' fields[].multiline true for something like a PEM',
69
+ ' fields[].replace true to put the value in place of the one already kept under that exact name (rotation).',
70
+ ' Refused at creation: name_taken when the name exists and replace is not declared, name_missing',
71
+ ' when it is declared and nothing is there. The owner may still give it another name, in which',
72
+ ' case nothing is replaced; result.replaced lists the names that were.',
73
+ ' purpose one concrete sentence the owner can judge, in their language',
74
+ ' steps what they do, one string per step (up to 20, 500 characters each, no line breaks). Foundation',
75
+ ' holds no instructions for anyone else\'s',
76
+ ' site: look up what to click now, and write it yourself.',
77
+ ' Give the owner the verification_uri (there is no code). When they finish, it is simply kept.',
78
+ ' Ask for what the owner already holds. Never take it through the conversation and put it there yourself.',
79
+ '',
80
+ '3. CONNECTING A SERVICE for later explicit credential processing. OAuth renewal state stays with the connection,',
81
+ ' separate from ordinary saved values. Connecting does not create named values.',
82
+ ...connectorLines(connectors),
83
+ ' POST /v1/requests {"kind":"connect", "input":{"connector":"<id>"}, "purpose":"...", "valid_minutes":30} Give the owner the verification_uri.',
84
+ ' Poll GET /v1/requests/<id> every few seconds until done; result.connection_id identifies the connection.',
85
+ ' GET /v1/connections connection IDs, labels, provider details and available output identifiers.', '',
86
+ 'FUNCTIONS',
87
+ ' GET /v1/functions catalog of built-in operations and their invocation endpoints; no arbitrary-code runtime.',
88
+ ' POST /v1/functions/connection.credentials',
89
+ ' {"connection_id": "<id>", "save": {"<output identifier>": "<chosen saved name>"}}',
90
+ ' Checks or renews that connection and saves only the outputs you explicitly name. Returns saved metadata and',
91
+ ' actual expiry, without credential values. Existing values at those exact names are replaced atomically.',
92
+ ' Omit save to receive delivery directly. Prefer save when invoking from an AI tool; then use exec or http.request.',
93
+ ' Saving creates independent copies: subsequent reads never refresh them; invoke this function again when needed.',
94
+ ' Disconnecting stops future processing, not copies already saved or handed out. Revoke at the provider if needed.', '',
95
+ 'WHEN IT DOES NOT WORK',
96
+ ' GET /v1/requests/<id> one of your requests, and what happened at its page (events).',
97
+ ' GET /v1/requests?status=pending your requests. Several may be open at once (up to 10).',
98
+ ' events is the raw record, in order: page_opened / page_viewed / connect_started / connect_failed (with a code and',
99
+ ' Foundation\'s own message) / connected / stored / denied / cancelled. What was typed is never recorded.',
100
+ ' DELETE /v1/requests/<id> cancels it. status is pending / done / denied / cancelled.',
101
+ ' Completion and result are fixed until the request expires, even if the resulting resource changes or is removed.',
102
+ ' Current connection state is at GET /v1/connections. Revoked keys receive 401; their pending requests are cancelled.',
103
+ ' Request feedback expires with the request. A done result is a connection_id or the names saved at completion.',
104
+ ' Why a registration failed: invalid_values (wrong shape) / invalid_credential (the connector would not take it) /',
105
+ ' reconnect_required (the service rejected it) / already_connected. A key approval shows its own events at',
106
+ ' GET /v1/keys/current: confirmation_required (a wrong code) / confirmation_locked (5 tries).', '',
107
+ 'A PLACE FOR FILES (object storage the owner did not have to sign up for)',
108
+ ' PUT /v1/objects/<key> body is the bytes; the Content-Type you send is what a reader gets back. Up to 25MB.',
109
+ ' GET /v1/objects/<key> the bytes. DELETE removes it. GET /v1/objects?prefix=<p>&cursor=<c> lists what is there.',
110
+ ' POST /v1/objects/<key>/link {"minutes":n} a time-limited URL anyone can read, for a thing that only takes a URL.',
111
+ ' Keys look like a path (a/b/c.txt) but name one whole object: no renaming, no directories, no partial reads or writes.',
112
+ ' This is not a filesystem. If the owner needs one, they need a machine to mount it on.',
113
+ ' 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.', '',
114
+ 'HTTPS REQUEST FUNCTION',
115
+ ' POST /v1/functions/http.request {"url": "https://api.example.com/v1/items", "method": "POST",',
116
+ ' "headers": {"authorization": "Bearer {{foundation:token}}"}, "bindings": {"token": "build token"}, "body": "..."}',
117
+ ' Each placeholder identifies an input slot. bindings maps slots to exact saved names (including braces or spaces).',
118
+ ' Without bindings, a slot is itself a saved name. Inputs only in headers/body, never in the URL. Up to 8 inputs.',
119
+ ' body_encoding "base64" sends bytes without substitution. Input values are read as stored, never refreshed.',
120
+ ' Returns {response: {status, headers, body, body_encoding}}. Common echoes of input secrets are redacted;',
121
+ ' do not treat redaction as protection against arbitrary transformations by an untrusted destination.',
122
+ ' Optional save: "<name>" stores the response body and returns only response status/headers and saved metadata.',
123
+ ' Redirects are returned, not followed.',
124
+ ' HTTPS on 443, public hostnames only, never this server or private networks. 1MB each way, 20 seconds, 30/minute.',
125
+ ' No workflow, schedule or implicit execution: the caller chooses each invocation and each saved output.', '',
126
+ 'THE SHELL (only if you can run commands)',
127
+ ' Install: npm install -g @tomato414941/foundation (Node.js 24 or later)',
128
+ ' foundation connect [<url>] [--name <name>]',
129
+ ' Makes the key as a private file and asks for approval. The key is never printed, so it never reaches you.',
130
+ ' Given a URL, it also remembers that server for every later command; run it again when the server moves.',
131
+ ' foundation api <GET|POST|PUT|DELETE|PATCH> </path> [--json <body>] [--from <file>] [--type <media-type>]',
132
+ ' Any request above, with the key attached and the answer printed as it came. Prefer this over curl: reading the key',
133
+ ' file to build a header yourself would put the key in your context, which is the thing this avoids.',
134
+ ' foundation exec <ENV>=<name> [...] -- <command> [args...]',
135
+ ` foundation exec --inputs '[{"name":"signing key","as":"KEY_FILE","filename":"AuthKey.p8"}]' -- <command>`,
136
+ ' ENV=name treats everything after the first = literally. Use --inputs JSON for files or structured inputs.',
137
+ ' Only the child process gets delivered values; files exist only while it runs. FOUNDATION_NAMES is a JSON array',
138
+ ' of the exact saved names used. Prevent the child command from logging or echoing secrets.',
139
+ ' Use this instead of POST /v1/deliveries whenever the point is to run something.',
140
+ ` foundation exec --output '{"name":"login config","as":"AUTH_FILE","filename":"auth.json"}' -- <command>`,
141
+ ' Creates one empty private file and sets as to its path. Tell the command to write its authentication result there.',
142
+ ' After exit 0, saves the file bytes under the exact name with secret=true, then removes the temporary file.',
143
+ ' An existing value at that name is replaced. Output must be a private regular file containing 1 byte to 1MB.',
144
+ ' May be combined with inputs, using a different environment variable. A failed command never saves its output.',
145
+ ' If upload cannot be confirmed, exits with an error and reports the retained private file for recovery; inputs are',
146
+ ' still removed. Retry with api PUT --from, then remove the recovery file. Other temporary files are removed.',
147
+ ' Use native service login URLs; never collect passwords in chat. Stored credentials keep their original expiry.',
148
+ ' Environment: FOUNDATION_URL (optional; overrides the remembered server), FOUNDATION_AGENT (optional; your name, e.g. claude / codex), FOUNDATION_RUNTIME_KEY_FILE (optional).',
149
+ ' One key per machine and OS user by default. FOUNDATION_AGENT gives each agent its own key, but any agent running as the',
150
+ ' same OS user can read that file, so it is bookkeeping, not protection. To separate them for real, use separate OS users.', '',
151
+ 'RULES',
152
+ '- Never print, log or write out what is delivered. Hand it to a command, and nowhere else.',
153
+ '- Send saved values only to an authorized destination. http.request and exec use the destinations you choose.',
154
+ '- Ask for the least you need. Do not request something the work does not use.',
155
+ '- Be honest in name, purpose and label. The owner decides based on them.',
156
+ '- If a request is denied or expires, ask the owner why rather than guessing.',
157
+ '- Use what you receive only for the work it was asked for.',
158
+ ].join('\n');
159
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@tomato414941/foundation",
3
+ "version": "0.1.0",
4
+ "description": "Foundation CLI: make a key, and hand what is kept to a command without it passing through the agent.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=24"
8
+ },
9
+ "bin": {
10
+ "foundation": "runtime.mjs"
11
+ },
12
+ "files": [
13
+ "runtime.mjs",
14
+ "guide.mjs",
15
+ "env-name.mjs"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/tomato414941/foundation.git",
20
+ "directory": "cli"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "license": "MIT"
27
+ }
package/runtime.mjs ADDED
@@ -0,0 +1,295 @@
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, randomBytes } from 'node:crypto';
7
+ import { homedir, hostname, tmpdir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ import { createRequire } from 'node:module';
10
+ import { validEnvName } from './env-name.mjs';
11
+ import { guide } from './guide.mjs';
12
+
13
+ // This program does only what the agent running it cannot do for itself.
14
+ //
15
+ // Everything Foundation offers is plain HTTP, and an agent with the key can call it directly; a command
16
+ // wrapper around those calls would only narrow what the agent is allowed to think of. Two things are left:
17
+ // connect say which server, and make the key. It has to exist as a private file before anything can be asked, and whoever
18
+ // makes it must not print it.
19
+ // exec hand what is kept to a command, or keep a file it creates, without the bytes passing through
20
+ // the agent. If the agent fetched the values itself they would be in its context.
21
+ // There is also `api`, which is for people and for scripts rather than for agents: it attaches the key to a
22
+ // request and prints what comes back. One escape hatch, so that the API can grow without this program growing
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
+ }
39
+ let handle;
40
+ try {
41
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
42
+ const info = await handle.stat();
43
+ if (!info.isFile() || info.size > 512 || (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).');
44
+ const token = (await handle.readFile('utf8')).trim();
45
+ if (!/^fdn_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error('Invalid runtime key file.');
46
+ return token;
47
+ } catch (error) {
48
+ if (error.code === 'ENOENT') throw new Error('No key yet. Run: foundation connect');
49
+ if (error.code === 'ELOOP') throw new Error('Runtime key file must not be a symbolic link.');
50
+ throw error;
51
+ } finally { await handle?.close(); }
52
+ }
53
+
54
+ const VERSION = createRequire(import.meta.url)('./package.json').version;
55
+ // Which server this machine talks to is a setting, not part of the program: `connect <url>` writes it here,
56
+ // and FOUNDATION_URL, when set, wins for that one run.
57
+ const configPath = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'foundation', 'config.json');
58
+ async function savedUrl() {
59
+ try { return JSON.parse(await readFile(configPath(), 'utf8')).url || ''; }
60
+ catch (error) { if (error.code === 'ENOENT') return ''; throw new Error('Cannot read ' + configPath() + ': ' + error.message); }
61
+ }
62
+ async function saveUrl(origin) {
63
+ const path = configPath();
64
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
65
+ await writeFile(path + '.tmp', JSON.stringify({ url: origin }, null, 2) + '\n', { mode: 0o600 });
66
+ await rename(path + '.tmp', path);
67
+ }
68
+ function serverUrl(value) {
69
+ let url;
70
+ try { url = new URL(value); } catch { throw new Error('No Foundation server yet. Run: foundation connect <url>'); }
71
+ 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).');
72
+ return url;
73
+ }
74
+
75
+ const validFilename = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value);
76
+ async function outputBytes(path) {
77
+ let handle;
78
+ try {
79
+ const directory = await lstat(dirname(path));
80
+ 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.');
81
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
82
+ const info = await handle.stat(), limit = 1024 * 1024;
83
+ 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).');
84
+ if (info.size > limit) throw new Error('Output must contain 1 byte to 1MB.');
85
+ // Bound the read too: the file may grow after stat, or still have a writer.
86
+ const bytes = Buffer.alloc(limit + 1);
87
+ let size = 0;
88
+ while (size < bytes.length) {
89
+ const { bytesRead } = await handle.read(bytes, size, bytes.length - size, null);
90
+ if (!bytesRead) break;
91
+ size += bytesRead;
92
+ }
93
+ if (!size || size > limit) throw new Error('Output must contain 1 byte to 1MB.');
94
+ return bytes.subarray(0, size);
95
+ } catch (error) {
96
+ if (error.code === 'ELOOP') throw new Error('Output must not be a symbolic link.');
97
+ if (error.code === 'ENOENT') throw new Error('The command did not create its output file.');
98
+ throw error;
99
+ } finally { await handle?.close(); }
100
+ }
101
+
102
+ // --help is the usual thing: the commands and their options. The guide (what Foundation is and how to ask it
103
+ // for things) is its own command, since it is the server's document, not this program's.
104
+ const HELP = `Usage: foundation <command> [options]
105
+
106
+ Commands:
107
+ connect [<url>] [--name <name>] Make this machine's key and ask the owner to approve it.
108
+ With <url>, remember that Foundation server for later commands.
109
+ api <METHOD> </path> [--json <body>] [--from <file>] [--type <media-type>]
110
+ Send one request to the Foundation API with the key attached.
111
+ exec <ENV>=<name> [...] -- <command> [args...]
112
+ Run a command with saved values in its environment.
113
+ exec --inputs '<json>' -- <command> The same, with files or structured inputs.
114
+ exec --output '<json>' -- <command> Also save a file the command writes.
115
+ guide The API guide: what Foundation keeps, and how to ask it for things.
116
+ version Print the version.
117
+
118
+ Environment:
119
+ FOUNDATION_URL The server for this run (otherwise the one saved by connect).
120
+ FOUNDATION_AGENT Your name, such as claude or codex; gives each agent its own key file.
121
+ FOUNDATION_RUNTIME_KEY_FILE Where the key file is.
122
+ `;
123
+
124
+ async function main() {
125
+ const [action, ...args] = process.argv.slice(2);
126
+ const agentName = (process.env.FOUNDATION_AGENT || '').trim();
127
+ 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.');
128
+ if (action === '--version' || action === '-v' || action === 'version') { console.log(VERSION); return; }
129
+ const configured = process.env.FOUNDATION_URL || await savedUrl();
130
+ if (action === '--help' || action === '-h' || action === 'help' || !action) { console.log(HELP); return; }
131
+ if (action === 'guide') {
132
+ let connectors;
133
+ if (configured) {
134
+ try {
135
+ const response = await fetch(new URL('/v1/connectors', configured), { redirect: 'error', signal: AbortSignal.timeout(5_000) });
136
+ const catalog = await response.json();
137
+ if (response.ok && Array.isArray(catalog.connectors)) connectors = catalog.connectors;
138
+ } catch {}
139
+ }
140
+ console.log(guide(connectors));
141
+ return;
142
+ }
143
+ const separatorAt = args.indexOf('--'), command = separatorAt >= 0 ? args.slice(separatorAt + 1) : [];
144
+ // Names remain literal. Inputs deliver bytes; an optional output saves one generated file.
145
+ let names = [], output;
146
+ if (action === 'exec' && separatorAt > 0) {
147
+ const parsed = parseArgs({ args: args.slice(0, separatorAt), options: { inputs: { type: 'string' }, output: { type: 'string' } }, strict: true, allowPositionals: true });
148
+ if (parsed.values.inputs !== undefined && parsed.positionals.length) throw new Error('--inputs and ENV=name are alternatives.');
149
+ if (parsed.values.inputs !== undefined) {
150
+ try { names = JSON.parse(parsed.values.inputs); } catch { throw new Error('--inputs must be a JSON array of {name, as, filename?}.'); }
151
+ } else names = parsed.positionals.map(value => {
152
+ const at = value.indexOf('=');
153
+ if (at < 1) throw new Error('Specify the environment variable explicitly: ENV=name');
154
+ return { name: value.slice(at + 1), as: value.slice(0, at) };
155
+ });
156
+ if (!Array.isArray(names) || names.length > 16 || names.some(item => !item || typeof item.name !== 'string' || !item.name || !validEnvName(item.as))) throw new Error('Each input needs a name and a non-reserved environment variable in as.');
157
+ if (new Set(names.map(item => item.as)).size !== names.length) throw new Error('Each input needs a different environment variable.');
158
+ if (parsed.values.output !== undefined) {
159
+ try { output = JSON.parse(parsed.values.output); } catch { throw new Error('--output must be a JSON object {name, as, filename}.'); }
160
+ 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}.');
161
+ 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.');
162
+ 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).');
163
+ if (names.some(item => item.as === output.as)) throw new Error('Output needs a different environment variable from every input.');
164
+ }
165
+ }
166
+ let call, connectTo, name;
167
+ if (action === 'connect') {
168
+ const parsed = parseArgs({ args, options: { name: { type: 'string' } }, strict: true, allowPositionals: true });
169
+ if (parsed.positionals.length > 1) throw new Error('Usage: connect [<url>] [--name <name>]');
170
+ connectTo = parsed.positionals[0];
171
+ name = parsed.values.name;
172
+ } else if (action === 'api') {
173
+ const parsed = parseArgs({ args, options: { json: { type: 'string' }, from: { type: 'string' }, type: { type: 'string' } }, strict: true, allowPositionals: true });
174
+ if (parsed.positionals.length !== 2 || !/^(GET|POST|PUT|DELETE|PATCH)$/.test(parsed.positionals[0]) || !parsed.positionals[1].startsWith('/')) {
175
+ throw new Error('Usage: api <GET|POST|PUT|DELETE|PATCH> </path> [--json <body>] [--from <file>] [--type <media-type>]');
176
+ }
177
+ if (parsed.values.json !== undefined && parsed.values.from !== undefined) throw new Error('--json and --from are alternatives.');
178
+ // A request that carries nothing still says so in JSON, which is what the server asks of anything but a GET.
179
+ const method = parsed.positionals[0];
180
+ 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('{}');
181
+ call = { method, target: parsed.positionals[1], body: content,
182
+ type: parsed.values.type || (parsed.values.from !== undefined ? 'application/octet-stream' : 'application/json') };
183
+ } else if (!(action === 'exec' && (names.length || output) && command.length)) {
184
+ throw new Error('Usage: connect [<url>] [--name <name>] | exec [<ENV>=<name> ... | --inputs <json>] [--output <json>] -- <command> [args...] | api <method> </path> [--json <body>] [--from <file>]');
185
+ }
186
+ const url = serverUrl(connectTo ?? configured);
187
+ 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);
189
+ async function send(target, payload, { accept, method = 'POST', type = 'application/json' } = {}) {
190
+ const response = await fetch(url.origin + target, { method, headers: { authorization: 'Bearer ' + token, ...(payload === undefined ? {} : { 'content-type': type }) },
191
+ body: payload === undefined ? undefined : type === 'application/json' ? JSON.stringify(payload) : payload, redirect: 'error', signal: AbortSignal.timeout(30_000) });
192
+ const data = await response.json();
193
+ 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.'));
194
+ return data;
195
+ }
196
+ // One request, with the key attached and the answer printed as it came. Nothing here knows the endpoints.
197
+ if (action === 'api') {
198
+ const response = await fetch(url.origin + call.target, { method: call.method, headers: { authorization: 'Bearer ' + token, ...(call.body === undefined ? {} : { 'content-type': call.type }) },
199
+ ...(call.body === undefined ? {} : { body: call.body }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
200
+ const bytes = Buffer.from(await response.arrayBuffer());
201
+ process.stdout.write(bytes);
202
+ if (bytes.length && !bytes.subarray(-1).equals(Buffer.from('\n'))) process.stdout.write('\n');
203
+ if (!response.ok) process.exitCode = 1;
204
+ return;
205
+ }
206
+ // Asking the owner to approve this key. The key itself is never printed: it stays in the file.
207
+ // A key the owner already approved has nothing to ask; connecting again only changes which server is remembered.
208
+ if (action === 'connect') {
209
+ const answer = await send('/v1/keys', { name: name ?? hostname() + ' の ' + (agentName || 'AI') }, { accept: data => data.error?.code === 'already_approved' });
210
+ if (connectTo !== undefined) await saveUrl(url.origin);
211
+ console.log(answer.error ? 'Already approved on ' + url.origin + '.' : JSON.stringify(answer, null, 2));
212
+ console.log('\nKey file: ' + keyPath + '\nServer: ' + url.origin + (connectTo !== undefined ? ' (saved to ' + configPath() + ')' : '') + '\nEverything else is HTTP: Authorization: Bearer <the contents of that file>');
213
+ return;
214
+ }
215
+ // Even output-only commands need an approved key before they start an external login.
216
+ let delivery;
217
+ if (names.length) ({ delivery } = await send('/v1/deliveries', { names }));
218
+ else {
219
+ const current = await send('/v1/keys/current', undefined, { method: 'GET' });
220
+ if (!current.key) throw new Error('Foundation request failed (401, not_approved). This key is waiting for approval at ' + current.request?.verification_uri + '.');
221
+ delivery = { environment: {}, files: [] };
222
+ }
223
+ if (!delivery || typeof delivery.environment !== 'object' || !Array.isArray(delivery.files)) throw new Error('Foundation returned an invalid delivery.');
224
+ // What each of them sets is the server's to say; this applies it and refuses anything it may not set.
225
+ const environment = { ...process.env };
226
+ delete environment.FOUNDATION_RUNTIME_KEY_FILE;
227
+ const assign = (name, value) => {
228
+ if (!validEnvName(name)) throw new Error('Foundation named a reserved environment variable (' + name + ').');
229
+ if (typeof value !== 'string' || /[\x00\r\n]/.test(value) || value.length > 16384) throw new Error('Foundation returned an invalid value for ' + name + '.');
230
+ environment[name] = value;
231
+ };
232
+ for (const [name, value] of Object.entries(delivery.environment)) assign(name, value);
233
+ const fileNames = new Set(), variables = new Set(Object.keys(delivery.environment));
234
+ for (const file of delivery.files) {
235
+ 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.');
236
+ fileNames.add(file.filename); variables.add(file.env);
237
+ }
238
+ if (output && variables.has(output.as)) throw new Error('Output needs a different environment variable from every input.');
239
+ environment.FOUNDATION_NAMES = JSON.stringify(names.map(item => item.name));
240
+ // Delivered inputs are always cleaned up. A completed output survives only an unconfirmed upload.
241
+ let secretDir, outputDir, outputPath, child, interrupted = false, retainOutput = false;
242
+ const cleanup = () => {
243
+ if (secretDir) rmSync(secretDir, { recursive: true, force: true });
244
+ if (outputDir) {
245
+ if (!retainOutput) rmSync(outputDir, { recursive: true, force: true });
246
+ else for (const entry of readdirSync(outputDir)) {
247
+ if (entry !== output.filename) rmSync(join(outputDir, entry), { recursive: true, force: true });
248
+ }
249
+ }
250
+ };
251
+ 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/secrets?name=<URL-encoded-name>&secret=true" --from <file>, then remove that recovery file.';
252
+ process.once('exit', cleanup);
253
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, () => {
254
+ interrupted = true;
255
+ if (child?.pid && child.exitCode === null && child.signalCode === null) {
256
+ child.kill(signal);
257
+ setTimeout(() => child.kill('SIGKILL'), 5_000).unref();
258
+ return;
259
+ }
260
+ if (retainOutput) console.error(recovery());
261
+ cleanup(); process.exit(1);
262
+ });
263
+ try {
264
+ const temporaryDirectory = async () => {
265
+ 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-'));
266
+ await chmod(directory, 0o700);
267
+ return directory;
268
+ };
269
+ if (delivery.files.length) {
270
+ secretDir = await temporaryDirectory();
271
+ for (const file of delivery.files) {
272
+ const target = join(secretDir, file.filename);
273
+ await writeFile(target, file.encoding === 'base64' ? Buffer.from(file.content, 'base64') : file.content, { mode: 0o600, flag: 'wx' });
274
+ assign(file.env, target);
275
+ }
276
+ }
277
+ if (output) {
278
+ outputDir = await temporaryDirectory();
279
+ outputPath = join(outputDir, output.filename);
280
+ await writeFile(outputPath, '', { mode: 0o600, flag: 'wx' });
281
+ assign(output.as, outputPath);
282
+ }
283
+ child = spawn(command[0], command.slice(1), { stdio: 'inherit', env: environment, shell: false });
284
+ process.exitCode = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (value, signal) => resolve(interrupted ? 1 : value ?? (signal ? 1 : 0))); });
285
+ if (output && process.exitCode === 0) {
286
+ const bytes = await outputBytes(outputPath);
287
+ retainOutput = true;
288
+ try { await send('/v1/secrets?name=' + encodeURIComponent(output.name) + '&secret=true', bytes, { method: 'PUT', type: 'application/octet-stream' }); }
289
+ catch { throw new Error(recovery()); }
290
+ retainOutput = false;
291
+ console.error('Saved output as ' + JSON.stringify(output.name) + '.');
292
+ }
293
+ } finally { cleanup(); }
294
+ }
295
+ main().catch((error) => { console.error(error instanceof TypeError ? 'Unable to connect. Check the Foundation URL and network access.' : error.message); process.exitCode = 1; });