@factiii/runner 0.11.1 → 0.11.2
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/dist/cli.js +558 -272
- package/package.json +1 -1
- package/plugin/factiii/bin/factiii-secrets +110 -19
- package/plugin/factiii/skills/factiii-secrets/SKILL.md +59 -17
package/package.json
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* factiii-secrets - run a command with this space's stored secrets injected.
|
|
4
4
|
*
|
|
5
|
-
* A PROXY, never a carrier. This process asks the runner to run the command
|
|
6
|
-
* the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* A PROXY, never a carrier. This process asks the runner to run the command
|
|
6
|
+
* and names the keys that command needs; the runner is what holds the
|
|
7
|
+
* decrypted values, execs the command with just those in its env, and sends
|
|
8
|
+
* back output already redacted. Nothing secret ever enters this process, so
|
|
9
|
+
* nothing secret is visible to whatever spawned it - which is the whole point,
|
|
10
|
+
* because what spawns it is an AI agent.
|
|
10
11
|
*
|
|
11
12
|
* Finding the runner doubles as the gate: this plugin installs globally, so
|
|
12
13
|
* outside a factiii space there is no socket and the command says so rather
|
|
@@ -29,13 +30,74 @@ function usage() {
|
|
|
29
30
|
process.stderr.write(
|
|
30
31
|
[
|
|
31
32
|
'usage:',
|
|
32
|
-
' factiii-secrets run --
|
|
33
|
-
'
|
|
33
|
+
' factiii-secrets run --key NAME [--key NAME]... -- <command...>',
|
|
34
|
+
' run it with the values for exactly those keys in its environment',
|
|
35
|
+
' factiii-secrets list',
|
|
36
|
+
' print the key NAMES the store holds, and their status (never values)',
|
|
37
|
+
'',
|
|
38
|
+
'Only the keys you name are injected. --key takes one name, repeats, and',
|
|
39
|
+
'accepts a comma-separated list. Naming none runs the command with no',
|
|
40
|
+
'secrets at all.',
|
|
34
41
|
''
|
|
35
42
|
].join('\n')
|
|
36
43
|
);
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
/** What a shell variable may be called, and so what the runner will accept. */
|
|
47
|
+
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Split `--key` out of the caller's argv.
|
|
51
|
+
*
|
|
52
|
+
* Only the flags BEFORE the command are ours: everything from the first
|
|
53
|
+
* non-flag word (or from `--`) belongs to the command, which may well have a
|
|
54
|
+
* `--key` of its own.
|
|
55
|
+
*/
|
|
56
|
+
function parseKeys(argv) {
|
|
57
|
+
const keys = [];
|
|
58
|
+
let i = 0;
|
|
59
|
+
for (; i < argv.length; i += 1) {
|
|
60
|
+
const arg = argv[i];
|
|
61
|
+
if (arg === '--') {
|
|
62
|
+
i += 1;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
let raw;
|
|
66
|
+
if (arg === '--key') {
|
|
67
|
+
i += 1;
|
|
68
|
+
if (i >= argv.length) return { error: '--key needs a name.' };
|
|
69
|
+
raw = argv[i];
|
|
70
|
+
} else if (arg.startsWith('--key=')) {
|
|
71
|
+
raw = arg.slice('--key='.length);
|
|
72
|
+
} else {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
for (const name of raw.split(',')) {
|
|
76
|
+
const key = name.trim();
|
|
77
|
+
if (!key) continue;
|
|
78
|
+
if (!KEY_PATTERN.test(key)) {
|
|
79
|
+
return { error: `"${key}" is not a valid key name.` };
|
|
80
|
+
}
|
|
81
|
+
if (!keys.includes(key)) keys.push(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { keys, rest: argv.slice(i) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `NAME status` rows, aligned, so a long store stays readable. */
|
|
88
|
+
function formatKeys(entries) {
|
|
89
|
+
const width = entries.reduce((max, e) => Math.max(max, e.key.length), 0);
|
|
90
|
+
return entries
|
|
91
|
+
.map((entry) => {
|
|
92
|
+
const status = [
|
|
93
|
+
entry.stored ? 'set' : 'not set',
|
|
94
|
+
entry.declared ? 'declared' : 'undeclared'
|
|
95
|
+
].join(', ');
|
|
96
|
+
return `${entry.key.padEnd(width)} ${status}`;
|
|
97
|
+
})
|
|
98
|
+
.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
39
101
|
/** `<spaceDir>/state/secrets.sock`. The env var is what the runner sets on
|
|
40
102
|
* every spawn; the walk-up covers a caller that changed the working
|
|
41
103
|
* directory to somewhere the runner did not choose. */
|
|
@@ -108,18 +170,18 @@ function request(socketPath, payload, onMessage) {
|
|
|
108
170
|
}
|
|
109
171
|
});
|
|
110
172
|
conn.on('error', (err) => {
|
|
111
|
-
// The path can be known and still have nothing listening: the
|
|
112
|
-
//
|
|
113
|
-
//
|
|
173
|
+
// The path can be known and still have nothing listening: the space is
|
|
174
|
+
// down, or still provisioning. The store is served for as long as the
|
|
175
|
+
// space is up now, so this is a state to wait out, not a place to be.
|
|
114
176
|
const why =
|
|
115
177
|
err.code === 'ENOENT' || err.code === 'ECONNREFUSED'
|
|
116
|
-
? 'the secrets store is not being served right now.
|
|
178
|
+
? 'the secrets store is not being served right now. The space serves it whenever it is up, so this usually means it is still starting'
|
|
117
179
|
: `cannot reach the runner (${err.message})`;
|
|
118
180
|
process.stderr.write(`factiii-secrets: ${why}.\n`);
|
|
119
181
|
finish(EXIT_UNAVAILABLE);
|
|
120
182
|
});
|
|
121
|
-
// Closed with no verdict: the runner died, or the
|
|
122
|
-
//
|
|
183
|
+
// Closed with no verdict: the runner died, or the work was cancelled
|
|
184
|
+
// mid-command. Either way nothing was reported as finished.
|
|
123
185
|
conn.on('close', () => finish(EXIT_UNAVAILABLE));
|
|
124
186
|
});
|
|
125
187
|
}
|
|
@@ -140,7 +202,7 @@ async function main() {
|
|
|
140
202
|
const socketPath = findSocket();
|
|
141
203
|
if (!socketPath) {
|
|
142
204
|
process.stderr.write(
|
|
143
|
-
'factiii-secrets: no secrets store is reachable here. This command works inside a
|
|
205
|
+
'factiii-secrets: no secrets store is reachable here. This command works inside a session a factiii runner started.\n'
|
|
144
206
|
);
|
|
145
207
|
return EXIT_UNAVAILABLE;
|
|
146
208
|
}
|
|
@@ -148,7 +210,13 @@ async function main() {
|
|
|
148
210
|
if (op === 'list') {
|
|
149
211
|
return request(socketPath, { op: 'list' }, (message) => {
|
|
150
212
|
if (message.type === 'keys') {
|
|
151
|
-
|
|
213
|
+
// `entries` carries the status. An older runner sends names only, and
|
|
214
|
+
// there is no status to infer from them - printing a bare list says
|
|
215
|
+
// less than inventing a "set, declared" nobody vouched for.
|
|
216
|
+
const rows = Array.isArray(message.entries)
|
|
217
|
+
? formatKeys(message.entries)
|
|
218
|
+
: (message.keys || []).join('\n');
|
|
219
|
+
if (rows) process.stdout.write(`${rows}\n`);
|
|
152
220
|
return 0;
|
|
153
221
|
}
|
|
154
222
|
if (message.type === 'error') {
|
|
@@ -158,19 +226,42 @@ async function main() {
|
|
|
158
226
|
});
|
|
159
227
|
}
|
|
160
228
|
|
|
161
|
-
// `--`
|
|
162
|
-
//
|
|
163
|
-
|
|
229
|
+
// `--` ends our flags and starts the command. It is optional - the first
|
|
230
|
+
// word that is not a `--key` also ends them - but it is what keeps a command
|
|
231
|
+
// of its own beginning with `--key`/`-e`/`--prod` from being read as ours.
|
|
232
|
+
const parsed = parseKeys(rest);
|
|
233
|
+
if (parsed.error) {
|
|
234
|
+
process.stderr.write(`factiii-secrets: ${parsed.error}\n`);
|
|
235
|
+
usage();
|
|
236
|
+
return EXIT_UNAVAILABLE;
|
|
237
|
+
}
|
|
238
|
+
const argv = parsed.rest;
|
|
164
239
|
if (!argv.length) {
|
|
165
240
|
process.stderr.write('factiii-secrets: run needs a command.\n');
|
|
166
241
|
usage();
|
|
167
242
|
return EXIT_UNAVAILABLE;
|
|
168
243
|
}
|
|
244
|
+
// Naming no key is allowed, not corrected: the wrapper then buys nothing, so
|
|
245
|
+
// say so once rather than guess at a scope the caller did not ask for.
|
|
246
|
+
if (!parsed.keys.length) {
|
|
247
|
+
process.stderr.write(
|
|
248
|
+
'factiii-secrets: no --key given, so this command gets no secrets. Run `factiii-secrets list` for the names.\n'
|
|
249
|
+
);
|
|
250
|
+
}
|
|
169
251
|
|
|
170
252
|
const command = argv.map(shellQuote).join(' ');
|
|
171
253
|
return request(
|
|
172
254
|
socketPath,
|
|
173
|
-
{
|
|
255
|
+
{
|
|
256
|
+
op: 'run',
|
|
257
|
+
command,
|
|
258
|
+
cwd: process.cwd(),
|
|
259
|
+
keys: parsed.keys,
|
|
260
|
+
// Proves which session is asking. The runner sets it on every session it
|
|
261
|
+
// spawns; a caller it did not spawn has none and is refused rather than
|
|
262
|
+
// put in front of the owner as an anonymous password prompt.
|
|
263
|
+
token: process.env.FACTIII_SECRETS_TOKEN || ''
|
|
264
|
+
},
|
|
174
265
|
(message) => {
|
|
175
266
|
if (message.type === 'status') {
|
|
176
267
|
// stderr, so a caller capturing stdout gets the command's output and
|
|
@@ -10,7 +10,7 @@ manages. You never hold those values, and you never need to: one command runs
|
|
|
10
10
|
anything that needs them.
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
factiii-secrets run -- <command...>
|
|
13
|
+
factiii-secrets run --key NAME -- <command...>
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
It is already on your PATH. Nothing needs installing, and nothing needs
|
|
@@ -18,30 +18,54 @@ setting up before you use it.
|
|
|
18
18
|
|
|
19
19
|
## What actually happens
|
|
20
20
|
|
|
21
|
-
You run the command. It blocks. The owner
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
You run the command. It blocks. The owner gets a prompt wherever they are -
|
|
22
|
+
web or phone - naming your session, the exact command, and the keys you asked
|
|
23
|
+
for, and enters their password to authorize it.
|
|
24
|
+
The runner then decrypts the store for that one command, runs it with **only
|
|
25
|
+
those keys** in its environment, and hands you back its output with every
|
|
26
|
+
secret value replaced by `[REDACTED]`. `factiii-secrets` exits with the
|
|
27
|
+
command's own exit code, so `&&`, `||` and `set -e` all behave normally.
|
|
27
28
|
|
|
28
29
|
The values reach the command and stop there. They are never in your
|
|
29
30
|
environment, never in your context, and never in anything you can read.
|
|
30
31
|
|
|
32
|
+
## Naming the keys
|
|
33
|
+
|
|
34
|
+
`--key` is how a command says what it needs. Only the keys you name are
|
|
35
|
+
injected; the rest of the store stays shut. **A command that names no key gets
|
|
36
|
+
no secrets** - the wrapper does nothing for it.
|
|
37
|
+
|
|
38
|
+
Name one key per flag, repeat the flag, or pass a comma-separated list. All
|
|
39
|
+
three of these ask for the same two keys:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
factiii-secrets run --key AWS_ACCESS_KEY_ID --key AWS_SECRET_ACCESS_KEY -- ...
|
|
43
|
+
factiii-secrets run --key=AWS_ACCESS_KEY_ID --key=AWS_SECRET_ACCESS_KEY -- ...
|
|
44
|
+
factiii-secrets run --key AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY -- ...
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Every `--key` must come BEFORE the `--`. After it, the words are the command's
|
|
48
|
+
own, so a command with a `--key` flag of its own is safe.
|
|
49
|
+
|
|
31
50
|
## Using it
|
|
32
51
|
|
|
33
52
|
Write the command exactly as you would without the wrapper, referencing
|
|
34
|
-
secrets as ordinary `$NAME` variables:
|
|
53
|
+
secrets as ordinary `$NAME` variables, and name every key it will read:
|
|
35
54
|
|
|
36
55
|
```bash
|
|
37
|
-
factiii-secrets run -- vercel deploy --prod --token "$VERCEL_TOKEN"
|
|
38
|
-
factiii-secrets run -- psql "$DATABASE_URL" -f migrations/latest.sql
|
|
39
|
-
factiii-secrets run -- ./scripts/publish.sh
|
|
56
|
+
factiii-secrets run --key VERCEL_TOKEN -- vercel deploy --prod --token "$VERCEL_TOKEN"
|
|
57
|
+
factiii-secrets run --key DATABASE_URL -- psql "$DATABASE_URL" -f migrations/latest.sql
|
|
58
|
+
factiii-secrets run --key NPM_TOKEN --key GH_TOKEN -- ./scripts/publish.sh
|
|
40
59
|
```
|
|
41
60
|
|
|
42
61
|
Quote the variables. They expand on the runner's side, once the values are
|
|
43
62
|
present - not in your shell, where they are empty.
|
|
44
63
|
|
|
64
|
+
A script reads keys its command line never mentions - that last example. Name
|
|
65
|
+
what the SCRIPT reads, not what you can see in the command. Get it wrong and
|
|
66
|
+
the variable is simply empty inside the script; the runner tells you which
|
|
67
|
+
name it could not find.
|
|
68
|
+
|
|
45
69
|
Wrap **only** the commands that need a secret. Everything else - builds,
|
|
46
70
|
tests, git, file edits - runs normally, unwrapped, and stays fast because it
|
|
47
71
|
needs no authorization.
|
|
@@ -50,8 +74,18 @@ needs no authorization.
|
|
|
50
74
|
factiii-secrets list
|
|
51
75
|
```
|
|
52
76
|
|
|
53
|
-
prints the key NAMES
|
|
54
|
-
|
|
77
|
+
prints the key NAMES this workspace has, whether the store actually holds a
|
|
78
|
+
value for each, and whether the deploy contract declares it:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
DATABASE_URL set, declared
|
|
82
|
+
EXPO_TOKEN not set, declared
|
|
83
|
+
OLD_TOKEN set, undeclared
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Never their values; there is no command that prints a value, on purpose. Use
|
|
87
|
+
it when you are unsure what a key is called - guessing a name gets you an
|
|
88
|
+
empty variable, not the value.
|
|
55
89
|
|
|
56
90
|
## Things that will waste your time
|
|
57
91
|
|
|
@@ -60,7 +94,7 @@ no command that prints a value, on purpose.
|
|
|
60
94
|
`printenv`, `test -n "$TOKEN"` and echo-checks all tell you nothing.
|
|
61
95
|
- **Do not report them as missing or stop over them.** An unset declared
|
|
62
96
|
variable is the security model working. Route the command through
|
|
63
|
-
`factiii-secrets run
|
|
97
|
+
`factiii-secrets run --key <NAME>` and it gets what it needs.
|
|
64
98
|
- **Do not ask the owner to export them,** write them to a `.env`, or put them
|
|
65
99
|
in a shell profile or CI config. They are already stored; that is what this
|
|
66
100
|
command reads.
|
|
@@ -73,10 +107,18 @@ no command that prints a value, on purpose.
|
|
|
73
107
|
adapt or stop and explain where things stand. Do not retry the same command.
|
|
74
108
|
- `3` - nothing ran. No store is reachable here, or the runner went away.
|
|
75
109
|
|
|
110
|
+
It works from any session the runner started - a terminal, a card session, a
|
|
111
|
+
release - not only during a deploy. A caller the runner did NOT start is
|
|
112
|
+
refused before the owner is ever prompted, so running the CLI from a shell of
|
|
113
|
+
your own does not work, and no amount of retrying changes that.
|
|
114
|
+
|
|
76
115
|
## Writing a deploy skill
|
|
77
116
|
|
|
78
117
|
A repo's deploy skill declares every key it needs in
|
|
79
118
|
`.agents/skills/deploy/required-variables.json`, then wraps each step that
|
|
80
|
-
needs one in `factiii-secrets run --
|
|
81
|
-
|
|
82
|
-
|
|
119
|
+
needs one in `factiii-secrets run --key <NAME> -- …`, naming exactly the keys
|
|
120
|
+
that step reads. Declare a key exactly when a command uses it: never declare
|
|
121
|
+
one the skill does not use, never use one it does not declare. A step asks for
|
|
122
|
+
the keys IT needs, not for every key the skill declares - that per-step scope
|
|
123
|
+
is what the owner sees and authorizes. Put no secret value, and no key
|
|
124
|
+
material, in any file.
|