auto-harness-client 0.6.0 → 0.7.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/README.md +287 -0
- package/package.json +5 -1
- package/src/cli/admin-login.js +71 -0
- package/src/cli/allowlist.js +34 -0
- package/src/cli/args.js +41 -0
- package/src/cli/cli-errors.js +16 -0
- package/src/cli/commands/api.js +91 -0
- package/src/cli/commands/dependency-conflict.js +15 -0
- package/src/cli/commands/doctor.js +164 -0
- package/src/cli/commands/host-drain.js +22 -0
- package/src/cli/commands/host-inventory-get.js +40 -0
- package/src/cli/commands/host-inventory-set.js +66 -0
- package/src/cli/commands/host-inventory.js +15 -0
- package/src/cli/commands/host-list.js +87 -0
- package/src/cli/commands/host-post-action.js +38 -0
- package/src/cli/commands/host-repo-rm.js +128 -0
- package/src/cli/commands/host-repo.js +11 -0
- package/src/cli/commands/host-resume.js +17 -0
- package/src/cli/commands/host.js +25 -0
- package/src/cli/commands/repo-list.js +84 -0
- package/src/cli/commands/repo-rm.js +83 -0
- package/src/cli/commands/repo.js +15 -0
- package/src/cli/commands/service-account-create.js +114 -0
- package/src/cli/commands/service-account-list.js +84 -0
- package/src/cli/commands/service-account-rm.js +48 -0
- package/src/cli/commands/service-account.js +19 -0
- package/src/cli/commands/whoami.js +22 -0
- package/src/cli/config.js +96 -0
- package/src/cli/index.js +21 -0
- package/src/cli/main.js +69 -0
- package/src/cli/path-segment.js +20 -0
- package/src/cli/read-stdin.js +9 -0
- package/src/cli/report-error.js +34 -0
- package/src/cli/service-account-format.js +24 -0
- package/src/cli/usage.js +66 -0
- package/src/errors.js +1 -0
- package/src/index.d.ts +64 -4
- package/src/index.js +76 -30
- package/src/resolve-target.js +29 -13
package/README.md
CHANGED
|
@@ -160,6 +160,20 @@ const resumed = await harness.resumeSession("session-1", { prompt: "Address the
|
|
|
160
160
|
console.log(resumed.status);
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
Pass `target` (and, only alongside it, `fallbacks`) to **rebind** the resume onto a different
|
|
164
|
+
Command/Provider instead of continuing the source session's original route — the same
|
|
165
|
+
`providerId`/`commandId`/`providerName`/`commandName` shapes as `createSession()`, resolved to ids
|
|
166
|
+
the same way. This replaces the source session's whole target/fallback policy (a bare `fallbacks`
|
|
167
|
+
override with no `target` is rejected) and always resumes on a fresh assignment: there is no native
|
|
168
|
+
CLI resume onto a different Command, so it may also land on a different host.
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
const resumed = await harness.resumeSession("session-1", {
|
|
172
|
+
prompt: "Continue with the updated Command.",
|
|
173
|
+
target: { commandName: "claude-print-auto" },
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
163
177
|
## List sessions
|
|
164
178
|
|
|
165
179
|
Session listings are bounded pages with the same cursor shape as repository listings, plus
|
|
@@ -203,3 +217,276 @@ import { parseHarnessTarget, requiredEnvironmentValue } from "auto-harness-clien
|
|
|
203
217
|
const target = parseHarnessTarget(process.env.HARNESS_TARGET);
|
|
204
218
|
const apiKey = requiredEnvironmentValue(process.env, "HARNESS_API_KEY");
|
|
205
219
|
```
|
|
220
|
+
|
|
221
|
+
## CLI: `auto-harness`
|
|
222
|
+
|
|
223
|
+
This package also ships a small operator CLI, `auto-harness`, for scripting and debugging
|
|
224
|
+
against a control plane from a shell:
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
npx auto-harness-client whoami
|
|
228
|
+
# or, once installed:
|
|
229
|
+
auto-harness whoami
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Configuration
|
|
233
|
+
|
|
234
|
+
The CLI never touches your repository config or the host daemon's persisted env file — it only
|
|
235
|
+
reads what you pass it:
|
|
236
|
+
|
|
237
|
+
| Setting | Flag | Environment variable(s) |
|
|
238
|
+
| -------------------- | ----------------------- | --------------------------------------------- |
|
|
239
|
+
| Base URL | `--api-url <url>` | `HARNESS_API_URL` (alias: `HARNESS_API_HTTP`) |
|
|
240
|
+
| API key | — | `HARNESS_API_KEY` |
|
|
241
|
+
| API key, from a file | `--api-key-file <path>` | `HARNESS_API_KEY_FILE` |
|
|
242
|
+
| Allow plain HTTP | `--allow-insecure-http` | — |
|
|
243
|
+
|
|
244
|
+
A flag always wins over its environment variable. `HARNESS_API_URL`/`HARNESS_API_HTTP` are the
|
|
245
|
+
same two variables the host daemon itself already accepts, so operators typically have one set.
|
|
246
|
+
An `--api-key-file`/`HARNESS_API_KEY_FILE` value is read and trimmed of surrounding whitespace.
|
|
247
|
+
|
|
248
|
+
**There is no `--api-key` flag.** A key passed on the command line lands in `ps` output and shell
|
|
249
|
+
history for the life of the process and the life of the shell's history file. Use the
|
|
250
|
+
`HARNESS_API_KEY` environment variable, or point `--api-key-file` / `HARNESS_API_KEY_FILE` at a
|
|
251
|
+
file holding it; passing `--api-key` fails immediately with a usage error telling you so.
|
|
252
|
+
|
|
253
|
+
Exit codes: `0` success, `1` an API/HTTP failure (or a failed `doctor` check), `2` a usage or
|
|
254
|
+
configuration error.
|
|
255
|
+
|
|
256
|
+
#### Admin bootstrap: `--admin-password-stdin`
|
|
257
|
+
|
|
258
|
+
Every API key is minted by `service-account create`, which itself requires being authenticated —
|
|
259
|
+
so the very first maintainer or service-account key can only come from an admin, and admin exists
|
|
260
|
+
only as a username/password, not an API key. `--admin-password-stdin` (with `--admin-username
|
|
261
|
+
<name>`, default `admin`) logs in as that admin instead of using an API key: it reads the password
|
|
262
|
+
from stdin (one trailing newline stripped), `POST`s it once to `/auth/login`, and carries the
|
|
263
|
+
returned session cookie on every later request for the rest of the invocation. The password never
|
|
264
|
+
touches argv, shell history, or output, and the login response body — which carries the
|
|
265
|
+
principal — is deliberately never read or printed.
|
|
266
|
+
|
|
267
|
+
```sh
|
|
268
|
+
aws ssm get-parameter --name /auto-harness/admin-password --with-decryption \
|
|
269
|
+
--query Parameter.Value --output text \
|
|
270
|
+
| auto-harness --admin-password-stdin service-account create --name ci --role operator --print-key
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
It cannot be combined with an API key (`--api-key-file`, `HARNESS_API_KEY`, or
|
|
274
|
+
`HARNESS_API_KEY_FILE` all make the identity ambiguous) or with a command that also reads stdin
|
|
275
|
+
for its own input (`api --body-file -`, `host inventory set --file -`) — both are usage errors
|
|
276
|
+
(exit 2) before any request is made. A rejected password is `error: admin login failed (HTTP 401)`
|
|
277
|
+
(exit 1), never anything about the password itself. `whoami` and `doctor` work in this mode too,
|
|
278
|
+
reporting the admin identity instead of an API key's role.
|
|
279
|
+
|
|
280
|
+
### `auto-harness api <METHOD> <path>`
|
|
281
|
+
|
|
282
|
+
A generic escape hatch for any route the control plane exposes. Both `/hosts` and
|
|
283
|
+
`/api/v1/hosts` are accepted (a leading `/api/v1` is stripped). `--body` takes inline JSON;
|
|
284
|
+
`--body-file <path>` reads a file, and `--body-file -` reads stdin. A `204` or otherwise empty
|
|
285
|
+
response prints nothing; anything else is pretty-printed JSON on stdout.
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
auto-harness api GET /hosts
|
|
289
|
+
auto-harness api POST /repositories --body '{"name":"org/repo","url":"https://github.com/org/repo"}'
|
|
290
|
+
echo '{"prompt":"Review the latest changes"}' | auto-harness api POST /sessions --body-file -
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### `auto-harness whoami [--json]`
|
|
294
|
+
|
|
295
|
+
`GET /auth/me`, printing only an allowlist of fields (`id`, `kind`, `username`, `name`, `role`,
|
|
296
|
+
`capabilities`, `boundHostId`, `allowedRepositoryIds`) — never `passwordHash`, `apiKeyHash`, or
|
|
297
|
+
anything else the API response happens to carry.
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
auto-harness whoami
|
|
301
|
+
auto-harness whoami --json
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### `auto-harness doctor`
|
|
305
|
+
|
|
306
|
+
Runs a handful of independent checks and reports each as `ok`, `warn`, or `fail` with a one-line
|
|
307
|
+
reason, exiting `1` if any check `fail`s:
|
|
308
|
+
|
|
309
|
+
- **url** — `fail`s for a plain `http://` base URL unless `--allow-insecure-http` is set; `warn`s
|
|
310
|
+
for a raw `*.execute-api.*.amazonaws.com` URL, which bypasses CloudFront (and the ingress token
|
|
311
|
+
CloudFront injects), so requests against it will be rejected.
|
|
312
|
+
- **reachability** — `GET /health` (at the site root, not under `/api/v1`) expecting HTTP 200 and
|
|
313
|
+
`{"ok":true}`.
|
|
314
|
+
- **auth** — if an API key is configured, `GET /auth/me`; `warn`s instead, without making the
|
|
315
|
+
call, when no key is configured. A `401` reports "API key rejected".
|
|
316
|
+
|
|
317
|
+
```sh
|
|
318
|
+
auto-harness doctor
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### `auto-harness host <subcommand>`
|
|
322
|
+
|
|
323
|
+
Operator commands for the host fleet. Every call goes through `client.request()` against the
|
|
324
|
+
same routes `auto-harness api` would hit — nothing here is a special path.
|
|
325
|
+
|
|
326
|
+
#### `auto-harness host list [--online | --offline] [--limit N] [--cursor C] [--all] [--json]`
|
|
327
|
+
|
|
328
|
+
`GET /hosts`, printing one line per host (`hostId`, `online`/`offline`, and `draining` when
|
|
329
|
+
true). `--online`/`--offline` filter server-side (mutually exclusive — passing both is a usage
|
|
330
|
+
error); `--limit`/`--cursor` page manually. If the page has a `nextCursor` and `--all` was not
|
|
331
|
+
passed, a final line prints it so you can continue.
|
|
332
|
+
|
|
333
|
+
`--all` follows `nextCursor` itself and prints every host across pages (or `{ items: [...all] }`
|
|
334
|
+
with `--json`). Per this repo's [list/history invariant](../../docs/plan.md#5-invariants), it
|
|
335
|
+
never collects pages without bound: it stops after 20 pages and warns on stderr if there was
|
|
336
|
+
still more, rather than silently truncating or looping forever.
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
auto-harness host list --online
|
|
340
|
+
auto-harness host list --all --json
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
#### `auto-harness host drain <hostId> [--json]`
|
|
344
|
+
|
|
345
|
+
`POST /hosts/drain` with `{"hostId": "<id>"}` in the JSON body — **the host id is never in the
|
|
346
|
+
path** (`POST /hosts/<id>/drain` does not exist). Prints how many sessions are still running and
|
|
347
|
+
their ids. A `409 CONFLICT` means the host's connection changed mid-request; the normal error is
|
|
348
|
+
printed, followed by a one-line hint that retrying is safe.
|
|
349
|
+
|
|
350
|
+
#### `auto-harness host resume <hostId> [--json]`
|
|
351
|
+
|
|
352
|
+
`POST /hosts/resume`, same body-param shape as `drain`. Idempotent — safe to run on a host that
|
|
353
|
+
is not currently draining, which returns 200 rather than an error.
|
|
354
|
+
|
|
355
|
+
#### `auto-harness host inventory get <hostId> [--json]`
|
|
356
|
+
|
|
357
|
+
`GET /hosts/<hostId>/inventory`. Human output shows the record's `version`, each attached
|
|
358
|
+
repository (`id`, `path`, worktree count), and the provider account count; `--json` prints the
|
|
359
|
+
raw record.
|
|
360
|
+
|
|
361
|
+
#### `auto-harness host inventory set <hostId> --file <path|->`
|
|
362
|
+
|
|
363
|
+
`PUT /hosts/<hostId>/inventory` from a JSON file (or `-` for stdin), sent **verbatim** — this is
|
|
364
|
+
an authoritative write that replaces the entire inventory record, not a merge. Two traps this
|
|
365
|
+
command guards against:
|
|
366
|
+
|
|
367
|
+
- **Omitting `version` silently disables optimistic concurrency**: the server falls back to
|
|
368
|
+
whatever version is currently stored, so a concurrent edit can be overwritten with no error.
|
|
369
|
+
This command refuses (exit 2) to send a document with no integer `version` field, and tells you
|
|
370
|
+
to start from `auto-harness host inventory get <hostId> --json`.
|
|
371
|
+
- A version that has moved since you read it comes back as `409 CONFLICT` — re-read and reapply
|
|
372
|
+
rather than retrying the same body.
|
|
373
|
+
|
|
374
|
+
Because the write replaces the whole record, omitting `providerAccounts` wipes the host's
|
|
375
|
+
provider routing — always build the new document from a fresh `inventory get --json`, editing
|
|
376
|
+
only what you mean to change.
|
|
377
|
+
|
|
378
|
+
#### `auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]`
|
|
379
|
+
|
|
380
|
+
Detaches one repository from a host. This exists because the only prior way to do it was to
|
|
381
|
+
hand-assemble a full `PUT` of the inventory record — easy to get wrong, especially for
|
|
382
|
+
`providerAccounts`, which a naive PUT can silently drop. This command does a safe
|
|
383
|
+
read-modify-write instead:
|
|
384
|
+
|
|
385
|
+
1. `GET` the inventory.
|
|
386
|
+
2. If no repository with that id is attached, it fails (exit 1) and lists the ids that _are_
|
|
387
|
+
attached — nothing is written.
|
|
388
|
+
3. Builds the new document as the record exactly as read, with only that repository removed from
|
|
389
|
+
`repositories` and the read `version` kept — every other field, including
|
|
390
|
+
`providerAccounts`, is preserved untouched. (The control plane also re-adds any repository a
|
|
391
|
+
scoped API key can't see before persisting, so this read-modify-write is safe even when the
|
|
392
|
+
caller's key is scoped to a subset of repositories.)
|
|
393
|
+
4. `--dry-run` prints what would be removed — the repository's id, path, and the ids of its
|
|
394
|
+
worktrees — and exits without writing.
|
|
395
|
+
5. On a `409` (someone else wrote first) it re-reads and re-applies, up to 3 attempts total, then
|
|
396
|
+
fails saying the inventory kept changing. Any other error status is not retried.
|
|
397
|
+
6. On success it prints what was removed and the version transition (e.g. `version 29 → 30`).
|
|
398
|
+
|
|
399
|
+
**Removing a repository from the inventory also removes its worktrees** — they are a projection
|
|
400
|
+
of the repository, not independent records. Deleting the repository record itself (as opposed to
|
|
401
|
+
detaching it from this host's inventory) is a separate operation.
|
|
402
|
+
|
|
403
|
+
```sh
|
|
404
|
+
auto-harness host repo rm host-1 repo-1 --dry-run
|
|
405
|
+
auto-harness host repo rm host-1 repo-1
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### `auto-harness repo <subcommand>`
|
|
409
|
+
|
|
410
|
+
Repository CRUD, straight against the same routes `auto-harness api` would hit.
|
|
411
|
+
|
|
412
|
+
#### `auto-harness repo list [--limit N] [--cursor C] [--all] [--json]`
|
|
413
|
+
|
|
414
|
+
`GET /repositories`, printing one line per repository (id, name, and status/url when present).
|
|
415
|
+
Paging works exactly like `host list`: `--limit`/`--cursor` page manually, and `--all` follows
|
|
416
|
+
`nextCursor` itself, capped at 20 pages with a stderr warning if more remain.
|
|
417
|
+
|
|
418
|
+
```sh
|
|
419
|
+
auto-harness repo list --all
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
#### `auto-harness repo rm <repositoryId> [--json]`
|
|
423
|
+
|
|
424
|
+
`DELETE /repositories/<id>`. The point of this command is that a refusal explains itself: a `409`
|
|
425
|
+
means other records still reference the repository, and rather than dumping that as raw JSON,
|
|
426
|
+
each blocking dependency gets the concrete next step to actually clear it:
|
|
427
|
+
|
|
428
|
+
| Dependency kind | Next step |
|
|
429
|
+
| ----------------------------- | ------------------------------------------------------------------------------------ |
|
|
430
|
+
| `schedule` | `auto-harness api DELETE /schedules/<id>` |
|
|
431
|
+
| `session` (live) | wait for it, or `auto-harness api POST /sessions/<id>/cancel` |
|
|
432
|
+
| `session-drain` | `auto-harness api POST /repositories/<repositoryId>/session-drains/<id>/release` |
|
|
433
|
+
| `host-inventory` | `auto-harness host repo rm <hostId> <repositoryId>` |
|
|
434
|
+
| `worktree` | same as `host-inventory` — worktrees go with detaching the repository from that host |
|
|
435
|
+
| `integration: github-ingress` | remove this repository's binding from the GitHub ingress configuration |
|
|
436
|
+
| `integration` (other) | remove or retarget that integration |
|
|
437
|
+
| anything else | printed as `<kind> <id>`, so a new server-side kind still displays |
|
|
438
|
+
|
|
439
|
+
Exits `1` on a `409` (after printing the server's message and every hint). With `--json`, a `409`
|
|
440
|
+
prints `{ "deleted": false, "dependencies": [...], "hints": [...] }`; success prints
|
|
441
|
+
`{ "deleted": true, "id": "..." }`.
|
|
442
|
+
|
|
443
|
+
```sh
|
|
444
|
+
auto-harness repo rm repo-1
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### `auto-harness service-account <subcommand>`
|
|
448
|
+
|
|
449
|
+
Service-account lifecycle. Every account's real permissions are its `role` and grants
|
|
450
|
+
(`boundHostId`/`allowedRepositoryIds`) — the CLI never invents a route for anything else.
|
|
451
|
+
|
|
452
|
+
#### `auto-harness service-account list [--limit N] [--cursor C] [--all] [--json]`
|
|
453
|
+
|
|
454
|
+
`GET /auth/service-accounts`, printing id, name, role, `boundHostId` (if set), and `createdAt`.
|
|
455
|
+
Items are already server-sanitized, but this still filters them through the same allowlist as
|
|
456
|
+
`whoami`/`doctor` as defense in depth — in **both** human and `--json` output, so a field outside
|
|
457
|
+
the allowlist can never leak through either mode. Paging matches `host list`/`repo list`.
|
|
458
|
+
|
|
459
|
+
#### `auto-harness service-account create --name <name> --role <role> [--bound-host <hostId>] [--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]`
|
|
460
|
+
|
|
461
|
+
`POST /auth/service-accounts`. The response's API key is shown **exactly once** — the server
|
|
462
|
+
stores only a hash — so this command forces a deliberate choice about where that one-time value
|
|
463
|
+
goes:
|
|
464
|
+
|
|
465
|
+
- **`--key-file <path>`** writes the key to a new file, created with `O_CREAT | O_EXCL` and mode
|
|
466
|
+
`0600` — it never overwrites an existing file (checked before the request is even made, and
|
|
467
|
+
enforced atomically when the file is actually written). Only the account id and
|
|
468
|
+
`key written to <path>` are printed; if the write fails _after_ the account was already
|
|
469
|
+
created, the error names the account id and tells you to run `service-account rm` on it, since
|
|
470
|
+
the key itself is unrecoverable at that point.
|
|
471
|
+
- **`--print-key`** writes _only_ the key to stdout, on its own line, so
|
|
472
|
+
`KEY=$(auto-harness service-account create … --print-key)` works; the human-readable account
|
|
473
|
+
summary goes to stderr instead. Combining this with `--json` is a usage error — the stdout
|
|
474
|
+
contract would be ambiguous.
|
|
475
|
+
|
|
476
|
+
Exactly one of `--key-file`/`--print-key` is required; neither/both is a usage error (exit 2,
|
|
477
|
+
no request made). `--repositories a,b,c` maps to `allowedRepositoryIds`. The server validates
|
|
478
|
+
`--role` and any grants; an invalid one surfaces as its own `400`, unchanged.
|
|
479
|
+
|
|
480
|
+
```sh
|
|
481
|
+
auto-harness service-account create --name ci --role operator --key-file ./ci.key
|
|
482
|
+
KEY=$(auto-harness service-account create --name ci --role operator --print-key)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
#### `auto-harness service-account rm <id> [--json]`
|
|
486
|
+
|
|
487
|
+
`DELETE /auth/service-accounts/<id>`. A `409` prints each blocking dependency generically, as
|
|
488
|
+
`<kind> <id>` — unlike `repo rm`, there is no per-kind hint here.
|
|
489
|
+
|
|
490
|
+
```sh
|
|
491
|
+
auto-harness service-account rm svc-1
|
|
492
|
+
```
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-harness-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Dependency-free client for the Auto Harness automation API",
|
|
5
|
+
"license": "MIT",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
7
8
|
"url": "git+https://github.com/jonathanong/auto-harness.git",
|
|
8
9
|
"directory": "modules/client"
|
|
9
10
|
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"auto-harness": "./src/cli/index.js"
|
|
13
|
+
},
|
|
10
14
|
"files": [
|
|
11
15
|
"LICENSE",
|
|
12
16
|
"README.md",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isLoopbackOrigin } from "../loopback.js";
|
|
2
|
+
import { CliConfigError, CliUsageError } from "./cli-errors.js";
|
|
3
|
+
import { readStdin } from "./read-stdin.js";
|
|
4
|
+
|
|
5
|
+
// Matches `AutoHarnessClient`'s default `requestTimeoutMs`.
|
|
6
|
+
const ADMIN_LOGIN_TIMEOUT_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Usage-error checks for `--admin-password-stdin`, run before any request. An admin login is an
|
|
10
|
+
* alternate *identity*, not an additional one, so it is ambiguous to also have an API key
|
|
11
|
+
* configured — and it needs stdin for the password, so it conflicts with any command that also
|
|
12
|
+
* reads stdin for its own input (pass that command's own description as `stdinClaimedBy`, e.g.
|
|
13
|
+
* `"api --body-file -"`).
|
|
14
|
+
*/
|
|
15
|
+
export function checkAdminLoginUsage(flags, env, stdinClaimedBy) {
|
|
16
|
+
if (!flags["--admin-password-stdin"]) return;
|
|
17
|
+
// Presence, not truthiness: an empty `--api-key-file=` is still an explicit (broken) request
|
|
18
|
+
// for API-key identity, and must not slip past this ambiguity check.
|
|
19
|
+
if (Object.hasOwn(flags, "--api-key-file") || env.HARNESS_API_KEY || env.HARNESS_API_KEY_FILE) {
|
|
20
|
+
throw new CliUsageError(
|
|
21
|
+
"--admin-password-stdin cannot be combined with an API key (--api-key-file, " +
|
|
22
|
+
"HARNESS_API_KEY, or HARNESS_API_KEY_FILE); pick one identity",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (stdinClaimedBy) {
|
|
26
|
+
throw new CliUsageError(
|
|
27
|
+
`--admin-password-stdin and ${stdinClaimedBy} both need stdin; pass one of them another way`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `POST /auth/login` with the password read from stdin (one trailing newline stripped), then
|
|
34
|
+
* returns a `fetch` that carries the session cookie on every later request. The login response
|
|
35
|
+
* body carries the principal but is deliberately never read — see the CLI spec — so a non-200 is
|
|
36
|
+
* reported by status code alone, never by inspecting or printing the body.
|
|
37
|
+
*/
|
|
38
|
+
export async function loginAsAdmin(io, baseUrl, username, allowInsecureHttp = false) {
|
|
39
|
+
// The password is a credential, so it gets the same transport rule the client applies to an
|
|
40
|
+
// API key (`assertSecureTransport` in loopback.js): https, or plain http only to a loopback
|
|
41
|
+
// host and only with --allow-insecure-http. Admin mode sets no API key, so the client's own
|
|
42
|
+
// check never fires here. Checked before stdin is read, so a refused URL never consumes the
|
|
43
|
+
// password, and the session cookie that comes back is bound to the same transport.
|
|
44
|
+
if (!baseUrl.startsWith("https://") && !(allowInsecureHttp && isLoopbackOrigin(baseUrl))) {
|
|
45
|
+
throw new CliConfigError(
|
|
46
|
+
"--admin-password-stdin requires an https API URL (--allow-insecure-http only permits " +
|
|
47
|
+
"plain HTTP to a loopback host, e.g. http://127.0.0.1 or http://localhost)",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const rawPassword = await readStdin(io.stdin);
|
|
51
|
+
const password = rawPassword.endsWith("\n") ? rawPassword.slice(0, -1) : rawPassword;
|
|
52
|
+
const origin = baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
|
|
53
|
+
// A raw fetch, so it needs its own bound — the same one `doctor`'s /health probe uses —
|
|
54
|
+
// or a stalled server hangs the command before `AutoHarnessClient`'s timeout ever applies.
|
|
55
|
+
const response = await io.fetch(`${origin}/api/v1/auth/login`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/json" },
|
|
58
|
+
body: JSON.stringify({ username, password }),
|
|
59
|
+
signal: (io.timeoutSignal ?? AbortSignal.timeout)(ADMIN_LOGIN_TIMEOUT_MS),
|
|
60
|
+
});
|
|
61
|
+
if (response.status !== 200) throw new Error(`admin login failed (HTTP ${response.status})`);
|
|
62
|
+
const cookieHeader = response.headers
|
|
63
|
+
.getSetCookie()
|
|
64
|
+
.map((header) => header.split(";")[0].trim())
|
|
65
|
+
.join("; ");
|
|
66
|
+
return {
|
|
67
|
+
username,
|
|
68
|
+
fetch: (url, init = {}) =>
|
|
69
|
+
io.fetch(url, { ...init, headers: { ...init.headers, cookie: cookieHeader } }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Defense in depth. `GET /auth/me` spreads the server's principal object into its response.
|
|
2
|
+
// That principal is sanitized today — verified live, it carries no password or API-key hash —
|
|
3
|
+
// but the stored records behind it do hold `passwordHash` / `apiKeyHash`, and the client cannot
|
|
4
|
+
// see a future server change that lets one through. So `whoami` and `doctor` print only these
|
|
5
|
+
// named fields rather than trusting the response shape.
|
|
6
|
+
const ALLOWED_PRINCIPAL_FIELDS = [
|
|
7
|
+
"id",
|
|
8
|
+
"kind",
|
|
9
|
+
"username",
|
|
10
|
+
"name",
|
|
11
|
+
"role",
|
|
12
|
+
"capabilities",
|
|
13
|
+
"boundHostId",
|
|
14
|
+
"allowedRepositoryIds",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function allowlistPrincipal(principal) {
|
|
18
|
+
const allowed = {};
|
|
19
|
+
if (!principal || typeof principal !== "object") return allowed;
|
|
20
|
+
for (const field of ALLOWED_PRINCIPAL_FIELDS) {
|
|
21
|
+
if (Object.hasOwn(principal, field)) allowed[field] = principal[field];
|
|
22
|
+
}
|
|
23
|
+
return allowed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatWhoami(principal) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
for (const field of ALLOWED_PRINCIPAL_FIELDS) {
|
|
29
|
+
if (!Object.hasOwn(principal, field)) continue;
|
|
30
|
+
const value = principal[field];
|
|
31
|
+
lines.push(`${field}: ${Array.isArray(value) ? value.join(", ") : value}`);
|
|
32
|
+
}
|
|
33
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "(no fields returned)\n";
|
|
34
|
+
}
|
package/src/cli/args.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CliUsageError } from "./cli-errors.js";
|
|
2
|
+
|
|
3
|
+
const REJECTED_API_KEY_MESSAGE =
|
|
4
|
+
"--api-key is not accepted on the command line (it would leak into `ps` output and shell " +
|
|
5
|
+
"history); set HARNESS_API_KEY instead, or point --api-key-file / HARNESS_API_KEY_FILE at a file";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Splits `argv` into recognized `--flag`/`--flag value`/`--flag=value` entries and positional
|
|
9
|
+
* arguments. Every `--name=value` token is split into name and value *before* any matching, so
|
|
10
|
+
* every check below — the rejected `--api-key` flag, an unknown flag, a value flag missing its
|
|
11
|
+
* value — only ever sees the flag name. This is what keeps a mistyped `--api-key=<secret>` (or
|
|
12
|
+
* any other `--unknown=<secret>`) from echoing the secret back in a "unknown flag" error.
|
|
13
|
+
*/
|
|
14
|
+
export function parseFlags(argv, { valueFlags = [], booleanFlags = [] } = {}) {
|
|
15
|
+
const flags = {};
|
|
16
|
+
const positionals = [];
|
|
17
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
18
|
+
const arg = argv[i];
|
|
19
|
+
if (!arg.startsWith("--")) {
|
|
20
|
+
positionals.push(arg);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const equals = arg.indexOf("=");
|
|
24
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
25
|
+
const inlineValue = equals === -1 ? undefined : arg.slice(equals + 1);
|
|
26
|
+
if (name === "--api-key") throw new CliUsageError(REJECTED_API_KEY_MESSAGE);
|
|
27
|
+
if (valueFlags.includes(name)) {
|
|
28
|
+
const value = inlineValue !== undefined ? inlineValue : argv[(i += 1)];
|
|
29
|
+
if (value === undefined) throw new CliUsageError(`${name} requires a value`);
|
|
30
|
+
flags[name] = value;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (booleanFlags.includes(name)) {
|
|
34
|
+
if (inlineValue !== undefined) throw new CliUsageError(`${name} does not take a value`);
|
|
35
|
+
flags[name] = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
throw new CliUsageError(`unknown flag: ${name}`);
|
|
39
|
+
}
|
|
40
|
+
return { flags, positionals };
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A malformed invocation: a bad flag, a missing/invalid argument, invalid JSON input. Exit 2. */
|
|
2
|
+
export class CliUsageError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "CliUsageError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Configuration could not be resolved: no base URL, an unreadable key file, an invalid
|
|
10
|
+
* baseUrl/apiKey combination rejected by `AutoHarnessClient` itself. Exit 2. */
|
|
11
|
+
export class CliConfigError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CliConfigError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { checkAdminLoginUsage } from "../admin-login.js";
|
|
2
|
+
import { parseFlags } from "../args.js";
|
|
3
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
4
|
+
import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
|
|
5
|
+
import { readStdin } from "../read-stdin.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = "usage: auto-harness api <METHOD> <path> [--body <json> | --body-file <path|->]";
|
|
8
|
+
|
|
9
|
+
// Methods the control-plane API uses. An explicit list rather than HTTP's token grammar: Fetch
|
|
10
|
+
// also rejects valid tokens (CONNECT, TRACE, TRACK), and a typo should be a usage error (exit 2)
|
|
11
|
+
// before anything is sent, not a Fetch TypeError surfacing as an API failure (exit 1).
|
|
12
|
+
const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
|
|
13
|
+
|
|
14
|
+
// Segments the WHATWG URL parser resolves as "." and "..", percent-encoded forms included
|
|
15
|
+
// (compared case-insensitively). Letting one through would resolve the request outside
|
|
16
|
+
// `/api/v1` — e.g. `/../../health` becomes `/health` — with the bearer token still attached.
|
|
17
|
+
const DOT_SEGMENTS = new Set([".", "..", "%2e", "%2e%2e", ".%2e", "%2e."]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generic escape hatch for any route. Prints the response's parsed JSON, pretty-printed, on
|
|
21
|
+
* success; prints nothing for a 204 or otherwise empty body. Deliberately does not filter the
|
|
22
|
+
* response the way `whoami`/`doctor` do — an operator asking for `GET /auth/me` through this
|
|
23
|
+
* command gets the raw body, hashes included, because filtering an explicit raw-response request
|
|
24
|
+
* would defeat the point of having an escape hatch at all.
|
|
25
|
+
*/
|
|
26
|
+
export async function runApi(argv, io) {
|
|
27
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
28
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, "--body", "--body-file"],
|
|
29
|
+
booleanFlags: GLOBAL_BOOLEAN_FLAGS,
|
|
30
|
+
});
|
|
31
|
+
const [method, path] = positionals;
|
|
32
|
+
if (!method || !path || positionals.length > 2) throw new CliUsageError(USAGE);
|
|
33
|
+
if (flags["--body"] !== undefined && flags["--body-file"] !== undefined) {
|
|
34
|
+
throw new CliUsageError("--body and --body-file are mutually exclusive");
|
|
35
|
+
}
|
|
36
|
+
const httpMethod = method.toUpperCase();
|
|
37
|
+
if (!METHODS.has(httpMethod)) {
|
|
38
|
+
throw new CliUsageError(
|
|
39
|
+
`unsupported HTTP method ${method}; use one of ${[...METHODS].join(", ")}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const apiPath = normalizeApiPath(path);
|
|
43
|
+
// Both this command's own `--body-file -` and `--admin-password-stdin` need stdin; catch the
|
|
44
|
+
// conflict before reading either, rather than letting one silently drain the other's input.
|
|
45
|
+
const stdinClaimedBy = flags["--body-file"] === "-" ? "api --body-file -" : undefined;
|
|
46
|
+
checkAdminLoginUsage(flags, io.env, stdinClaimedBy);
|
|
47
|
+
const body = await resolveBody(flags, io);
|
|
48
|
+
const client = await createClient(flags, io);
|
|
49
|
+
const result = await client.request(apiPath, {
|
|
50
|
+
method: httpMethod,
|
|
51
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
52
|
+
});
|
|
53
|
+
if (result !== undefined) io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function resolveBody(flags, io) {
|
|
58
|
+
let text;
|
|
59
|
+
if (flags["--body"] !== undefined) text = flags["--body"];
|
|
60
|
+
else if (flags["--body-file"] === "-") text = await readStdin(io.stdin);
|
|
61
|
+
else if (flags["--body-file"] !== undefined) {
|
|
62
|
+
text = await readBodyFile(flags["--body-file"], io.readFile);
|
|
63
|
+
} else return undefined;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(text);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new CliUsageError("--body/--body-file must contain valid JSON");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readBodyFile(path, readFile) {
|
|
72
|
+
try {
|
|
73
|
+
return await readFile(path, "utf8");
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new CliUsageError(`could not read --body-file ${path}: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The client already prefixes every path with `/api/v1`, so accept both `/hosts` and
|
|
80
|
+
* `/api/v1/hosts` by stripping a leading `/api/v1` segment before handing the path along.
|
|
81
|
+
* Rejects any dot segment in the path part (the query string is not a path), splitting on `\`
|
|
82
|
+
* as well as `/` because an https URL treats a backslash as a segment separator. */
|
|
83
|
+
function normalizeApiPath(path) {
|
|
84
|
+
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
|
|
85
|
+
const stripped = withLeadingSlash.replace(/^\/api\/v1(?=\/|$)/, "");
|
|
86
|
+
const pathPart = stripped.split(/[?#]/, 1)[0];
|
|
87
|
+
if (pathPart.split(/[/\\]/).some((segment) => DOT_SEGMENTS.has(segment.toLowerCase()))) {
|
|
88
|
+
throw new CliUsageError(`path must not contain "." or ".." segments: ${path}`);
|
|
89
|
+
}
|
|
90
|
+
return stripped === "" ? "/" : stripped;
|
|
91
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AutoHarnessError } from "../../index.js";
|
|
2
|
+
|
|
3
|
+
/** True for a refused delete: a 409 CONFLICT whose body carries `dependencies` (see
|
|
4
|
+
* `AutoHarnessClient#request`, which puts the whole `error` body on `AutoHarnessError#details`).
|
|
5
|
+
* Shared by `repo rm` and `service-account rm`, which both handle this status themselves rather
|
|
6
|
+
* than letting the generic `reportError` print it as an opaque JSON blob. */
|
|
7
|
+
export function isDependencyConflict(error) {
|
|
8
|
+
return error instanceof AutoHarnessError && error.status === 409;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The dependency array from a conflict's error body, or `[]` if absent/malformed. */
|
|
12
|
+
export function conflictDependencies(error) {
|
|
13
|
+
const dependencies = error.details?.dependencies;
|
|
14
|
+
return Array.isArray(dependencies) ? dependencies : [];
|
|
15
|
+
}
|