@factiii/runner 0.11.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factiii/runner",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Factiii Runner, run Board AI agents on a machine you control. Pairs with the Factiii web/mobile clients over WebRTC.",
5
5
  "license": "ISC",
6
6
  "keywords": [
@@ -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 runner is what holds the decrypted values, execs the command, and sends
7
- * back output already redacted against them. Nothing secret ever enters this
8
- * process, so nothing secret is visible to whatever spawned it - which is the
9
- * whole point, because what spawns it is an AI agent.
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 -- <command...> run it with the declared secrets injected',
33
- ' factiii-secrets list print the declared key NAMES (never values)',
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 store is
112
- // served only while a deploy run is in progress, and a creator session
113
- // drafting a skill is the common case of asking outside one.
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. It is available during a deploy run'
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 deploy run was
122
- // cancelled mid-command. Either way nothing was reported as finished.
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 factiii space while a deploy run is active.\n'
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
- process.stdout.write(`${message.keys.join('\n')}\n`);
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
- // `--` is optional but documented: it is what stops the caller's own flags
162
- // (-e, --prod) from being read as ours.
163
- const argv = rest[0] === '--' ? rest.slice(1) : rest;
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
- { op: 'run', command, cwd: process.cwd() },
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 sees the exact command on their
22
- board, with the keys it will receive, and enters their password to authorize
23
- it. The runner then decrypts the store for that one command, runs it with the
24
- values in its environment, and hands you back its output with every secret
25
- value replaced by `[REDACTED]`. `factiii-secrets` exits with the command's own
26
- exit code, so `&&`, `||` and `set -e` all behave normally.
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 the workspace has declared. Never their values; there is
54
- no command that prints a value, on purpose.
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` and it gets what it needs.
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 -- …`. Declare a key exactly when a command
81
- uses it: never declare one the skill does not use, never use one it does not
82
- declare. Put no secret value, and no key material, in any file.
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.