@tomato414941/foundation 0.3.0 → 0.5.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 +47 -36
- package/package.json +1 -1
- package/runtime.mjs +42 -26
package/guide.mjs
CHANGED
|
@@ -19,24 +19,28 @@ function connectorLines(connectors) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function guide(connectors) {
|
|
22
|
-
return ['Foundation
|
|
23
|
-
'A
|
|
24
|
-
'
|
|
22
|
+
return ['Foundation holds, for each principal, grants and objects, and runs explicitly requested built-in functions.',
|
|
23
|
+
'A grant is what its holder let Foundation do at a provider: a credential they handed over (given), a consent they gave',
|
|
24
|
+
'at the service (authorized), or a role they made for Foundation to assume (delegated). An object is bytes they placed here.',
|
|
25
|
+
'Reading never reaches a provider. Delivering a grant derives what it yields now: a given one its bytes, the others what',
|
|
26
|
+
'their connector obtains. A name is the holder\'s word for a thing; it decides no service, function or variable.', '',
|
|
25
27
|
'Everything below is HTTP with your approved key: Authorization: Bearer fdn_...',
|
|
26
28
|
'Nothing here needs a shell. With one, use foundation api to attach the key and foundation exec to deliver values',
|
|
27
29
|
'to a command. api prints responses: use function save options when output should not enter your context.',
|
|
28
30
|
'Never ask the owner to paste passwords, keys or tokens into chat. Use a storage request or OAuth URL instead.', '',
|
|
29
31
|
'WHO YOU ARE',
|
|
30
32
|
' 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
|
-
'
|
|
33
|
+
' proves it. Foundation issues it, once, and shows it once: keep it private and never print it. If you can write a',
|
|
34
|
+
' private file, keep it there; otherwise keep it wherever your secrets go.',
|
|
35
|
+
' POST /v1/principals {"name": "<what to call you>"} with no credential at all. Makes you, and answers with token.',
|
|
33
36
|
' What you may reach follows from lines between principals: whoever you act for (actor), whoever made you (owner).',
|
|
34
37
|
' POST /v1/requests {"kind": "actor", "input": {"name": "<what to call you>"}} with your key as the bearer.',
|
|
35
38
|
' 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
|
-
'
|
|
39
|
+
' both; they open the URL and type the code. Until they do you act for nobody. Retry GET /v1/principals/me every',
|
|
40
|
+
' few seconds and look at acts_for. Do not hammer it.',
|
|
38
41
|
' GET /v1/principals/me who you are: principal, acts_for (whom you act for), owners, credentials, your open requests.',
|
|
39
|
-
'
|
|
42
|
+
' Every call below reaches your own holdings unless you name whose: ?as=<id of one you act for>. The foundation CLI',
|
|
43
|
+
' and the MCP tool add ?as= for you when you act for exactly one; over raw HTTP, add it yourself.',
|
|
40
44
|
' PATCH /v1/principals/me {"name"} DELETE /v1/principals/me a new name; leaving (your key stops working).',
|
|
41
45
|
' GET /v1/requests/<id> one of your requests and what happened at its page (events). DELETE cancels it.',
|
|
42
46
|
' GET /v1/relations every line you are on. GET /v1/records what was done in your name or to you.',
|
|
@@ -44,28 +48,36 @@ export function guide(connectors) {
|
|
|
44
48
|
' POST /v1/relations {"subject":"<principal id>","relation":"viewer"|"editor","object_type":"holding","object_id":"<id>"} DELETE takes it back.',
|
|
45
49
|
' GET /v1/holdings what others have shown you. GET /v1/holdings/<id> one thing, whoever holds it.',
|
|
46
50
|
' GET /v1/holdings/<id>/content reads it; PUT writes it, as editor. Lines point at the id, so renaming changes nothing.', '',
|
|
47
|
-
'
|
|
48
|
-
' PUT /v1/holdings?kind=
|
|
51
|
+
'GRANTS (what the holder let Foundation use)',
|
|
52
|
+
' PUT /v1/holdings?kind=grant&name=<name>[&provider=<id>&tags=<a,b>] body: raw bytes, up to 1MB. A given grant:',
|
|
53
|
+
' the same exact name replaces that value; the answer carries its id. provider names the service it belongs to',
|
|
54
|
+
' (a short lowercase id: aws, stripe, github, ...); tags are the holder\'s words for grouping (a project, an',
|
|
55
|
+
' environment). Both may be changed later. Set them when you know them: the owner sorts by them.',
|
|
49
56
|
' What you keep for the holder you may read back (an editor line is drawn for you). What the holder kept, or a',
|
|
50
|
-
'
|
|
57
|
+
' service authorized, you may deliver into a command but not read, unless the holder draws you a line.',
|
|
51
58
|
' Do not print delivered values into your context.',
|
|
52
|
-
' GET /v1/holdings?kind=
|
|
53
|
-
'
|
|
54
|
-
' GET /v1/holdings
|
|
55
|
-
'
|
|
59
|
+
' GET /v1/holdings?kind=grant every grant: id, name, method (given / authorized / delegated), provider, tags, status;',
|
|
60
|
+
' for a connected one also its service, facts and the variables it yields. &method= &provider= &tag= &prefix= narrow it.',
|
|
61
|
+
' GET /v1/holdings?kind=grant&name=<name> one given grant by name: its id and metadata. 404 if no such name.',
|
|
62
|
+
' GET /v1/holdings/<id>/content a given grant\'s bytes, as written; 403 forbidden without a line to it. PUT writes them.',
|
|
63
|
+
' A connected grant has no content to read (405): what it yields is derived when delivered.',
|
|
64
|
+
' PATCH /v1/holdings/<id> {"name","provider","tags"} new words for the same thing. DELETE /v1/holdings/<id> removes a given one.',
|
|
56
65
|
' 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',
|
|
57
66
|
' remain literal. No normalization, hierarchy, service ownership or automatic renewal is inferred.',
|
|
58
67
|
' The owner can read, rename or delete any saved value. Stored copies survive OAuth disconnection.', '',
|
|
59
68
|
'HANDING IT TO A COMMAND',
|
|
60
|
-
' POST /v1/deliveries {"names": [{"name": "build token", "as": "GH_TOKEN"}]}',
|
|
61
|
-
' Each item is {name, as, filename?}.
|
|
69
|
+
' POST /v1/deliveries {"names": [{"name": "build token", "as": "GH_TOKEN"}, {"name": "<id of a connected grant>"}]}',
|
|
70
|
+
' Each item is {name, as, filename?}. name is a given grant\'s name or any grant\'s id. For a given grant, as is',
|
|
71
|
+
' required: it names the environment variable. A connected grant yields its connector\'s variables (see outputs);',
|
|
72
|
+
' as may rename the one variable of a connector that has exactly one. Delivering a connected grant asks the service',
|
|
73
|
+
' for a fresh credential when needed; expires_at says how long what came back is good.',
|
|
62
74
|
' filename makes the bytes a temporary file instead; as holds its local path. Use this for binary or multiline data.',
|
|
63
75
|
' Up to 16 inputs. Variables and filenames must be distinct; reserved system variables are refused.',
|
|
64
|
-
' Returns delivery: {environment, files}.
|
|
65
|
-
'
|
|
76
|
+
' Returns delivery: {environment, files}. Calling this into an agent context exposes values. Use exec when the intent',
|
|
77
|
+
' is to run a local command.', '',
|
|
66
78
|
'FILLING THE STORE',
|
|
67
79
|
'',
|
|
68
|
-
'1. Put it there yourself. Anything you obtained or wrote: PUT /v1/holdings?kind=
|
|
80
|
+
'1. Put it there yourself. Anything you obtained or wrote: PUT /v1/holdings?kind=grant&name=<name>, above.',
|
|
69
81
|
'',
|
|
70
82
|
'2. ASKING THE OWNER, for what only they can fetch -- an API token, a key, a certificate they must go and create.',
|
|
71
83
|
' POST /v1/requests {"kind":"store", "input":{"fields":[{...}]}, "purpose":"...", "steps":["..."], "valid_minutes":30}',
|
|
@@ -86,21 +98,18 @@ export function guide(connectors) {
|
|
|
86
98
|
' Give the owner the verification_uri (there is no code). When they finish, it is simply kept.',
|
|
87
99
|
' Ask for what the owner already holds. Never take it through the conversation and put it there yourself.',
|
|
88
100
|
'',
|
|
89
|
-
'3. CONNECTING A SERVICE
|
|
90
|
-
'
|
|
101
|
+
'3. CONNECTING A SERVICE, so that Foundation may obtain credentials there itself. What renews them stays with the',
|
|
102
|
+
' grant, never in a value you can read. Connecting creates one grant, listed with the others.',
|
|
91
103
|
...connectorLines(connectors),
|
|
92
104
|
' POST /v1/requests {"kind":"connect", "input":{"connector":"<id>"}, "purpose":"...", "valid_minutes":30} Give the owner the verification_uri.',
|
|
105
|
+
' A connector whose flow is "role" (aws.role) has the owner make a role for Foundation in their own console and',
|
|
106
|
+
' paste its name; nothing of theirs is kept but that name, and each delivery obtains an hour of credentials.',
|
|
93
107
|
' Poll GET /v1/requests/<id> every few seconds until done; result.connection_id identifies the connection.',
|
|
94
|
-
' GET /v1/connections
|
|
108
|
+
' GET /v1/connections the connected grants: ids, labels, service details and the variables each yields.',
|
|
109
|
+
' To use one, deliver it (above) or bind it in http.request (below). Disconnecting removes the grant; what was',
|
|
110
|
+
' already handed out stays where it went. Revoke at the provider if needed.', '',
|
|
95
111
|
'FUNCTIONS',
|
|
96
|
-
' GET /v1/functions catalog of built-in operations and their invocation endpoints; no arbitrary-code runtime.',
|
|
97
|
-
' POST /v1/functions/connection.credentials',
|
|
98
|
-
' {"connection_id": "<id>", "save": {"<output identifier>": "<chosen saved name>"}}',
|
|
99
|
-
' Checks or renews that connection and saves only the outputs you explicitly name. Returns saved metadata and',
|
|
100
|
-
' actual expiry, without credential values. Existing values at those exact names are replaced atomically.',
|
|
101
|
-
' Omit save to receive delivery directly. Prefer save when invoking from an AI tool; then use exec or http.request.',
|
|
102
|
-
' Saving creates independent copies: subsequent reads never refresh them; invoke this function again when needed.',
|
|
103
|
-
' Disconnecting stops future processing, not copies already saved or handed out. Revoke at the provider if needed.', '',
|
|
112
|
+
' GET /v1/functions catalog of built-in operations and their invocation endpoints; no arbitrary-code runtime.', '',
|
|
104
113
|
'WHEN IT DOES NOT WORK',
|
|
105
114
|
' GET /v1/requests/<id> one of your requests, and what happened at its page (events).',
|
|
106
115
|
' GET /v1/requests?status=pending your requests. Several may be open at once (up to 10).',
|
|
@@ -124,12 +133,14 @@ export function guide(connectors) {
|
|
|
124
133
|
'HTTPS REQUEST FUNCTION',
|
|
125
134
|
' POST /v1/functions/http.request {"url": "https://api.example.com/v1/items", "method": "POST",',
|
|
126
135
|
' "headers": {"authorization": "Bearer {{foundation:token}}"}, "bindings": {"token": "build token"}, "body": "..."}',
|
|
127
|
-
' Each placeholder identifies an input slot. bindings maps slots to
|
|
128
|
-
'
|
|
129
|
-
'
|
|
136
|
+
' Each placeholder identifies an input slot. bindings maps slots to grants: a given grant\'s exact name (including',
|
|
137
|
+
' braces or spaces), or a connected grant\'s id (<id>#<VARIABLE> when it yields several). Without bindings, a slot',
|
|
138
|
+
' is itself a name. Inputs only in headers/body, never in the URL. Up to 8 inputs. A bound connected grant is',
|
|
139
|
+
' derived first, so its service may be asked for a fresh credential.',
|
|
140
|
+
' body_encoding "base64" sends bytes without substitution.',
|
|
130
141
|
' Returns {response: {status, headers, body, body_encoding}}. Common echoes of input secrets are redacted;',
|
|
131
142
|
' do not treat redaction as protection against arbitrary transformations by an untrusted destination.',
|
|
132
|
-
' Optional save: "<name>"
|
|
143
|
+
' Optional save: "<name>" keeps the response body as a given grant and returns only status/headers and saved metadata.',
|
|
133
144
|
' Redirects are returned, not followed.',
|
|
134
145
|
' HTTPS on 443, public hostnames only, never this server or private networks. 1MB each way, 20 seconds, 30/minute.',
|
|
135
146
|
' No workflow, schedule or implicit execution: the caller chooses each invocation and each saved output.', '',
|
|
@@ -149,7 +160,7 @@ export function guide(connectors) {
|
|
|
149
160
|
' Use this instead of POST /v1/deliveries whenever the point is to run something.',
|
|
150
161
|
` foundation exec --output '{"name":"login config","as":"AUTH_FILE","filename":"auth.json"}' -- <command>`,
|
|
151
162
|
' Creates one empty private file and sets as to its path. Tell the command to write its authentication result there.',
|
|
152
|
-
' After exit 0,
|
|
163
|
+
' After exit 0, keeps the file bytes as a given grant under the exact name, then removes the temporary file.',
|
|
153
164
|
' An existing value at that name is replaced. Output must be a private regular file containing 1 byte to 1MB.',
|
|
154
165
|
' May be combined with inputs, using a different environment variable. A failed command never saves its output.',
|
|
155
166
|
' 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,
|
|
@@ -110,7 +107,7 @@ Commands:
|
|
|
110
107
|
Send one request to the Foundation API with the key attached.
|
|
111
108
|
exec <ENV>=<name> [...] -- <command> [args...]
|
|
112
109
|
Run a command with saved values in its environment.
|
|
113
|
-
exec --inputs '<json>' -- <command> The same, with files or
|
|
110
|
+
exec --inputs '<json>' -- <command> The same, with files, structured inputs, or a connected grant by id.
|
|
114
111
|
exec --output '<json>' -- <command> Also save a file the command writes.
|
|
115
112
|
guide The API guide: what Foundation keeps, and how to ask it for things.
|
|
116
113
|
version Print the version.
|
|
@@ -153,8 +150,10 @@ async function main() {
|
|
|
153
150
|
if (at < 1) throw new Error('Specify the environment variable explicitly: ENV=name');
|
|
154
151
|
return { name: value.slice(at + 1), as: value.slice(0, at) };
|
|
155
152
|
});
|
|
156
|
-
|
|
157
|
-
if (
|
|
153
|
+
// A connected grant names its own variables, so an input may leave `as` out; a given one must say where it goes.
|
|
154
|
+
if (!Array.isArray(names) || names.length > 16 || names.some(item => !item || typeof item.name !== 'string' || !item.name || (item.as !== undefined && !validEnvName(item.as)))) throw new Error('Each input needs a name and, when given, a non-reserved environment variable in as.');
|
|
155
|
+
const chosen = names.map(item => item.as).filter(value => value !== undefined);
|
|
156
|
+
if (new Set(chosen).size !== chosen.length) throw new Error('Each input needs a different environment variable.');
|
|
158
157
|
if (parsed.values.output !== undefined) {
|
|
159
158
|
try { output = JSON.parse(parsed.values.output); } catch { throw new Error('--output must be a JSON object {name, as, filename}.'); }
|
|
160
159
|
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}.');
|
|
@@ -185,7 +184,7 @@ async function main() {
|
|
|
185
184
|
}
|
|
186
185
|
const url = serverUrl(connectTo ?? configured);
|
|
187
186
|
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
|
-
|
|
187
|
+
let token = await readKey(keyPath, { missingOk: action === 'connect' });
|
|
189
188
|
async function send(target, payload, { accept, method = 'POST', type = 'application/json' } = {}) {
|
|
190
189
|
const response = await fetch(url.origin + target, { method, headers: { authorization: 'Bearer ' + token, ...(payload === undefined ? {} : { 'content-type': type }) },
|
|
191
190
|
body: payload === undefined ? undefined : type === 'application/json' ? JSON.stringify(payload) : payload, redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
@@ -195,6 +194,10 @@ async function main() {
|
|
|
195
194
|
}
|
|
196
195
|
// One request, with the key attached and the answer printed as it came. Nothing here knows the endpoints.
|
|
197
196
|
if (action === 'api') {
|
|
197
|
+
if (!/[?&]as=/.test(call.target)) {
|
|
198
|
+
const me = await send('/v1/principals/me', undefined, { method: 'GET', accept: () => true });
|
|
199
|
+
if (me.acts_for?.length === 1) call.target += (call.target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(me.acts_for[0].id);
|
|
200
|
+
}
|
|
198
201
|
const response = await fetch(url.origin + call.target, { method: call.method, headers: { authorization: 'Bearer ' + token, ...(call.body === undefined ? {} : { 'content-type': call.type }) },
|
|
199
202
|
...(call.body === undefined ? {} : { body: call.body }), redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
200
203
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
@@ -207,9 +210,18 @@ async function main() {
|
|
|
207
210
|
// A key the owner already approved has nothing to ask; connecting again only changes which server is remembered.
|
|
208
211
|
if (action === 'connect') {
|
|
209
212
|
// A key someone already accepted has nothing to ask; connecting again only changes which server is remembered.
|
|
213
|
+
// No key, or one this server does not know: become a principal there first, and keep what it issues.
|
|
214
|
+
const wanted = name ?? hostname() + ' の ' + (agentName || 'AI');
|
|
210
215
|
let me = null;
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
if (token) me = await send('/v1/principals/me', undefined, { method: 'GET', accept: data => data.error?.code === 'not_approved' });
|
|
217
|
+
if (!token || me?.error) {
|
|
218
|
+
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) });
|
|
219
|
+
const made = await response.json();
|
|
220
|
+
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') + ').');
|
|
221
|
+
await writeKey(keyPath, made.token, !process.env.FOUNDATION_RUNTIME_KEY_FILE);
|
|
222
|
+
token = made.token; me = null;
|
|
223
|
+
}
|
|
224
|
+
const answer = me?.acts_for?.length ? null : await send('/v1/requests', { kind: 'actor', input: { name: wanted } });
|
|
213
225
|
if (connectTo !== undefined) await saveUrl(url.origin);
|
|
214
226
|
console.log(answer === null ? 'Already approved on ' + url.origin + '.' : JSON.stringify(answer, null, 2));
|
|
215
227
|
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 +231,12 @@ async function main() {
|
|
|
219
231
|
// and the person it asked has yet to answer.
|
|
220
232
|
const current = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
221
233
|
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 : '') + '.');
|
|
234
|
+
// Whose holdings a run reaches: the one this key acts for, or the one named when it acts for several.
|
|
235
|
+
const holder = process.env.FOUNDATION_AS || (current.acts_for.length === 1 ? current.acts_for[0].id : null);
|
|
236
|
+
if (!holder) throw new Error('This key acts for several principals. Set FOUNDATION_AS=<principal id> to say which one this run is for.');
|
|
237
|
+
const forHolder = target => target + (target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(holder);
|
|
222
238
|
let delivery;
|
|
223
|
-
if (names.length) ({ delivery } = await send('/v1/deliveries', { names }));
|
|
239
|
+
if (names.length) ({ delivery } = await send(forHolder('/v1/deliveries'), { names }));
|
|
224
240
|
else delivery = { environment: {}, files: [] };
|
|
225
241
|
if (!delivery || typeof delivery.environment !== 'object' || !Array.isArray(delivery.files)) throw new Error('Foundation returned an invalid delivery.');
|
|
226
242
|
// What each of them sets is the server's to say; this applies it and refuses anything it may not set.
|
|
@@ -250,7 +266,7 @@ async function main() {
|
|
|
250
266
|
}
|
|
251
267
|
}
|
|
252
268
|
};
|
|
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/holdings?kind=
|
|
269
|
+
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=grant&name=<URL-encoded-name>" --from <file>, then remove that recovery file.';
|
|
254
270
|
process.once('exit', cleanup);
|
|
255
271
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, () => {
|
|
256
272
|
interrupted = true;
|
|
@@ -289,7 +305,7 @@ async function main() {
|
|
|
289
305
|
retainOutput = true;
|
|
290
306
|
// 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
307
|
let saved;
|
|
292
|
-
try { saved = await send('/v1/holdings?kind=
|
|
308
|
+
try { saved = await send(forHolder('/v1/holdings?kind=grant&name=' + encodeURIComponent(output.name)), bytes, { method: 'PUT', type: 'application/octet-stream' }); }
|
|
293
309
|
catch { throw new Error(recovery()); }
|
|
294
310
|
try { await send('/v1/relations', { relation: 'editor', object_type: 'holding', object_id: saved.holding.id }, { method: 'DELETE' }); } catch {}
|
|
295
311
|
retainOutput = false;
|