@tomato414941/foundation 0.8.0 → 0.10.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 +126 -106
- package/package.json +1 -1
- package/runtime.mjs +26 -22
package/guide.mjs
CHANGED
|
@@ -6,82 +6,95 @@
|
|
|
6
6
|
//
|
|
7
7
|
// This text is read by the agent, not by the owner, so it is English; everything the owner reads
|
|
8
8
|
// (purposes, steps, the dashboard) stays in the owner's language.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
|
9
|
+
// The services this server knows, each with the schemes it can be connected by.
|
|
10
|
+
function serviceLines(services) {
|
|
11
|
+
if (!services) return [' GET /v1/services lists the services this server knows.'];
|
|
12
|
+
return services.map(service => ' ' + service.id + ' ' + service.name + ' ' + Object.entries(service.auth_schemes)
|
|
13
|
+
.map(([id, scheme]) => id + (id === 'oauth' ? (scheme.foundation_app ? ' (Foundation\'s app)' : scheme.takes_apps ? ' (owner\'s app)' : '') : id === 'role' && !scheme.available ? ' (unavailable)' : '')).join(', '));
|
|
19
14
|
}
|
|
20
15
|
|
|
21
|
-
export function guide(
|
|
22
|
-
return ['Foundation holds, for each principal,
|
|
23
|
-
'A
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
16
|
+
export function guide(services) {
|
|
17
|
+
return ['Foundation holds, for each principal, credentials and objects, and runs explicitly requested built-in functions.',
|
|
18
|
+
'A credential lets something act at a service on its holder\'s behalf. It is either for a service Foundation knows,',
|
|
19
|
+
'by one of that service\'s schemes - oauth (the holder said yes at the service), token (the holder made a token there and',
|
|
20
|
+
'handed it over) or role (the holder made a role for Foundation to assume) - or a secret: bytes the holder handed over',
|
|
21
|
+
'for no service in particular. An object is bytes placed here. Reading never reaches a service. Injecting a credential',
|
|
22
|
+
'derives what it yields now: a secret its bytes, the others what their scheme obtains. A name is the holder\'s word for',
|
|
23
|
+
'a thing; it decides no service, function or variable.', '',
|
|
27
24
|
'Everything below is HTTP with your approved key: Authorization: Bearer fdn_...',
|
|
28
|
-
'Nothing here needs a shell. With one, use foundation api to attach the key and foundation exec to
|
|
29
|
-
'
|
|
30
|
-
'Never ask the owner to paste passwords, keys or tokens into chat. Use a
|
|
25
|
+
'Nothing here needs a shell. With one, use foundation api to attach the key and foundation exec to inject values',
|
|
26
|
+
'into a command. api prints responses: use function save options when output should not enter your context.',
|
|
27
|
+
'Never ask the owner to paste passwords, keys or tokens into chat. Use a request instead.', '',
|
|
31
28
|
'WHO YOU ARE',
|
|
32
|
-
' Everything that comes to Foundation is a principal: a person, an AI, an app. You are one, and your key is
|
|
33
|
-
' proves it. Foundation issues it, once, and shows it once: keep it private and never print it. If you can
|
|
34
|
-
' private file, keep it there; otherwise keep it wherever your secrets go.',
|
|
35
|
-
' POST /v1/principals {"name": "<what to call you>"} with no
|
|
29
|
+
' Everything that comes to Foundation is a principal: a person, an AI, an app. You are one, and your access key is',
|
|
30
|
+
' what proves it. Foundation issues it, once, and shows it once: keep it private and never print it. If you can',
|
|
31
|
+
' write a private file, keep it there; otherwise keep it wherever your secrets go.',
|
|
32
|
+
' POST /v1/principals {"name": "<what to call you>"} with no key at all. Makes you, and answers with token.',
|
|
36
33
|
' What you may reach follows from lines between principals: whoever you act for (actor), whoever made you (owner).',
|
|
37
34
|
' POST /v1/requests {"kind": "actor", "input": {"name": "<what to call you>"}} with your key as the bearer.',
|
|
38
35
|
' Asks whoever opens it to let you act for them. Returns verification_uri and confirmation_code. Give the person',
|
|
39
36
|
' both; they open the URL and type the code. Until they do you act for nobody. Retry GET /v1/principals/me every',
|
|
40
37
|
' few seconds and look at acts_for. Do not hammer it.',
|
|
41
|
-
' GET /v1/principals/me who you are: principal, acts_for (whom you act for), owners,
|
|
42
|
-
' Every call below reaches your own
|
|
43
|
-
' and the MCP tool add ?as= for you when you act for exactly one; over raw HTTP, add it yourself.',
|
|
38
|
+
' GET /v1/principals/me who you are: principal, acts_for (whom you act for), owners, keys, your open requests.',
|
|
39
|
+
' Every call below reaches your own resources unless you name whose: ?as=<id of one you act for>. The foundation',
|
|
40
|
+
' CLI and the MCP tool add ?as= for you when you act for exactly one; over raw HTTP, add it yourself.',
|
|
44
41
|
' PATCH /v1/principals/me {"name"} DELETE /v1/principals/me a new name; leaving (your key stops working).',
|
|
45
42
|
' GET /v1/requests/<id> one of your requests and what happened at its page (events). DELETE cancels it.',
|
|
46
|
-
' GET /v1/relations every line you are on. GET /v1/
|
|
47
|
-
'
|
|
48
|
-
' POST /v1/relations {"subject":"<principal id>","relation":"viewer"|"editor","object_type":"
|
|
49
|
-
' GET /v1/
|
|
50
|
-
' GET /v1/
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
43
|
+
' GET /v1/relations every line you are on. GET /v1/audit-log what was done in your name or to you.',
|
|
44
|
+
' Every resource has an id, shown in every listing. The holder shows one to another principal with',
|
|
45
|
+
' POST /v1/relations {"subject":"<principal id>","relation":"viewer"|"editor","object_type":"resource","object_id":"<id>"} DELETE takes it back.',
|
|
46
|
+
' GET /v1/resources?shown=me what others have shown you. GET /v1/resources/<id> one resource, whoever holds it.',
|
|
47
|
+
' GET /v1/resources/<id>/content reads it; PUT writes it, as editor. Lines point at the id, so renaming changes nothing.', '',
|
|
48
|
+
'CREDENTIALS',
|
|
49
|
+
' GET /v1/resources?kind=credential every credential: id, name, status, and for one made for a service, its',
|
|
50
|
+
' service, auth_scheme, facts and the variables it yields. &service=<id> narrows to one service; &secret=true to',
|
|
51
|
+
' secrets, &secret=false to those for services; &prefix= by name.',
|
|
52
|
+
' PUT /v1/resources?kind=credential&name=<name> body: raw bytes, up to 1MB. A secret: the same exact name replaces',
|
|
53
|
+
' that value; the answer carries its id.',
|
|
54
54
|
' What you keep for the holder you may read back (an editor line is drawn for you). What the holder kept, or a',
|
|
55
|
-
' service authorized, you may
|
|
56
|
-
' Do not print
|
|
57
|
-
' GET /v1/
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
' 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',
|
|
64
|
-
' remain literal. No normalization, hierarchy, service ownership or automatic renewal is inferred.',
|
|
65
|
-
' The owner can read, rename or delete any saved value. Stored copies survive OAuth disconnection.', '',
|
|
55
|
+
' service authorized, you may inject into a command but not read, unless the holder draws you a line.',
|
|
56
|
+
' Do not print injected values into your context.',
|
|
57
|
+
' GET /v1/resources?kind=credential&name=<name> one secret by name: its id and metadata. 404 if no such name.',
|
|
58
|
+
' GET /v1/resources/<id>/content a secret\'s bytes, as written; 403 forbidden without a line to it. PUT writes them.',
|
|
59
|
+
' A credential for a service has no content to read (405): what it yields is derived when injected.',
|
|
60
|
+
' PATCH /v1/resources/<id> {"name"} a new name. DELETE /v1/resources/<id> removes it (one for a service: see below).',
|
|
61
|
+
' 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',
|
|
62
|
+
' characters without control characters; case, spaces, slashes and punctuation remain literal.', '',
|
|
66
63
|
'HANDING IT TO A COMMAND',
|
|
67
|
-
' POST /v1/
|
|
68
|
-
' Each item is {name, as, filename?}. name is a
|
|
69
|
-
'
|
|
70
|
-
'
|
|
71
|
-
'
|
|
64
|
+
' POST /v1/injections {"names": [{"name": "build token", "as": "GH_TOKEN"}, {"name": "<id of a credential for a service>"}]}',
|
|
65
|
+
' Each item is {name, as, filename?}. name is a secret\'s name or any credential\'s id. For a secret, as is required:',
|
|
66
|
+
' it names the environment variable. A credential for a service yields its scheme\'s variables (see variables); as',
|
|
67
|
+
' may rename the one variable of a scheme that has exactly one. Injecting one may ask the service for a fresh',
|
|
68
|
+
' token; expires_at says how long what came back is good.',
|
|
72
69
|
' filename makes the bytes a temporary file instead; as holds its local path. Use this for binary or multiline data.',
|
|
73
70
|
' Up to 16 inputs. Variables and filenames must be distinct; reserved system variables are refused.',
|
|
74
|
-
' Returns
|
|
75
|
-
' is to run a local command.', '',
|
|
76
|
-
'
|
|
71
|
+
' Returns injection: {environment, files}. Calling this into an agent context exposes values. Use exec when the',
|
|
72
|
+
' intent is to run a local command.', '',
|
|
73
|
+
'SERVICES',
|
|
74
|
+
' GET /v1/services every service this server knows: its api, docs, console (where an app or a token for it is',
|
|
75
|
+
' made) and auth_schemes. For each scheme: variables (what a credential yields), hint (how to use it), and',
|
|
76
|
+
' - oauth: scopes (base and documentation_url), foundation_app (Foundation has its own app for it), takes_apps (the',
|
|
77
|
+
' owner may bring their own app) and app_fields (what registering one asks for);',
|
|
78
|
+
' - token: fields (what the owner types; secret ones are sealed);',
|
|
79
|
+
' - role: available.',
|
|
80
|
+
...serviceLines(services),
|
|
81
|
+
' A service the list does not know can be described by the owner, or by you acting for them, as a resource:',
|
|
82
|
+
' PUT /v1/resources?kind=service&name=<name> body: its definition, the same JSON shape as a listed service',
|
|
83
|
+
' {"version":1, "name", "api"?, "docs"?, "console"?, "auth_schemes": {"oauth"?: {...}, "token"?: {...}}}',
|
|
84
|
+
' oauth: authorize, token (https URLs; {field} is filled from the app), injection {"VARIABLE":"{access_token}"...},',
|
|
85
|
+
' and as the service needs them: scopes {base, docs}, scope_separator, pkce, client_auth (basic / body),',
|
|
86
|
+
' token_format (form / json), ok_field, identity {url, id, label}, revoke {url, style: rfc7009 / bearer / delete},',
|
|
87
|
+
' app_fields, authorize_params, keep. Placeholders: access_token, account, expires_at, kept fields, app fields.',
|
|
88
|
+
' token: fields [{name, label, secret?, pattern?}], injection {"VARIABLE":"{field}"}, identity {url, headers, id, label}.',
|
|
89
|
+
' A refusal (invalid_definition) says where. It is then connected like any other, by its resource id.', '',
|
|
90
|
+
'FILLING IN CREDENTIALS',
|
|
77
91
|
'',
|
|
78
|
-
'1. Put
|
|
92
|
+
'1. Put a secret there yourself. Anything you obtained or wrote: PUT /v1/resources?kind=credential&name=<name>, above.',
|
|
79
93
|
'',
|
|
80
|
-
'2. ASKING THE OWNER, for what only they can fetch
|
|
94
|
+
'2. ASKING THE OWNER FOR A SECRET, for what only they can fetch and no listed service describes.',
|
|
81
95
|
' POST /v1/requests {"kind":"store", "input":{"fields":[{...}]}, "purpose":"...", "steps":["..."], "valid_minutes":30}',
|
|
82
96
|
' fields[].name suggested name; result.names returns the names the owner chose, result.replaced those replaced',
|
|
83
97
|
' fields[].label what they are being asked for, in their language. It titles the screen and names the field.',
|
|
84
|
-
|
|
85
98
|
' fields[].site the page where they make it, offered as a link',
|
|
86
99
|
' fields[].readable true asks to read it back afterwards (a viewer line); the default is that you cannot',
|
|
87
100
|
' fields[].multiline true for something like a PEM',
|
|
@@ -91,45 +104,52 @@ export function guide(connectors) {
|
|
|
91
104
|
' case nothing is replaced; result.replaced lists the names that were.',
|
|
92
105
|
' purpose one concrete sentence the owner can judge, in their language',
|
|
93
106
|
' steps what they do, one string per step (up to 20, 500 characters each, no line breaks). Foundation',
|
|
94
|
-
' holds no instructions for anyone else\'s',
|
|
95
|
-
' site: look up what to click now, and write it yourself.',
|
|
107
|
+
' holds no instructions for anyone else\'s site: look up what to click now, and write it yourself.',
|
|
96
108
|
' Give the owner the verification_uri (there is no code). When they finish, it is simply kept.',
|
|
97
109
|
' Ask for what the owner already holds. Never take it through the conversation and put it there yourself.',
|
|
98
110
|
'',
|
|
99
|
-
'3. CONNECTING A SERVICE,
|
|
100
|
-
'
|
|
101
|
-
...
|
|
102
|
-
'
|
|
103
|
-
'
|
|
104
|
-
'
|
|
105
|
-
'
|
|
106
|
-
'
|
|
107
|
-
'
|
|
108
|
-
'
|
|
111
|
+
'3. CONNECTING A SERVICE, for a credential Foundation keeps (and, for OAuth, renews) itself.',
|
|
112
|
+
' POST /v1/requests {"kind":"connect", "input":{"service":"<id>", "auth_scheme":"oauth"|"token"|"role",',
|
|
113
|
+
' "scopes":["<the service\'s scope>", ...]}, "purpose":"...", "steps":["..."], "valid_minutes":30}',
|
|
114
|
+
' Give the owner the verification_uri. auth_scheme defaults to the service\'s first.',
|
|
115
|
+
' Choose the scheme that fits the owner: OAuth through Foundation\'s app is the easiest when foundation_app is true.',
|
|
116
|
+
' Otherwise a token the owner makes at the service (console) is often simplest for one person\'s own use; write the',
|
|
117
|
+
' steps for making it. OAuth through the owner\'s own app suits an organization or scopes Foundation\'s app lacks.',
|
|
118
|
+
' scopes (OAuth) are the service\'s own names for what the credential may do (scopes.documentation_url). Ask for',
|
|
119
|
+
' what the work needs; the owner sees each one before agreeing. Foundation adds only the few it needs to know who',
|
|
120
|
+
' authorized (scopes.base). facts.missing_scopes lists any the service did not grant.',
|
|
121
|
+
' OAuth goes through an app: Foundation\'s own, or one the owner holds (or was lent).',
|
|
122
|
+
' GET /v1/resources?kind=app the apps the owner may connect through, Foundation\'s included (foundation: true).',
|
|
109
123
|
' Add "app":"<app id>" inside input to connect through one; without it, Foundation\'s is used, or on reconnecting,',
|
|
110
|
-
' the app the
|
|
111
|
-
'
|
|
112
|
-
' POST /v1/requests {"kind":"app", "input":{"
|
|
113
|
-
' to register one: they make it at the service (redirect URL <this server>/oauth
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
122
|
-
' Without connection_id, authorization creates a separate connection, even for the same service user. List and use',
|
|
123
|
-
' an existing connection when no new authorization is needed. Provider consent and revocation may affect several connections.',
|
|
124
|
-
' A connector whose flow is "role" (aws.role) has the owner make a role for Foundation in their own console and',
|
|
125
|
-
' paste its name; nothing of theirs is kept but that name, and each delivery obtains an hour of credentials.',
|
|
126
|
-
' Poll GET /v1/requests/<id> every few seconds until done; result.connection_id identifies the connection.',
|
|
127
|
-
' GET /v1/holdings?kind=grant lists connected grants with the others: for each, method (authorized / delegated),',
|
|
128
|
-
' service details, facts and the variables it yields. To use one, deliver it (above) or bind it in http.request (below).',
|
|
129
|
-
' DELETE /v1/holdings/<id> {"revoke": true|false} disconnects one (the owner, in a browser); revoke also asks the',
|
|
124
|
+
' the app the credential was made through. A request nobody could complete is refused (app_required): ask for an',
|
|
125
|
+
' app first when the service has no Foundation app.',
|
|
126
|
+
' POST /v1/requests {"kind":"app", "input":{"service":"<id>"}, "purpose":"...", "steps":["..."]} asks the owner',
|
|
127
|
+
' to register one: they make it at the service (console; redirect URL <this server>/oauth/callback) and type its',
|
|
128
|
+
' values on the page. result.app_id names it. You never see or handle its secret.',
|
|
129
|
+
' To reconnect, add "credential_id":"<existing id>" inside input. This updates that credential and keeps its id,',
|
|
130
|
+
' its scopes (add more with scopes) and the app it was made through. Without it, a new credential is made, even',
|
|
131
|
+
' for the same account. List and use an existing credential when no new authorization is needed.',
|
|
132
|
+
' A role (aws) has the owner make a role for Foundation in their own console and paste its name; nothing of theirs',
|
|
133
|
+
' is kept but that name, and each injection obtains an hour of credentials.',
|
|
134
|
+
' Poll GET /v1/requests/<id> every few seconds until done; result.credential_id identifies the credential.',
|
|
135
|
+
' DELETE /v1/resources/<id> {"revoke": true|false} disconnects one (the owner, in a browser); revoke also asks the',
|
|
130
136
|
' service to withdraw what it granted. What was already handed out stays where it went.', '',
|
|
137
|
+
'ENVIRONMENTS (a machine with a shell, for when you have none)',
|
|
138
|
+
' A lent machine: a shell, files and the network, thrown away when done. By itself it reaches nothing of Foundation.',
|
|
139
|
+
' Give it an identity - a principal you may act as (yourself, one you own, or one you act for) - and it holds that',
|
|
140
|
+
' principal\'s key for its life: inside, the foundation CLI works as that principal (foundation exec, foundation api).',
|
|
141
|
+
' POST /v1/environments {"identity":"<principal id>"|null, "size":"small|medium|large",',
|
|
142
|
+
' "lifetime":{"end":"idle|exit","idle_seconds":600,"max_seconds":3600}} opens one; it is a resource (kind environment).',
|
|
143
|
+
' POST /v1/environments/<id>/commands {"command":["npm","test"],"stdin":null,"timeout_seconds":300}',
|
|
144
|
+
' runs one command: exit_code, stdout, stderr. 202 with status running if it takes longer; then',
|
|
145
|
+
' GET /v1/environments/<id>/commands/<command id>. One command at a time; files persist between commands.',
|
|
146
|
+
' PATCH /v1/environments/<id> {"identity":… or null} gives or takes away its identity. DELETE closes it.',
|
|
147
|
+
' POST /v1/runs {…as opening, plus "command"} opens, runs one command, stops: the answer carries the result.',
|
|
148
|
+
' What a command prints is cleaned of values handed in with injections. Keep what matters as an object.',
|
|
149
|
+
' Computing is spent: GET /v1/principals/<id>/compute shows this month\'s use and limit; its owner may lower the',
|
|
150
|
+
' limit with PUT {"monthly_seconds": n}. A medium machine spends twice its time, a large one four times.', '',
|
|
131
151
|
'FUNCTIONS',
|
|
132
|
-
' GET /v1/functions catalog of built-in operations and their invocation endpoints
|
|
152
|
+
' GET /v1/functions catalog of built-in operations and their invocation endpoints.', '',
|
|
133
153
|
'WHEN IT DOES NOT WORK',
|
|
134
154
|
' GET /v1/requests/<id> one of your requests, and what happened at its page (events).',
|
|
135
155
|
' GET /v1/requests?status=pending your requests. Several may be open at once (up to 10).',
|
|
@@ -137,30 +157,30 @@ export function guide(connectors) {
|
|
|
137
157
|
' Foundation\'s own message) / connect_review (awaiting confirmation of changes) / connected / stored / denied / cancelled. What was typed is never recorded.',
|
|
138
158
|
' DELETE /v1/requests/<id> cancels it. status is pending / done / denied / cancelled.',
|
|
139
159
|
' Completion and result are fixed until the request expires, even if the resulting resource changes or is removed.',
|
|
140
|
-
' Current
|
|
141
|
-
' Request feedback expires with the request. A done result is a
|
|
142
|
-
' Why a
|
|
143
|
-
' reconnect_required (the service rejected it) /
|
|
160
|
+
' Current credential state is at GET /v1/resources?kind=credential. Revoked keys receive 401; their pending requests are cancelled.',
|
|
161
|
+
' Request feedback expires with the request. A done result is a credential_id or the names saved at completion.',
|
|
162
|
+
' Why a connection failed: invalid_fields (wrong shape) / token_refused (the service would not take the token) /',
|
|
163
|
+
' reconnect_required (the service rejected it) / credential_changed (the target changed). A key approval shows its own events at',
|
|
144
164
|
' GET /v1/requests/<id>: confirmation_required (a wrong code) / confirmation_locked (5 tries).', '',
|
|
145
165
|
'A PLACE FOR FILES (object storage the owner did not have to sign up for)',
|
|
146
|
-
' PUT /v1/
|
|
147
|
-
' GET /v1/
|
|
148
|
-
' GET /v1/
|
|
149
|
-
' POST /v1/
|
|
166
|
+
' PUT /v1/resources?kind=object&name=<key> body is the bytes; the Content-Type you send is what a reader gets back. Up to 25MB.',
|
|
167
|
+
' GET /v1/resources?kind=object&prefix=<p> lists what is there, with ids. &name=<key> finds one.',
|
|
168
|
+
' GET /v1/resources/<id>/content the bytes. PUT writes them. DELETE /v1/resources/<id> removes it. PATCH renames it.',
|
|
169
|
+
' POST /v1/resources/<id>/link {"minutes":n} a time-limited URL anyone can read, for a thing that only takes a URL.',
|
|
150
170
|
' Keys look like a path (a/b/c.txt) but name one whole object: no renaming, no directories, no partial reads or writes.',
|
|
151
171
|
' This is not a filesystem. If the owner needs one, they need a machine to mount it on.',
|
|
152
172
|
' 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.', '',
|
|
153
173
|
'HTTPS REQUEST FUNCTION',
|
|
154
174
|
' POST /v1/functions/http.request {"url": "https://api.example.com/v1/items", "method": "POST",',
|
|
155
175
|
' "headers": {"authorization": "Bearer {{foundation:token}}"}, "bindings": {"token": "build token"}, "body": "..."}',
|
|
156
|
-
' Each placeholder identifies an input slot. bindings maps slots to
|
|
157
|
-
' braces or spaces), or a
|
|
158
|
-
' is itself a name. Inputs only in headers/body, never in the URL. Up to 8 inputs. A bound
|
|
159
|
-
' derived first, so its service may be asked for a fresh
|
|
176
|
+
' Each placeholder identifies an input slot. bindings maps slots to credentials: a secret\'s exact name (including',
|
|
177
|
+
' braces or spaces), or a credential\'s id (<id>#<VARIABLE> when it yields several). Without bindings, a slot',
|
|
178
|
+
' is itself a name. Inputs only in headers/body, never in the URL. Up to 8 inputs. A bound credential for a service is',
|
|
179
|
+
' derived first, so its service may be asked for a fresh token.',
|
|
160
180
|
' body_encoding "base64" sends bytes without substitution.',
|
|
161
181
|
' Returns {response: {status, headers, body, body_encoding}}. Common echoes of input secrets are redacted;',
|
|
162
182
|
' do not treat redaction as protection against arbitrary transformations by an untrusted destination.',
|
|
163
|
-
' Optional save: "<name>" keeps the response body as a
|
|
183
|
+
' Optional save: "<name>" keeps the response body as a secret and returns only status/headers and saved metadata.',
|
|
164
184
|
' Redirects are returned, not followed.',
|
|
165
185
|
' HTTPS on 443, public hostnames only, never this server or private networks. 1MB each way, 20 seconds, 30/minute.',
|
|
166
186
|
' No workflow, schedule or implicit execution: the caller chooses each invocation and each saved output.', '',
|
|
@@ -175,12 +195,12 @@ export function guide(connectors) {
|
|
|
175
195
|
' foundation exec <ENV>=<name> [...] -- <command> [args...]',
|
|
176
196
|
` foundation exec --inputs '[{"name":"signing key","as":"KEY_FILE","filename":"AuthKey.p8"}]' -- <command>`,
|
|
177
197
|
' ENV=name treats everything after the first = literally. Use --inputs JSON for files or structured inputs.',
|
|
178
|
-
' Only the child process gets
|
|
198
|
+
' Only the child process gets injected values; files exist only while it runs. FOUNDATION_NAMES is a JSON array',
|
|
179
199
|
' of the exact saved names used. Prevent the child command from logging or echoing secrets.',
|
|
180
|
-
' Use this instead of POST /v1/
|
|
200
|
+
' Use this instead of POST /v1/injections whenever the point is to run something.',
|
|
181
201
|
` foundation exec --output '{"name":"login config","as":"AUTH_FILE","filename":"auth.json"}' -- <command>`,
|
|
182
202
|
' Creates one empty private file and sets as to its path. Tell the command to write its authentication result there.',
|
|
183
|
-
' After exit 0, keeps the file bytes as a
|
|
203
|
+
' After exit 0, keeps the file bytes as a secret under the exact name, then removes the temporary file.',
|
|
184
204
|
' An existing value at that name is replaced. Output must be a private regular file containing 1 byte to 1MB.',
|
|
185
205
|
' May be combined with inputs, using a different environment variable. A failed command never saves its output.',
|
|
186
206
|
' If upload cannot be confirmed, exits with an error and reports the retained private file for recovery; inputs are',
|
|
@@ -190,7 +210,7 @@ export function guide(connectors) {
|
|
|
190
210
|
' One key per machine and OS user by default. FOUNDATION_AGENT gives each agent its own key, but any agent running as the',
|
|
191
211
|
' same OS user can read that file, so it is bookkeeping, not protection. To separate them for real, use separate OS users.', '',
|
|
192
212
|
'RULES',
|
|
193
|
-
'- Never print, log or write out what is
|
|
213
|
+
'- Never print, log or write out what is injected. Hand it to a command, and nowhere else.',
|
|
194
214
|
'- Send saved values only to an authorized destination. http.request and exec use the destinations you choose.',
|
|
195
215
|
'- Ask for the least you need. Do not request something the work does not use.',
|
|
196
216
|
'- Be honest in name, purpose and label. The owner decides based on them.',
|
package/package.json
CHANGED
package/runtime.mjs
CHANGED
|
@@ -107,7 +107,7 @@ Commands:
|
|
|
107
107
|
Send one request to the Foundation API with the key attached.
|
|
108
108
|
exec <ENV>=<name> [...] -- <command> [args...]
|
|
109
109
|
Run a command with saved values in its environment.
|
|
110
|
-
exec --inputs '<json>' -- <command> The same, with files, structured inputs, or a
|
|
110
|
+
exec --inputs '<json>' -- <command> The same, with files, structured inputs, or a credential for a service by id.
|
|
111
111
|
exec --output '<json>' -- <command> Also save a file the command writes.
|
|
112
112
|
guide Read the server's API guide (bundled reference when offline).
|
|
113
113
|
version Print the version.
|
|
@@ -141,7 +141,7 @@ async function main() {
|
|
|
141
141
|
return;
|
|
142
142
|
}
|
|
143
143
|
const separatorAt = args.indexOf('--'), command = separatorAt >= 0 ? args.slice(separatorAt + 1) : [];
|
|
144
|
-
// Names remain literal. Inputs
|
|
144
|
+
// Names remain literal. Inputs inject bytes; an optional output saves one generated file.
|
|
145
145
|
let names = [], output;
|
|
146
146
|
if (action === 'exec' && separatorAt > 0) {
|
|
147
147
|
const parsed = parseArgs({ args: args.slice(0, separatorAt), options: { inputs: { type: 'string' }, output: { type: 'string' } }, strict: true, allowPositionals: true });
|
|
@@ -153,7 +153,7 @@ async function main() {
|
|
|
153
153
|
if (at < 1) throw new Error('Specify the environment variable explicitly: ENV=name');
|
|
154
154
|
return { name: value.slice(at + 1), as: value.slice(0, at) };
|
|
155
155
|
});
|
|
156
|
-
// A
|
|
156
|
+
// A credential for a service names its own variables, so an input may leave `as` out; a secret must say where it goes.
|
|
157
157
|
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.');
|
|
158
158
|
const chosen = names.map(item => item.as).filter(value => value !== undefined);
|
|
159
159
|
if (new Set(chosen).size !== chosen.length) throw new Error('Each input needs a different environment variable.');
|
|
@@ -230,18 +230,22 @@ async function main() {
|
|
|
230
230
|
console.log('\nKey file: ' + keyPath + '\nServer: ' + url.origin + (connectTo !== undefined ? ' (saved to ' + configPath() + ')' : '') + '\nEverything else is HTTP: Authorization: Bearer <the contents of that file>');
|
|
231
231
|
return;
|
|
232
232
|
}
|
|
233
|
-
// Nothing runs before someone has accepted this key: a key that acts for nobody reaches only its own empty
|
|
233
|
+
// Nothing runs before someone has accepted this key: a key that acts for nobody reaches only its own empty resources,
|
|
234
234
|
// and the person it asked has yet to answer.
|
|
235
235
|
const current = await send('/v1/principals/me', undefined, { method: 'GET' });
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
const
|
|
239
|
-
if (!
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
236
|
+
// A key given to a lent machine acts as its principal's own self. Any other key acts for someone once approved;
|
|
237
|
+
// until then, whether waiting or refused, it has nothing to run with.
|
|
238
|
+
const own = Boolean(current.key?.environment);
|
|
239
|
+
if (!own && !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 : '') + '.');
|
|
240
|
+
// Whose resources a run reaches: the one this key acts for, the one named when it acts for several, or its own.
|
|
241
|
+
const acting = current.acts_for ?? [];
|
|
242
|
+
const holder = process.env.FOUNDATION_AS || (acting.length === 1 ? acting[0].id : null);
|
|
243
|
+
if (!holder && acting.length > 1) throw new Error('This key acts for several principals. Set FOUNDATION_AS=<principal id> to say which one this run is for.');
|
|
244
|
+
const forHolder = target => holder ? target + (target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(holder) : target;
|
|
245
|
+
let injection;
|
|
246
|
+
if (names.length) ({ injection } = await send(forHolder('/v1/injections'), { names }));
|
|
247
|
+
else injection = { environment: {}, files: [] };
|
|
248
|
+
if (!injection || typeof injection.environment !== 'object' || !Array.isArray(injection.files)) throw new Error('Foundation returned an invalid injection.');
|
|
245
249
|
// What each of them sets is the server's to say; this applies it and refuses anything it may not set.
|
|
246
250
|
const environment = { ...process.env };
|
|
247
251
|
delete environment.FOUNDATION_RUNTIME_KEY_FILE;
|
|
@@ -250,15 +254,15 @@ async function main() {
|
|
|
250
254
|
if (typeof value !== 'string' || /[\x00\r\n]/.test(value) || value.length > 16384) throw new Error('Foundation returned an invalid value for ' + name + '.');
|
|
251
255
|
environment[name] = value;
|
|
252
256
|
};
|
|
253
|
-
for (const [name, value] of Object.entries(
|
|
254
|
-
const fileNames = new Set(), variables = new Set(Object.keys(
|
|
255
|
-
for (const file of
|
|
257
|
+
for (const [name, value] of Object.entries(injection.environment)) assign(name, value);
|
|
258
|
+
const fileNames = new Set(), variables = new Set(Object.keys(injection.environment));
|
|
259
|
+
for (const file of injection.files) {
|
|
256
260
|
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.');
|
|
257
261
|
fileNames.add(file.filename); variables.add(file.env);
|
|
258
262
|
}
|
|
259
263
|
if (output && variables.has(output.as)) throw new Error('Output needs a different environment variable from every input.');
|
|
260
264
|
environment.FOUNDATION_NAMES = JSON.stringify(names.map(item => item.name));
|
|
261
|
-
//
|
|
265
|
+
// Injected inputs are always cleaned up. A completed output survives only an unconfirmed upload.
|
|
262
266
|
let secretDir, outputDir, outputPath, child, interrupted = false, retainOutput = false;
|
|
263
267
|
const cleanup = () => {
|
|
264
268
|
if (secretDir) rmSync(secretDir, { recursive: true, force: true });
|
|
@@ -269,7 +273,7 @@ async function main() {
|
|
|
269
273
|
}
|
|
270
274
|
}
|
|
271
275
|
};
|
|
272
|
-
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/
|
|
276
|
+
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/resources?kind=credential&name=<URL-encoded-name>" --from <file>, then remove that recovery file.';
|
|
273
277
|
process.once('exit', cleanup);
|
|
274
278
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, () => {
|
|
275
279
|
interrupted = true;
|
|
@@ -287,9 +291,9 @@ async function main() {
|
|
|
287
291
|
await chmod(directory, 0o700);
|
|
288
292
|
return directory;
|
|
289
293
|
};
|
|
290
|
-
if (
|
|
294
|
+
if (injection.files.length) {
|
|
291
295
|
secretDir = await temporaryDirectory();
|
|
292
|
-
for (const file of
|
|
296
|
+
for (const file of injection.files) {
|
|
293
297
|
const target = join(secretDir, file.filename);
|
|
294
298
|
await writeFile(target, file.encoding === 'base64' ? Buffer.from(file.content, 'base64') : file.content, { mode: 0o600, flag: 'wx' });
|
|
295
299
|
assign(file.env, target);
|
|
@@ -308,9 +312,9 @@ async function main() {
|
|
|
308
312
|
retainOutput = true;
|
|
309
313
|
// 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.
|
|
310
314
|
let saved;
|
|
311
|
-
try { saved = await send(forHolder('/v1/
|
|
315
|
+
try { saved = await send(forHolder('/v1/resources?kind=credential&name=' + encodeURIComponent(output.name)), bytes, { method: 'PUT', type: 'application/octet-stream' }); }
|
|
312
316
|
catch { throw new Error(recovery()); }
|
|
313
|
-
try { await send('/v1/relations', { relation: 'editor', object_type: '
|
|
317
|
+
try { await send('/v1/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
|
|
314
318
|
retainOutput = false;
|
|
315
319
|
console.error('Saved output as ' + JSON.stringify(output.name) + '.');
|
|
316
320
|
}
|