approval-md 0.3.0 → 0.4.1
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 +83 -914
- package/SPEC.md +101 -13
- package/cli.js +134 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +72 -0
- package/dist/src/channels/contract.js +161 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.d.ts +25 -0
- package/dist/src/channels/tagging.js +63 -6
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +725 -20
- package/dist/src/channels/telegram.js +1332 -123
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +358 -5
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
- package/dist/src/cli/channel-telegram-webhook.js +716 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +648 -56
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +29 -1
- package/dist/src/cli/execute.js +387 -21
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +10 -1
- package/dist/src/cli/help.js +283 -54
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +485 -6
- package/dist/src/cli/hook.js +2370 -70
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +72 -4
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +26 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +338 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +153 -12
- package/dist/src/core/audit.js +415 -20
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +766 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +364 -6
- package/dist/src/core/gate.js +802 -27
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +130 -22
- package/dist/src/core/log.js +473 -50
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +40 -0
- package/dist/src/core/policy-proposal.js +37 -16
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +59 -11
- package/dist/src/core/sender-identity.js +79 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +166 -0
- package/dist/src/serve/archive.js +363 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +768 -0
- package/docs/cli-reference.md +2139 -67
- package/package.json +4 -2
- package/schema/event.schema.json +413 -30
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +16 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +102 -6
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `approval serve` — the agent-facing surface over HTTP, for a harness that
|
|
3
|
+
* has no local log and no local policy (APRV-421).
|
|
4
|
+
*
|
|
5
|
+
* A tenant whose harness runs in a sandbox (Agent Village's Hermes tenants,
|
|
6
|
+
* the Meta Muse connector packet) has nowhere for `approval hook <harness>` to
|
|
7
|
+
* write and nothing for it to read. The hosted daemon runs elsewhere, one
|
|
8
|
+
* process per tenant (design/hosted-daemon-identity.md); what was missing was a
|
|
9
|
+
* way to reach it. This is that door, and it is a DOOR rather than a second
|
|
10
|
+
* gate.
|
|
11
|
+
*
|
|
12
|
+
* ## Transport only, and the file says so by importing rather than repeating
|
|
13
|
+
*
|
|
14
|
+
* Every verdict, every verb and every batch of records here comes from the
|
|
15
|
+
* function the CLI dispatches to:
|
|
16
|
+
*
|
|
17
|
+
* - the verb surface is `mcp/server.ts`'s — {@link publishedVerbs},
|
|
18
|
+
* {@link toolDefinitions}, {@link buildArgv} and {@link invokeVerb}, so the
|
|
19
|
+
* catalog MATCHES `approval mcp serve` because it IS `approval mcp serve`'s,
|
|
20
|
+
* `--as` is absent from every published schema for the same one reason, and
|
|
21
|
+
* the launch identity is appended last by the same one line;
|
|
22
|
+
* - a hook call is `commandHook` with the request body as its stdin, so the
|
|
23
|
+
* bytes on the response are the bytes the stdin form prints, for every
|
|
24
|
+
* adapter in `HARNESS_ADAPTERS`, with no dialect knowledge in this file;
|
|
25
|
+
* - a follow page is `core/log-subscribe.ts`'s verified subscription, drained
|
|
26
|
+
* once (`serve/follow.ts`).
|
|
27
|
+
*
|
|
28
|
+
* Nothing here classifies a command, resolves a policy, mints a token or
|
|
29
|
+
* verifies a chain. Two implementations of the gate sequence would be two
|
|
30
|
+
* gates, and the second would be the one nobody reviewed.
|
|
31
|
+
*
|
|
32
|
+
* ## Two credentials, two parties
|
|
33
|
+
*
|
|
34
|
+
* `serve/credentials.ts` holds the reasoning. The short form: the agent
|
|
35
|
+
* credential opens the verbs and the hook, the tenant credential opens the
|
|
36
|
+
* log, the export and status, and the agent credential never reads the log it
|
|
37
|
+
* is judged by.
|
|
38
|
+
*
|
|
39
|
+
* ## Which daemon wrote it (APRV-448)
|
|
40
|
+
*
|
|
41
|
+
* Every record this process appends carries the `daemon` field, with the id
|
|
42
|
+
* `approval status` reports for the same store: `APPROVAL_DAEMON_ID` from the
|
|
43
|
+
* launch environment, or the id derived from the store's instance, resolved
|
|
44
|
+
* ONCE at startup by `core/daemon-host.ts`'s `resolveDaemonId`, the function
|
|
45
|
+
* the daemon loop resolves its own id with. The listener's thread declares it
|
|
46
|
+
* before it binds, and every hook thread declares the same resolution before
|
|
47
|
+
* its first job (`serve/hook-worker.ts`), so a verb's records and a hook's
|
|
48
|
+
* carry one id. No request names any part of it.
|
|
49
|
+
*
|
|
50
|
+
* The attested policy's `daemons` allowlist is enforced at the write boundary
|
|
51
|
+
* exactly as it is for the daemon: refreshed from the verified log inside the
|
|
52
|
+
* store lock before every verb call and every hook call, and an id the list
|
|
53
|
+
* does not admit is refused `daemon-not-allowed` with nothing written. This
|
|
54
|
+
* process does NOT mark itself the daemon (`core/daemon-actor.ts`): that mark
|
|
55
|
+
* routes an advance under the daemon's own autonomous class, and a process that
|
|
56
|
+
* dispatches the verbs an agent asks for must never hold it.
|
|
57
|
+
*
|
|
58
|
+
* ## What it does not do
|
|
59
|
+
*
|
|
60
|
+
* - **No TLS.** There is no certificate handling in this process and there is
|
|
61
|
+
* not going to be: the supported deployment is a loopback bind behind a
|
|
62
|
+
* proxy the operator owns, and a non-loopback bind requires an explicit flag
|
|
63
|
+
* and prints a banner (`cli/serve.ts`).
|
|
64
|
+
* - **No record of its own.** This server appends nothing on its own account.
|
|
65
|
+
* Every event in the log under it was written by a verb a caller asked for,
|
|
66
|
+
* under the identity the operator fixed at launch.
|
|
67
|
+
* - **No state a restart loses.** The only things held between requests are
|
|
68
|
+
* the store lock and a few warm hook threads, which are properties of the
|
|
69
|
+
* process rather than of any caller. Follow cursors belong to the caller, so
|
|
70
|
+
* a host that sleeps and wakes serves the same next page it would have served
|
|
71
|
+
* before.
|
|
72
|
+
*
|
|
73
|
+
* ## What is serialised, and what is not (APRV-427)
|
|
74
|
+
*
|
|
75
|
+
* One lock per store ({@link storeLock}). It covers every stretch of work in
|
|
76
|
+
* this process that may append to the store or must read it as one snapshot:
|
|
77
|
+
* a verb call, a follow page, the export, and a hook call's MUTATION sections
|
|
78
|
+
* (everything up to its poll loop, and everything after it). It does not cover
|
|
79
|
+
* a hook call's WAIT: the call runs on a worker thread (`serve/hook-thread.ts`)
|
|
80
|
+
* and hands the lock back for as long as it polls, so one question waiting on
|
|
81
|
+
* a human no longer holds the tenant's `status`, `queue` and follow, or the
|
|
82
|
+
* next hook call's own question, until the human answers. The catalog takes no
|
|
83
|
+
* lock at all. Across processes the guarantee is unchanged and is not this
|
|
84
|
+
* lock's: every append goes through `core/log.ts`'s lockfile and
|
|
85
|
+
* compare-and-append.
|
|
86
|
+
* - **No `.approval/env`.** SPEC.md §11.1 invariant 7, inherited verbatim from
|
|
87
|
+
* the MCP server: the environment a gate operation runs under is the one the
|
|
88
|
+
* host launched this process with.
|
|
89
|
+
*/
|
|
90
|
+
import { createServer, } from "node:http";
|
|
91
|
+
import { chmodSync, lstatSync, realpathSync, statSync, unlinkSync } from "node:fs";
|
|
92
|
+
import { connect } from "node:net";
|
|
93
|
+
import { basename, dirname, resolve } from "node:path";
|
|
94
|
+
import { StringDecoder } from "node:string_decoder";
|
|
95
|
+
import { effectiveHarnessCapMs, harnessBlockDirective, HARNESS_ADAPTERS } from "../cli/hook.js";
|
|
96
|
+
import { DEFAULT_LOG_PATH, resolvePath } from "../cli/paths.js";
|
|
97
|
+
import {} from "../cli/verb-registry.js";
|
|
98
|
+
import { isDaemonProcess } from "../core/daemon-actor.js";
|
|
99
|
+
import { declareResolvedDaemonIdentity, refreshDaemonAllowlistFrom, resolveDaemonId, } from "../core/daemon-host.js";
|
|
100
|
+
import { clearDaemonIdentity, daemonIdentity } from "../core/daemon-identity.js";
|
|
101
|
+
import { isHarnessKind } from "../core/harness-version.js";
|
|
102
|
+
import { withAppendLock } from "../core/log.js";
|
|
103
|
+
import { parseDuration } from "../core/policy-load.js";
|
|
104
|
+
import { buildArgv, invokeVerb, publishedVerbs, serializer, toolDefinitions, toolName, } from "../mcp/server.js";
|
|
105
|
+
import { buildStoreArchive, ExportHardLinkError, ExportSymlinkError } from "./archive.js";
|
|
106
|
+
import { checkVerbArguments } from "./arguments.js";
|
|
107
|
+
import { DEFAULT_HOOK_QUEUE, DEFAULT_HOOK_THREADS, HookCancelledError, HookSaturatedError, hookThreads, } from "./hook-thread.js";
|
|
108
|
+
/**
|
|
109
|
+
* Identity is resolved by the MCP server's own function, re-exported here so
|
|
110
|
+
* that `cli/serve.ts` reaches it through ITS server exactly as `cli/mcp.ts`
|
|
111
|
+
* reaches it through the MCP one.
|
|
112
|
+
*
|
|
113
|
+
* Two transports fixing an identity by two rules would be two answers to
|
|
114
|
+
* "which identities may this process act as", and the answer has to be one:
|
|
115
|
+
* `agent:<id>` and nothing else, refused at startup, never supplied by a
|
|
116
|
+
* caller.
|
|
117
|
+
*/
|
|
118
|
+
export { resolveAgentActor } from "../mcp/server.js";
|
|
119
|
+
import { bearerOf } from "./credentials.js";
|
|
120
|
+
import { DEFAULT_FOLLOW_LIMIT, followFailureExit, MAX_FOLLOW_LIMIT, followPage, } from "./follow.js";
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Limits
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/** Largest request body accepted. A tool call and a hook envelope are small. */
|
|
125
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
126
|
+
/**
|
|
127
|
+
* How much of an oversized body this server will read and throw away before
|
|
128
|
+
* dropping the socket.
|
|
129
|
+
*
|
|
130
|
+
* An oversized request is refused at {@link MAX_BODY_BYTES}, but the refusal
|
|
131
|
+
* cannot be WRITTEN until the client stops writing, or the response closes the
|
|
132
|
+
* socket under it and the caller sees a transport error instead of a verdict.
|
|
133
|
+
* So the remainder is discarded as it arrives, up to this much, and beyond it
|
|
134
|
+
* the connection is simply dropped: a caller streaming without end is not a
|
|
135
|
+
* caller owed a polite answer.
|
|
136
|
+
*/
|
|
137
|
+
export const DRAIN_LIMIT_BYTES = 16 * 1024 * 1024;
|
|
138
|
+
/**
|
|
139
|
+
* How much of a captured stream travels back in a response body.
|
|
140
|
+
*
|
|
141
|
+
* Both streams are bounded, and both carry a flag saying whether they were
|
|
142
|
+
* clipped. Generous enough that a verdict is never clipped in practice, and
|
|
143
|
+
* present so that a verb which decided to print a megabyte cannot make a
|
|
144
|
+
* response unsendable. Clipping happens at a CODEPOINT boundary
|
|
145
|
+
* ({@link clip}), so what comes back is always valid text.
|
|
146
|
+
*/
|
|
147
|
+
export const MAX_STREAM_BYTES = 256 * 1024;
|
|
148
|
+
/**
|
|
149
|
+
* The exit code every server-authored refusal carries.
|
|
150
|
+
*
|
|
151
|
+
* NON-ZERO, always, and this is the finding that made the whole response
|
|
152
|
+
* contract change. A refusal used to travel as an HTTP status with the exit
|
|
153
|
+
* code in a header that refusals did not set, so a client following the
|
|
154
|
+
* documented rule — write stdout, exit `x-approval-exit-code` — read a missing
|
|
155
|
+
* header as zero, and zero is ALLOW on Claude Code, Cursor, Codex and Muse. A
|
|
156
|
+
* 413 on an oversized hook envelope was therefore an allow. Nothing on this
|
|
157
|
+
* surface may be able to turn a refusal into a permission, so the exit code is
|
|
158
|
+
* in the body, it is always present, and on a refusal it is always this.
|
|
159
|
+
*/
|
|
160
|
+
export const REFUSAL_EXIT_CODE = 2;
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// The route table
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
/**
|
|
165
|
+
* The verbs the AGENT credential may call. An ALLOWLIST, and the direction is
|
|
166
|
+
* the whole of it.
|
|
167
|
+
*
|
|
168
|
+
* The catalog is the registry's and does not change (`mcp/server.ts` owns it,
|
|
169
|
+
* and this surface publishes exactly what `approval mcp serve` publishes).
|
|
170
|
+
* This is authorization, which is a different question asked one layer later,
|
|
171
|
+
* and it is asked per verb.
|
|
172
|
+
*
|
|
173
|
+
* ## Why a list of what a harness MAY do
|
|
174
|
+
*
|
|
175
|
+
* The threat model this transport exists for is a sandboxed harness on another
|
|
176
|
+
* machine, not an operator at their own laptop. `approval mcp serve` is run BY
|
|
177
|
+
* the operator, on the operator's box, over a pipe the operator handed it; its
|
|
178
|
+
* whole catalog is reachable there because the party at the other end is the
|
|
179
|
+
* party who started it. Here the party at the other end is the party under
|
|
180
|
+
* oversight, and the operative rule is that it never reads the log it is
|
|
181
|
+
* judged by, nor spends the host's credentials, nor runs argv on the host.
|
|
182
|
+
*
|
|
183
|
+
* A denylist would have to name `log tail`, `log export`, `queue`, `coverage`,
|
|
184
|
+
* `doctor`, every `adapter <name>`, `run`, `sandbox`, `token`, `reindex`,
|
|
185
|
+
* `render` and every verb that lands next. This list names what a harness
|
|
186
|
+
* under oversight needs in order to ASK and to ACT ON a grant, so a verb added
|
|
187
|
+
* to the registry tomorrow is TENANT-scoped until somebody decides otherwise.
|
|
188
|
+
* Fail closed, per SPEC.md §11, which is the same reasoning `GUEST_VERBS`
|
|
189
|
+
* gives for being positive.
|
|
190
|
+
*
|
|
191
|
+
* What is on it, and why each:
|
|
192
|
+
*
|
|
193
|
+
* - `instructions`, `hook_classify` — read the guide, and find out what the
|
|
194
|
+
* classifier makes of a command. Neither reads the log or the policy file;
|
|
195
|
+
* both tell an agent what it is about to be judged for before it asks.
|
|
196
|
+
* - `request`, `wait`, `withdraw` — ask, wait for the answer, and retract your
|
|
197
|
+
* own question. This is the gate sequence, and it is the reason the agent
|
|
198
|
+
* credential exists.
|
|
199
|
+
* - `propose`, `start` (APRV-445) — the same sequence for an action the agent
|
|
200
|
+
* declares itself, with its payload as a flag value: register-and-request in
|
|
201
|
+
* a class the operator declared by name and opened with `agent_may_request`,
|
|
202
|
+
* and record the execution the grant or the policy authorized. Neither takes
|
|
203
|
+
* a path, and `start` is requester-only.
|
|
204
|
+
* That is the whole list. It is seven verbs, and the shortness is the point: a
|
|
205
|
+
* harness under oversight asks and is answered, and everything else about the
|
|
206
|
+
* gate belongs to the party the gate is for.
|
|
207
|
+
*
|
|
208
|
+
* ## What came OFF this list in review, and why
|
|
209
|
+
*
|
|
210
|
+
* - `register` takes a task-file POSITIONAL, which is a host path. The hook
|
|
211
|
+
* endpoint is a harness's way to register: it synthesises the envelope from
|
|
212
|
+
* the tool call, so nothing is lost and a host path stops being something a
|
|
213
|
+
* remote caller names.
|
|
214
|
+
* - `log_verify` and `gate_status` hand back the log's length, its head hash
|
|
215
|
+
* and the live state of the gate. "Not a record" is not the same as "not
|
|
216
|
+
* about the log": a head hash moving is a decision being recorded, and an
|
|
217
|
+
* open window is the human's current posture toward this very agent.
|
|
218
|
+
* - `policy_check` and `policy_test` answer what the policy does with a class,
|
|
219
|
+
* and `policy_check` names the absolute store path while doing it.
|
|
220
|
+
* - **Both payload builders**, under the rule that a builder is the agent's
|
|
221
|
+
* only if it builds from the request's own arguments and never reaches a
|
|
222
|
+
* host resource. `payload_hash` takes a FILE, and on this transport that
|
|
223
|
+
* file is on the daemon's machine while the caller is not: every path it
|
|
224
|
+
* could name is one the host put there, so answering would be a hash oracle
|
|
225
|
+
* that also files the bytes into the payload store.
|
|
226
|
+
* `payload_agentmail-draft` takes two ids rather than a path, but it reads
|
|
227
|
+
* the draft over HTTPS with `AGENTMAIL_API_KEY` FROM THIS SERVER'S OWN
|
|
228
|
+
* ENVIRONMENT, so a remote caller would be spending the host's credential on
|
|
229
|
+
* ids it chose — the same fault wearing a network instead of a filesystem.
|
|
230
|
+
* The narrow reading was taken on purpose: publishing a builder that should
|
|
231
|
+
* have been withheld is a capability leak, and withholding one that should
|
|
232
|
+
* have been published is a line of configuration.
|
|
233
|
+
*
|
|
234
|
+
* Everything else is the tenant's, including the ones easiest to wave through:
|
|
235
|
+
* `log_tail` and `log_export` return RECORDS, `queue` returns the tenant's
|
|
236
|
+
* pending decisions, `run` and `sandbox` spawn argv on the daemon's machine,
|
|
237
|
+
* every `adapter_<name>` spends vault credentials, and `status` is the
|
|
238
|
+
* tenant's view of the oversight the agent is under. All of them stay
|
|
239
|
+
* PUBLISHED, so the surface is honest about what exists, and all of them
|
|
240
|
+
* answer the tenant credential only.
|
|
241
|
+
*/
|
|
242
|
+
export const AGENT_VERBS = new Set([
|
|
243
|
+
"instructions",
|
|
244
|
+
"hook_classify",
|
|
245
|
+
"request",
|
|
246
|
+
"wait",
|
|
247
|
+
"withdraw",
|
|
248
|
+
"propose",
|
|
249
|
+
"start",
|
|
250
|
+
]);
|
|
251
|
+
/**
|
|
252
|
+
* The frozen refusal vocabulary this server authors itself (SPEC.md §11.1
|
|
253
|
+
* invariant 6).
|
|
254
|
+
*
|
|
255
|
+
* A refusal a VERB produced keeps the verb's own code, unchanged, because it
|
|
256
|
+
* is the CLI's refusal arriving over a different transport; so does a refusal
|
|
257
|
+
* `buildArgv` produced, `mcp-` prefix and all, because that function is the
|
|
258
|
+
* one both transports call and renaming its codes here would make one surface
|
|
259
|
+
* lie about the other. What is listed below is only what this file decides.
|
|
260
|
+
*/
|
|
261
|
+
export const SERVE_REFUSAL_CODES = [
|
|
262
|
+
"serve-unauthorized",
|
|
263
|
+
"serve-agent-forbidden",
|
|
264
|
+
"serve-tenant-forbidden",
|
|
265
|
+
"serve-unknown-path",
|
|
266
|
+
"serve-malformed-url",
|
|
267
|
+
"serve-unknown-verb",
|
|
268
|
+
"serve-unknown-harness",
|
|
269
|
+
"serve-method-not-allowed",
|
|
270
|
+
"serve-body-too-large",
|
|
271
|
+
"serve-body-unreadable",
|
|
272
|
+
"serve-invalid-cursor",
|
|
273
|
+
"serve-path-pinned",
|
|
274
|
+
"serve-path-outside-store",
|
|
275
|
+
"serve-positional-flag",
|
|
276
|
+
"serve-flag-not-permitted",
|
|
277
|
+
"serve-export-symlink",
|
|
278
|
+
"serve-export-hardlink",
|
|
279
|
+
"serve-export-failed",
|
|
280
|
+
"serve-hook-failed",
|
|
281
|
+
"serve-hook-saturated",
|
|
282
|
+
];
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Small HTTP helpers
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
/**
|
|
287
|
+
* A refusal, always as a RESULT BODY, and always carrying an exit code.
|
|
288
|
+
*
|
|
289
|
+
* Never a bare HTTP error: the status is a hint for the plumbing between here
|
|
290
|
+
* and the caller, and the `{"error":{"code","message"}}` object is the answer.
|
|
291
|
+
* A client branches on `error.code` exactly as it would on the CLI's stderr,
|
|
292
|
+
* and every code it can see is either this file's (above), a verb's own, or
|
|
293
|
+
* the subscription's.
|
|
294
|
+
*
|
|
295
|
+
* `exit_code` is {@link REFUSAL_EXIT_CODE} on every one of them, and that is
|
|
296
|
+
* the review finding this shape exists for. A client's rule is "write
|
|
297
|
+
* `stdout`, exit `exit_code`"; if a refusal carried no exit code, the client's
|
|
298
|
+
* default would be zero, and zero is ALLOW on four of the six harness
|
|
299
|
+
* dialects. A refusal must never be readable as a permission, so it carries a
|
|
300
|
+
* blocking code as data rather than leaving one to be inferred.
|
|
301
|
+
*/
|
|
302
|
+
function refuse(res, status, code, message, extra = {}) {
|
|
303
|
+
send(res, status, JSON.stringify({ error: { code, message }, exit_code: REFUSAL_EXIT_CODE, ...extra }), { "content-type": "application/json" });
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* A refusal on the HOOK route, spoken in the harness's own dialect.
|
|
307
|
+
*
|
|
308
|
+
* Same body as {@link refuse}, plus `stdout` carrying the block directive
|
|
309
|
+
* `approval hook <harness>` itself prints. A client that writes `stdout` and
|
|
310
|
+
* exits `exit_code` therefore blocks on BOTH halves of every dialect: the body
|
|
311
|
+
* blocks Claude Code, Cursor, Codex and Muse, and the exit code blocks Grok
|
|
312
|
+
* Build and Hermes. A client that instead branches on `error.code` sees the
|
|
313
|
+
* refusal for what it is.
|
|
314
|
+
*
|
|
315
|
+
* Where the harness is not known — an unroutable path, an unrecognised name, a
|
|
316
|
+
* credential that failed before the URL was parsed — there is no dialect to
|
|
317
|
+
* speak and `stdout` is empty. That case is why `docs/cli-reference.md` states
|
|
318
|
+
* the client rule as it does: a missing or unparseable body is a BLOCK.
|
|
319
|
+
*/
|
|
320
|
+
function refuseHook(res, status, harness, code, message) {
|
|
321
|
+
const directive = harnessBlockDirective(code, message, harness);
|
|
322
|
+
refuse(res, status, code, message, {
|
|
323
|
+
stdout: directive.stdout,
|
|
324
|
+
stderr: "",
|
|
325
|
+
stdout_truncated: false,
|
|
326
|
+
stderr_truncated: false,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function send(res, status, body, headers = {}) {
|
|
330
|
+
const bytes = typeof body === "string" ? Buffer.from(body, "utf8") : body;
|
|
331
|
+
res.writeHead(status, { ...headers, "content-length": bytes.length });
|
|
332
|
+
res.end(bytes);
|
|
333
|
+
}
|
|
334
|
+
/** Read one request body, bounded by {@link MAX_BODY_BYTES}. */
|
|
335
|
+
async function readBody(req) {
|
|
336
|
+
const chunks = [];
|
|
337
|
+
let size = 0;
|
|
338
|
+
let oversized = false;
|
|
339
|
+
try {
|
|
340
|
+
for await (const chunk of req) {
|
|
341
|
+
const buffer = chunk;
|
|
342
|
+
size += buffer.length;
|
|
343
|
+
if (size > MAX_BODY_BYTES) {
|
|
344
|
+
// DO NOT return here. The client is still uploading, and a response
|
|
345
|
+
// written while it writes closes the socket under it: the caller then
|
|
346
|
+
// sees ECONNRESET instead of the 413, and a hook client that got a
|
|
347
|
+
// transport error rather than a refusal has no verdict to read. So the
|
|
348
|
+
// rest is DISCARDED as it arrives — never buffered, so the memory is
|
|
349
|
+
// bounded by one chunk — and the refusal is written once the client
|
|
350
|
+
// has finished speaking.
|
|
351
|
+
//
|
|
352
|
+
// Bounded twice over: the drain stops at DRAIN_LIMIT_BYTES, after
|
|
353
|
+
// which the socket is dropped, so a caller that streams forever is not
|
|
354
|
+
// a caller this loop follows forever.
|
|
355
|
+
oversized = true;
|
|
356
|
+
chunks.length = 0;
|
|
357
|
+
if (size > DRAIN_LIMIT_BYTES) {
|
|
358
|
+
req.destroy();
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
chunks.push(buffer);
|
|
364
|
+
}
|
|
365
|
+
if (oversized) {
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
status: 413,
|
|
369
|
+
code: "serve-body-too-large",
|
|
370
|
+
message: `request body exceeds ${String(MAX_BODY_BYTES)} bytes`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch (cause) {
|
|
375
|
+
if (oversized) {
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
status: 413,
|
|
379
|
+
code: "serve-body-too-large",
|
|
380
|
+
message: `request body exceeds ${String(MAX_BODY_BYTES)} bytes`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
status: 400,
|
|
386
|
+
code: "serve-body-unreadable",
|
|
387
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
return { ok: true, text: Buffer.concat(chunks).toString("utf8") };
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* One captured stream, bounded and clipped at a CODEPOINT boundary.
|
|
394
|
+
*
|
|
395
|
+
* A naive byte slice can cut a multi-byte sequence in half and produce a
|
|
396
|
+
* string that is not valid text, which for a JSON body means either a
|
|
397
|
+
* replacement character where a verdict had a word or, worse, a body a strict
|
|
398
|
+
* client refuses to parse. `StringDecoder` emits only the complete codepoints
|
|
399
|
+
* in what it was given and keeps the incomplete tail to itself, which is
|
|
400
|
+
* exactly the clip wanted here.
|
|
401
|
+
*/
|
|
402
|
+
export function clip(text) {
|
|
403
|
+
const bytes = Buffer.from(text, "utf8");
|
|
404
|
+
if (bytes.length <= MAX_STREAM_BYTES)
|
|
405
|
+
return { text, truncated: false };
|
|
406
|
+
return {
|
|
407
|
+
text: new StringDecoder("utf8").write(bytes.subarray(0, MAX_STREAM_BYTES)),
|
|
408
|
+
truncated: true,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The ONE body shape a verb call and a hook call answer with.
|
|
413
|
+
*
|
|
414
|
+
* The exit code and both streams, exactly as the CLI produced them. A caller
|
|
415
|
+
* reproducing the invocation locally writes `stdout`, writes `stderr`, and
|
|
416
|
+
* exits `exit_code`; a caller that wants the verb's machine-readable refusal
|
|
417
|
+
* parses `stderr`, which is the stream the CLI prints refusals on.
|
|
418
|
+
*
|
|
419
|
+
* The alternative — unwrapping the verb's own JSON object into the response —
|
|
420
|
+
* was what this surface did before review, and it had two faults. It put the
|
|
421
|
+
* exit code in a header that refusals forgot to set, and it made a verb that
|
|
422
|
+
* printed something unexpected into a transport-level failure with its own
|
|
423
|
+
* invented code. Returning the streams says less and cannot be wrong about it.
|
|
424
|
+
*/
|
|
425
|
+
function streamsBody(result) {
|
|
426
|
+
const out = clip(result.stdout);
|
|
427
|
+
const err = clip(result.stderr);
|
|
428
|
+
return JSON.stringify({
|
|
429
|
+
exit_code: result.code,
|
|
430
|
+
stdout: out.text,
|
|
431
|
+
stderr: err.text,
|
|
432
|
+
stdout_truncated: out.truncated,
|
|
433
|
+
stderr_truncated: err.truncated,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The published surface: `approval mcp serve`'s tool list, with each entry's
|
|
438
|
+
* scope stated.
|
|
439
|
+
*
|
|
440
|
+
* Derived, not restated. A verb added to the registry tomorrow appears here
|
|
441
|
+
* the same day it appears as an MCP tool, with `--as` already deleted from its
|
|
442
|
+
* schema by the same function, and `grant` is absent for the same reason it is
|
|
443
|
+
* absent there — the registry marks it `human_only` and no transport publishes
|
|
444
|
+
* a human's authority. Its SCOPE is tenant until somebody puts it on
|
|
445
|
+
* {@link AGENT_VERBS}, which is the fail-closed half of the same derivation.
|
|
446
|
+
*/
|
|
447
|
+
export function serveCatalog() {
|
|
448
|
+
return toolDefinitions().map((tool) => ({
|
|
449
|
+
name: tool.name,
|
|
450
|
+
title: tool.title ?? tool.name,
|
|
451
|
+
description: tool.description ?? "",
|
|
452
|
+
inputSchema: tool.inputSchema,
|
|
453
|
+
scope: AGENT_VERBS.has(tool.name) ? "agent" : "tenant",
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
/** Every name this surface publishes, for the scope rule below. */
|
|
457
|
+
const PUBLISHED_TOOL_NAMES = new Set(publishedVerbs().map((spec) => toolName(spec)));
|
|
458
|
+
/**
|
|
459
|
+
* The flags every hook call is made with.
|
|
460
|
+
*
|
|
461
|
+
* Exported so a test can hand the SAME argv to `commandHook` directly and
|
|
462
|
+
* compare the bytes. That comparison is the acceptance criterion, and it is
|
|
463
|
+
* only honest if both sides are the same invocation of the same verb: a test
|
|
464
|
+
* that built its own flag list would be comparing two spellings.
|
|
465
|
+
*
|
|
466
|
+
* `--as` is appended LAST, as everywhere else on this surface, so the identity
|
|
467
|
+
* the operator fixed when they started the process wins over anything that
|
|
468
|
+
* arrived by another route (`parseFlags` keeps the last occurrence).
|
|
469
|
+
*/
|
|
470
|
+
export function hookArgv(options) {
|
|
471
|
+
return [
|
|
472
|
+
"--dir",
|
|
473
|
+
options.cwd,
|
|
474
|
+
...(options.log === undefined ? [] : ["--log", options.log]),
|
|
475
|
+
...(options.policy === undefined ? [] : ["--policy", options.policy]),
|
|
476
|
+
...(options.hookTimeout === undefined ? [] : ["--timeout", options.hookTimeout]),
|
|
477
|
+
...(options.hookHarnessCap === undefined
|
|
478
|
+
? []
|
|
479
|
+
: ["--harness-cap", options.hookHarnessCap]),
|
|
480
|
+
"--as",
|
|
481
|
+
options.actor,
|
|
482
|
+
];
|
|
483
|
+
}
|
|
484
|
+
/** The route a path names, or null. Method is checked by the handler. */
|
|
485
|
+
export function routeOf(pathname) {
|
|
486
|
+
if (pathname === "/verbs")
|
|
487
|
+
return { kind: "catalog" };
|
|
488
|
+
if (pathname === "/log/follow")
|
|
489
|
+
return { kind: "follow" };
|
|
490
|
+
if (pathname === "/export")
|
|
491
|
+
return { kind: "export" };
|
|
492
|
+
if (pathname === "/status")
|
|
493
|
+
return { kind: "status" };
|
|
494
|
+
const verb = /^\/verb\/(?<name>[a-z0-9_-]+)$/u.exec(pathname);
|
|
495
|
+
if (verb?.groups !== undefined)
|
|
496
|
+
return { kind: "verb", name: verb.groups["name"] };
|
|
497
|
+
const hook = /^\/hook\/(?<harness>[a-z0-9-]+)$/u.exec(pathname);
|
|
498
|
+
if (hook?.groups !== undefined) {
|
|
499
|
+
return { kind: "hook", harness: hook.groups["harness"] };
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Which credential opens a route.
|
|
505
|
+
*
|
|
506
|
+
* `null` means either one does, which is true of the catalog alone: it is the
|
|
507
|
+
* published contract, it reads no log and it appends nothing, and both parties
|
|
508
|
+
* have a reason to ask what this server is.
|
|
509
|
+
*/
|
|
510
|
+
export function scopeOf(route) {
|
|
511
|
+
switch (route.kind) {
|
|
512
|
+
case "catalog":
|
|
513
|
+
return null;
|
|
514
|
+
case "follow":
|
|
515
|
+
case "export":
|
|
516
|
+
case "status":
|
|
517
|
+
return "tenant";
|
|
518
|
+
case "hook":
|
|
519
|
+
return "agent";
|
|
520
|
+
default:
|
|
521
|
+
// Three answers, and the third is the one worth reading twice.
|
|
522
|
+
//
|
|
523
|
+
// On the allowlist: the agent's. Published and not on it: the tenant's,
|
|
524
|
+
// which is where a verb added to the registry tomorrow lands.
|
|
525
|
+
//
|
|
526
|
+
// NOT PUBLISHED AT ALL: the agent's, so the TENANT credential is refused
|
|
527
|
+
// rather than falling through to a not-found. The names in that third
|
|
528
|
+
// case are the verbs `mcp/server.ts` withholds for TRANSPORT reasons —
|
|
529
|
+
// `consume` above all, which is the token spend, an agent-side act that
|
|
530
|
+
// `run` wraps. The tenant credential must not reach it under any
|
|
531
|
+
// spelling, and a caller that guessed a path is owed the true fact about
|
|
532
|
+
// itself rather than a probe that answers differently. The agent
|
|
533
|
+
// credential reaches the same arm and gets `serve-unknown-verb`, which
|
|
534
|
+
// is also true: the verb is not on this surface.
|
|
535
|
+
if (AGENT_VERBS.has(route.name))
|
|
536
|
+
return "agent";
|
|
537
|
+
return PUBLISHED_TOOL_NAMES.has(route.name) ? "tenant" : "agent";
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
// The listener
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
/**
|
|
544
|
+
* Start the listener. Resolves once it is bound and its real port is known;
|
|
545
|
+
* rejects when the bind fails, so the CLI can report it and exit.
|
|
546
|
+
*/
|
|
547
|
+
export async function serveApproval(options) {
|
|
548
|
+
const host = options.host ?? "127.0.0.1";
|
|
549
|
+
const notice = options.notice ?? (() => { });
|
|
550
|
+
// ALWAYS pinned, on every verb call and in every scope, whether or not the
|
|
551
|
+
// operator named them (review finding 3). One process serves one store, and
|
|
552
|
+
// before this these three were injected only when the operator had passed
|
|
553
|
+
// them and `--dir` never was — so a published `--log` from a caller was the
|
|
554
|
+
// store the verb actually read. `buildArgv` appends these LAST, so a
|
|
555
|
+
// caller's value loses even if the guard above somehow let one through.
|
|
556
|
+
const paths = {
|
|
557
|
+
actor: options.actor,
|
|
558
|
+
cwd: options.cwd,
|
|
559
|
+
dir: options.cwd,
|
|
560
|
+
log: options.log ?? logPathOf(options.cwd),
|
|
561
|
+
// `--policy` names a FILE, so it is pinned only where the operator named
|
|
562
|
+
// one. Where they did not, `--dir` above is the pin: the CLI resolves the
|
|
563
|
+
// policy from that directory, which is exactly "the launch configuration's
|
|
564
|
+
// policy". Pinning a directory here would hand every verb a path it would
|
|
565
|
+
// try to read as a policy document.
|
|
566
|
+
...(options.policy === undefined ? {} : { policy: options.policy }),
|
|
567
|
+
};
|
|
568
|
+
// The store's lock (APRV-427): every verb call, follow page and export takes
|
|
569
|
+
// it whole, and a hook call takes it for its mutation sections only. See the
|
|
570
|
+
// header's "What is serialised" for the scope, and `storeLock` for why it is
|
|
571
|
+
// keyed by store.
|
|
572
|
+
const storeLog = paths.log ?? logPathOf(options.cwd);
|
|
573
|
+
const serialize = storeLock(storeLog, options.cwd);
|
|
574
|
+
// APRV-448: which daemon this process writes as, resolved once by the
|
|
575
|
+
// function the daemon loop uses and declared before anything can append. The
|
|
576
|
+
// CLI refused an unusable `APPROVAL_DAEMON_ID` before it got here; an embedder
|
|
577
|
+
// that did not is refused here, before the bind.
|
|
578
|
+
const daemon = resolveDaemonId(storeLog, options.env ?? process.env);
|
|
579
|
+
if (!daemon.ok)
|
|
580
|
+
throw new Error(daemon.message);
|
|
581
|
+
const daemonId = daemon.id;
|
|
582
|
+
// The declaration is the PROCESS's, so one process writes as one daemon
|
|
583
|
+
// (APRV-448 review). A second listener for a store whose id differs from the
|
|
584
|
+
// one already declared here (by another listener, or by a daemon loop in the
|
|
585
|
+
// same process) would restamp the first store's records with the second's
|
|
586
|
+
// id; it is refused before it binds instead.
|
|
587
|
+
const prior = daemonIdentity();
|
|
588
|
+
if (prior !== null && prior.id !== null && prior.id !== daemonId) {
|
|
589
|
+
throw new Error(`this process already writes as daemon ${prior.id}, and this store's id is ${daemonId}: one process stamps one daemon id, so serve a second store from its own process`);
|
|
590
|
+
}
|
|
591
|
+
declareResolvedDaemonIdentity(daemon);
|
|
592
|
+
// The store's policy, located exactly as the verbs locate it: the pinned file
|
|
593
|
+
// when the operator named one, otherwise discovered from the store root.
|
|
594
|
+
const policyWhere = options.policy === undefined ? { dir: options.cwd } : { file: options.policy };
|
|
595
|
+
/** Put the attested `daemons` list in force; called inside the store lock. */
|
|
596
|
+
const refreshAllowlist = () => {
|
|
597
|
+
refreshDaemonAllowlistFrom(storeLog, policyWhere);
|
|
598
|
+
};
|
|
599
|
+
refreshAllowlist();
|
|
600
|
+
// Hook calls run on worker threads, because a hook's wait is a synchronous
|
|
601
|
+
// `Atomics.wait` and on this thread it would stop the listener itself. Each
|
|
602
|
+
// thread declares the resolution above as its own (`serve/hook-worker.ts`).
|
|
603
|
+
const hooks = hookThreads(serialize, { daemon, policy: policyWhere }, {
|
|
604
|
+
threads: options.hookThreads ?? DEFAULT_HOOK_THREADS,
|
|
605
|
+
queue: options.hookQueue ?? DEFAULT_HOOK_QUEUE,
|
|
606
|
+
});
|
|
607
|
+
const byName = new Map(publishedVerbs().map((spec) => [toolName(spec), spec]));
|
|
608
|
+
const sockets = new Set();
|
|
609
|
+
let requests = 0;
|
|
610
|
+
let closing = false;
|
|
611
|
+
async function handleVerb(res, spec, args, scope) {
|
|
612
|
+
// Every scope, before the argv is built: no caller names a store, no
|
|
613
|
+
// caller names a path outside the one this process serves, and no
|
|
614
|
+
// positional arrives wearing a dash.
|
|
615
|
+
const checked = checkVerbArguments(spec, args, options.cwd, options.cwd, scope);
|
|
616
|
+
if (!checked.ok) {
|
|
617
|
+
refuse(res, 403, checked.code, checked.message);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const built = buildArgv(spec, args, paths);
|
|
621
|
+
if (!built.ok) {
|
|
622
|
+
refuse(res, 400, built.code, built.message);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const result = await serialize(() => {
|
|
626
|
+
// The allowlist this call's appends are judged against, resolved inside
|
|
627
|
+
// the lock from the verified log and the attested policy (APRV-448).
|
|
628
|
+
refreshAllowlist();
|
|
629
|
+
return invokeVerb(spec, built.argv, paths);
|
|
630
|
+
});
|
|
631
|
+
// The CLI's own streams and the CLI's own exit code. A refusal arrives as
|
|
632
|
+
// the `{"error":{...}}` the verb printed, on the stream it printed it on.
|
|
633
|
+
send(res, 200, streamsBody(result), { "content-type": "application/json" });
|
|
634
|
+
}
|
|
635
|
+
/** `--hook-harness-cap`, parsed once; the CLI has already refused a bad one. */
|
|
636
|
+
const statedHookCapMs = options.hookHarnessCap === undefined ? null : parseDuration(options.hookHarnessCap);
|
|
637
|
+
async function handleHook(res, harness, body, arrivedAt) {
|
|
638
|
+
// The same function `main()` dispatches to (`commandHook`, on a worker
|
|
639
|
+
// thread), with the request body as the stdin the verb would have read.
|
|
640
|
+
// The verdict object, its dialect, its reason and its exit code are decided
|
|
641
|
+
// entirely inside it; the thread changes where it runs and which stretches
|
|
642
|
+
// of it hold the store lock, and nothing about what it answers.
|
|
643
|
+
//
|
|
644
|
+
// A client that goes away before its answer (APRV-427 review) aborts the
|
|
645
|
+
// call: in line it leaves the line, and on its thread it stops at the next
|
|
646
|
+
// poll tick WITHOUT spending a grant or withdrawing the question, so the
|
|
647
|
+
// harness's retry adopts one or the other. Nothing is sent after that,
|
|
648
|
+
// and nothing is sent once the listener is closing either: the socket is
|
|
649
|
+
// about to be destroyed, and a caller with no answer blocks.
|
|
650
|
+
const gone = new AbortController();
|
|
651
|
+
res.once("close", () => {
|
|
652
|
+
if (!res.writableFinished)
|
|
653
|
+
gone.abort();
|
|
654
|
+
});
|
|
655
|
+
// The listener's own list, brought up to date before the call is handed to
|
|
656
|
+
// a thread (APRV-448 review): the thread starts no wider than it.
|
|
657
|
+
refreshAllowlist();
|
|
658
|
+
let outcome;
|
|
659
|
+
try {
|
|
660
|
+
outcome = await hooks.run([harness, ...hookArgv(options)], options.cwd, paths.log ?? logPathOf(options.cwd), body, gone.signal,
|
|
661
|
+
// The harness's ceiling runs from ARRIVAL, so the time this call
|
|
662
|
+
// spends in line and waiting for the lock is charged to it (APRV-427
|
|
663
|
+
// review), with the same ceiling the hook itself will compute.
|
|
664
|
+
{ arrivedAt, capMs: effectiveHarnessCapMs(HARNESS_ADAPTERS[harness], statedHookCapMs) });
|
|
665
|
+
}
|
|
666
|
+
catch (cause) {
|
|
667
|
+
if (cause instanceof HookCancelledError || gone.signal.aborted || closing)
|
|
668
|
+
return;
|
|
669
|
+
if (cause instanceof HookSaturatedError) {
|
|
670
|
+
// Every slot and every place in line is taken. Refused at once rather
|
|
671
|
+
// than queued without bound, because each running call is a thread
|
|
672
|
+
// holding its own copy of the log; answered as a block, so the harness
|
|
673
|
+
// does not proceed on a question nobody asked.
|
|
674
|
+
refuseHook(res, 503, harness, "serve-hook-saturated", cause.message);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// The THREAD failed (it was terminated, or it died), which is not a
|
|
678
|
+
// verdict. Answered as a refusal in the harness's own dialect, so a
|
|
679
|
+
// client that writes `stdout` and exits `exit_code` blocks.
|
|
680
|
+
refuseHook(res, 500, harness, "serve-hook-failed", `the hook call did not complete: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// `stdout` is BYTE-FOR-BYTE what `approval hook <harness>` printed, and
|
|
684
|
+
// `exit_code` is the code it exited. Both travel in the body, because a
|
|
685
|
+
// header is a thing a refusal can forget to set and an exit code that can
|
|
686
|
+
// go missing is an allow waiting to happen. `stderr` rides along because
|
|
687
|
+
// Hermes's ALLOW is `{}` and carries its reason there and nowhere else.
|
|
688
|
+
if (gone.signal.aborted || closing)
|
|
689
|
+
return;
|
|
690
|
+
send(res, 200, streamsBody(outcome), { "content-type": "application/json" });
|
|
691
|
+
}
|
|
692
|
+
async function handleFollow(res, url) {
|
|
693
|
+
const fromText = url.searchParams.get("from");
|
|
694
|
+
const hashText = url.searchParams.get("cursor_hash");
|
|
695
|
+
const limitText = url.searchParams.get("limit");
|
|
696
|
+
// Every query parameter is validated BEFORE anything reads a file, and a
|
|
697
|
+
// value out of range is refused rather than clamped. A clamp is a silent
|
|
698
|
+
// answer to a different question than the one asked, and the caller cannot
|
|
699
|
+
// tell it happened.
|
|
700
|
+
if (fromText !== null && !/^\d+$/u.test(fromText)) {
|
|
701
|
+
refuse(res, 400, "serve-invalid-cursor", `from expects a whole number, got ${JSON.stringify(fromText)}`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const from = fromText === null ? 0 : Number(fromText);
|
|
705
|
+
// `99999999999999999999` matches the digits above and is not a safe
|
|
706
|
+
// integer: it reached the subscription, which threw a TypeError, which
|
|
707
|
+
// escaped a GET as a 500. Refused here, in this verb's own vocabulary.
|
|
708
|
+
if (!Number.isSafeInteger(from)) {
|
|
709
|
+
refuse(res, 400, "serve-invalid-cursor", `from ${JSON.stringify(fromText)} is larger than the largest sequence number this runtime can represent`);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (limitText !== null && !/^\d+$/u.test(limitText)) {
|
|
713
|
+
refuse(res, 400, "serve-invalid-cursor", `limit expects a whole number, got ${JSON.stringify(limitText)}`);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const limit = limitText === null ? DEFAULT_FOLLOW_LIMIT : Number(limitText);
|
|
717
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_FOLLOW_LIMIT) {
|
|
718
|
+
refuse(res, 400, "serve-invalid-cursor", `limit expects 1 to ${String(MAX_FOLLOW_LIMIT)}, got ${JSON.stringify(limitText)}`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (hashText !== null && !/^[a-f0-9]{64}$/u.test(hashText)) {
|
|
722
|
+
refuse(res, 400, "serve-invalid-cursor", "cursor_hash expects a lowercase 64-character SHA-256 digest");
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (hashText !== null && from === 0) {
|
|
726
|
+
refuse(res, 400, "serve-invalid-cursor", "cursor_hash requires from greater than zero");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
// A RESUME MUST CARRY ITS HASH. `core/log-subscribe.ts` calls the
|
|
730
|
+
// sequence-only form "a weaker bootstrap ... by design", and it is weaker
|
|
731
|
+
// in exactly one way: it cannot detect a fully recomputed replacement
|
|
732
|
+
// prefix on its FIRST read. In the streaming form that costs one read,
|
|
733
|
+
// because the process then retains the digest it verified and catches any
|
|
734
|
+
// later replacement. Here every request is a first read, so the weakness
|
|
735
|
+
// would be permanent: a replaced prefix would be served silently, forever,
|
|
736
|
+
// to the caller who omitted the hash, while an honest caller who kept it
|
|
737
|
+
// got the refusal. `from=0` is the only hashless form, because replaying
|
|
738
|
+
// from genesis binds nothing and claims nothing.
|
|
739
|
+
if (from > 0 && hashText === null) {
|
|
740
|
+
refuse(res, 400, "serve-invalid-cursor", `from=${String(from)} requires cursor_hash: a resume names the prefix it consumed, and a sequence number alone cannot tell this log from one whose records were recomputed. Send the hash from the cursor of your last page, or from=0 to replay the whole verified log`);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const cursor = { seq: from, hash: hashText };
|
|
744
|
+
// Under the store lock, so no append THIS process makes lands underneath a
|
|
745
|
+
// page: a verified read walks the whole chain, and an append landing under
|
|
746
|
+
// one is a torn read this endpoint would report as an integrity failure of
|
|
747
|
+
// the tenant's own log. A hook call in its wait holds no lock and appends
|
|
748
|
+
// nothing, so it does not delay a page (APRV-427).
|
|
749
|
+
const result = await serialize(() => followPage(options.log ?? logPathOf(options.cwd), cursor, limit));
|
|
750
|
+
if (!result.ok) {
|
|
751
|
+
// The subscription's own terminal failure, with the CLI's own code, the
|
|
752
|
+
// CLI's own message, and NO RECORDS. `reason` carries `cursor-mismatch`
|
|
753
|
+
// where there is one, so the caller need not read English to tell a torn
|
|
754
|
+
// prefix from a cursor that belongs to another log.
|
|
755
|
+
send(res, result.code === "integrity" ? 409 : 500, JSON.stringify({
|
|
756
|
+
error: { code: result.code, message: result.message, reason: result.reason },
|
|
757
|
+
exit_code: followFailureExit(result.code),
|
|
758
|
+
records: [],
|
|
759
|
+
}), { "content-type": "application/json" });
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
send(res, 200, JSON.stringify({ ...result.page, exit_code: 0 }), {
|
|
763
|
+
"content-type": "application/json",
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
async function handleExport(res) {
|
|
767
|
+
// Under the store lock AND the append lock.
|
|
768
|
+
//
|
|
769
|
+
// The store lock is about this process: no verb and no hook's mutation
|
|
770
|
+
// section appends while the snapshot is taken, and `gzipSync` blocks the
|
|
771
|
+
// event loop for as long as the store is large. The APPEND lock is about
|
|
772
|
+
// every other process — a daemon, a CLI
|
|
773
|
+
// run beside this server — because the walk reads the log and the payload
|
|
774
|
+
// store as one snapshot, and an append landing between the two copies a
|
|
775
|
+
// record whose payload bytes are not there yet. `withAppendLock` is the
|
|
776
|
+
// same exclusion `approval log sync` takes for the same reason, and it
|
|
777
|
+
// hands the callback no write primitive: this endpoint gains only the
|
|
778
|
+
// guarantee that nobody is appending while it reads.
|
|
779
|
+
let archive;
|
|
780
|
+
try {
|
|
781
|
+
archive = await serialize(async () => {
|
|
782
|
+
const logPath = options.log ?? logPathOf(options.cwd);
|
|
783
|
+
const locked = withAppendLock(logPath, () => buildStoreArchive(options.cwd, logPath));
|
|
784
|
+
if (!locked.ok)
|
|
785
|
+
throw new Error(locked.error.message);
|
|
786
|
+
return locked.value;
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
catch (cause) {
|
|
790
|
+
if (cause instanceof ExportHardLinkError) {
|
|
791
|
+
refuse(res, 409, "serve-export-hardlink", cause.message, {
|
|
792
|
+
path: cause.path,
|
|
793
|
+
links: cause.links,
|
|
794
|
+
});
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (cause instanceof ExportSymlinkError) {
|
|
798
|
+
// Named, and the whole export refused rather than the link skipped: an
|
|
799
|
+
// archive silently missing a file is an archive nobody can tell from a
|
|
800
|
+
// complete one, and a tenant checking their exit against their own
|
|
801
|
+
// `payload_hash` values would find the gap only by doing the check.
|
|
802
|
+
refuse(res, 409, "serve-export-symlink", cause.message, { path: cause.path });
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
refuse(res, 500, "serve-export-failed", `the store at ${options.cwd} could not be archived: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
send(res, 200, archive.bytes, {
|
|
809
|
+
"content-type": "application/gzip",
|
|
810
|
+
"content-disposition": `attachment; filename="approval-export-${daemonId}.tar.gz"`,
|
|
811
|
+
"x-approval-export-paths": JSON.stringify(archive.paths),
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
const http = createServer((req, res) => {
|
|
815
|
+
// When the call arrived: a hook call's harness budget is charged from here.
|
|
816
|
+
const arrivedAt = Date.now();
|
|
817
|
+
void (async () => {
|
|
818
|
+
requests += 1;
|
|
819
|
+
try {
|
|
820
|
+
// AUTHENTICATED FIRST, before the URL is even parsed. Two reasons, and
|
|
821
|
+
// the second was a review finding: there is no unauthenticated surface
|
|
822
|
+
// here, not a health check and not a 404, so a caller with no
|
|
823
|
+
// credential learns nothing about what exists; and `new URL()` throws
|
|
824
|
+
// on a malformed `Host` header, which used to happen BEFORE this check
|
|
825
|
+
// and handed an unauthenticated caller a 500.
|
|
826
|
+
//
|
|
827
|
+
// Duplicate `Authorization` headers are refused outright rather than
|
|
828
|
+
// resolved. Node keeps the FIRST and discards the rest for this header,
|
|
829
|
+
// so a proxy, a client library or an attacker sending two can make the
|
|
830
|
+
// credential this server checks differ from the one a reader of the
|
|
831
|
+
// request would say was sent. There is no correct way to choose; the
|
|
832
|
+
// request is malformed and is treated as such.
|
|
833
|
+
// WHICH DIALECT this request would be answered in, read off the raw
|
|
834
|
+
// request target before anything else (second review pass, finding 3).
|
|
835
|
+
//
|
|
836
|
+
// It decides the SHAPE OF A REFUSAL and nothing else: no route is
|
|
837
|
+
// taken from it, no scope, no verb. It has to happen here because the
|
|
838
|
+
// two refusals below — a rotated credential, a malformed Host — fire
|
|
839
|
+
// before the URL is parsed, and a hook client that gets a body with no
|
|
840
|
+
// block directive reads exit 0 as ALLOW on four of the six dialects. A
|
|
841
|
+
// credential rotation should not open a gate.
|
|
842
|
+
//
|
|
843
|
+
// It discloses nothing: the path set is documented, the refusal's code
|
|
844
|
+
// and message are unchanged, and a name that is not a harness this
|
|
845
|
+
// runtime speaks for gets the generic body.
|
|
846
|
+
const early = /^\/hook\/(?<harness>[a-z0-9-]+)(?:[/?#]|$)/u.exec(req.url ?? "");
|
|
847
|
+
const earlyHarness = early?.groups !== undefined && isHarnessKind(early.groups["harness"])
|
|
848
|
+
? early.groups["harness"]
|
|
849
|
+
: null;
|
|
850
|
+
const refuseEarly = (status, code, message) => {
|
|
851
|
+
if (earlyHarness === null)
|
|
852
|
+
refuse(res, status, code, message);
|
|
853
|
+
else
|
|
854
|
+
refuseHook(res, status, earlyHarness, code, message);
|
|
855
|
+
};
|
|
856
|
+
const offered = req.rawHeaders.filter((entry, index) => index % 2 === 0 && entry.toLowerCase() === "authorization").length;
|
|
857
|
+
const bearer = offered > 1 ? null : bearerOf(req.headers.authorization);
|
|
858
|
+
const scope = bearer === null ? null : options.credentials.identify(bearer);
|
|
859
|
+
if (scope === null) {
|
|
860
|
+
refuseEarly(401, "serve-unauthorized", offered > 1
|
|
861
|
+
? `this request carries ${String(offered)} Authorization headers. Exactly one is accepted: where there are several, the one this server would check is not the one a reader of the request would name`
|
|
862
|
+
: "no usable Authorization: Bearer <credential>. This server has two credentials, one for the agent surface and one for the tenant surface, and every path requires one of them");
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
let url;
|
|
866
|
+
try {
|
|
867
|
+
url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
// After authentication, so this says nothing to a stranger. The
|
|
871
|
+
// `Host` header is the usual culprit and is named without being
|
|
872
|
+
// echoed back.
|
|
873
|
+
refuseEarly(400, "serve-malformed-url", "the request line and Host header do not form a URL this server can parse");
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const route = routeOf(url.pathname);
|
|
877
|
+
if (route === null) {
|
|
878
|
+
refuse(res, 404, "serve-unknown-path", `no endpoint at ${url.pathname}; this server answers /verbs, /verb/<name>, /hook/<harness>, /log/follow, /export and /status`);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
// On the hook route every refusal from here on is spoken in the
|
|
882
|
+
// harness's own dialect as well as in this server's, so a client that
|
|
883
|
+
// writes `stdout` and exits `exit_code` blocks rather than proceeds.
|
|
884
|
+
const harness = route.kind === "hook" && isHarnessKind(route.harness) ? route.harness : null;
|
|
885
|
+
const deny = (status, code, message) => {
|
|
886
|
+
if (harness === null)
|
|
887
|
+
refuse(res, status, code, message);
|
|
888
|
+
else
|
|
889
|
+
refuseHook(res, status, harness, code, message);
|
|
890
|
+
};
|
|
891
|
+
const required = scopeOf(route);
|
|
892
|
+
if (required !== null && required !== scope) {
|
|
893
|
+
const code = scope === "agent" ? "serve-agent-forbidden" : "serve-tenant-forbidden";
|
|
894
|
+
deny(403, code, scope === "agent"
|
|
895
|
+
? `${url.pathname} answers to the TENANT credential. The agent credential reaches the hook and the verbs a harness under oversight needs in order to ask and to act on a grant; it never reaches the log it is judged by, the store export, the tenant's own status report, or anything that runs on the host`
|
|
896
|
+
: `${url.pathname} answers to the AGENT credential. The tenant credential reaches the log, the export, status and every verb that touches the host; it does not act as the agent, because an action taken with it would be recorded under an identity nobody was acting as`);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const method = req.method ?? "GET";
|
|
900
|
+
const wants = route.kind === "verb" || route.kind === "hook" ? "POST" : "GET";
|
|
901
|
+
if (method !== wants) {
|
|
902
|
+
deny(405, "serve-method-not-allowed", `${url.pathname} answers ${wants}, not ${method}`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (route.kind === "catalog") {
|
|
906
|
+
send(res, 200, JSON.stringify({ actor: options.actor, verbs: serveCatalog() }), {
|
|
907
|
+
"content-type": "application/json",
|
|
908
|
+
});
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (route.kind === "follow") {
|
|
912
|
+
await handleFollow(res, url);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (route.kind === "export") {
|
|
916
|
+
await handleExport(res);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (route.kind === "status") {
|
|
920
|
+
const spec = byName.get("status");
|
|
921
|
+
if (spec === undefined) {
|
|
922
|
+
refuse(res, 500, "serve-unknown-verb", "the registry publishes no `status` verb");
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
await handleVerb(res, spec, {}, scope);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (route.kind === "hook" && harness === null) {
|
|
929
|
+
refuse(res, 404, "serve-unknown-harness", `no hook adapter for ${JSON.stringify(route.harness)}; this runtime speaks ${Object.keys(HARNESS_ADAPTERS).join(", ")}`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const body = await readBody(req);
|
|
933
|
+
if (!body.ok) {
|
|
934
|
+
// The oversized-envelope case the review found: this used to be a
|
|
935
|
+
// 413 with no exit code anywhere, which a client turned into exit 0,
|
|
936
|
+
// and exit 0 is ALLOW on four of the six dialects. It is now a block
|
|
937
|
+
// in the harness's own words.
|
|
938
|
+
deny(body.status, body.code, body.message);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (harness !== null) {
|
|
942
|
+
// NOT wrapped in the store lock: a hook call takes it for its own
|
|
943
|
+
// mutation sections and releases it while it waits (APRV-427).
|
|
944
|
+
await handleHook(res, harness, body.text, arrivedAt);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
// Only the verb route reaches here: the hook arm returned above, and
|
|
948
|
+
// every other route was handled before the body was read.
|
|
949
|
+
if (route.kind !== "verb") {
|
|
950
|
+
refuse(res, 500, "serve-unknown-path", `no handler for ${url.pathname}`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const spec = byName.get(route.name);
|
|
954
|
+
if (spec === undefined) {
|
|
955
|
+
refuse(res, 404, "serve-unknown-verb", `no verb ${JSON.stringify(route.name)} on this surface. GET /verbs lists what is published; a verb that records a human's authority is not among them, and never will be`);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
let args = {};
|
|
959
|
+
if (body.text.trim().length > 0) {
|
|
960
|
+
try {
|
|
961
|
+
args = JSON.parse(body.text);
|
|
962
|
+
}
|
|
963
|
+
catch (cause) {
|
|
964
|
+
refuse(res, 400, "serve-body-unreadable", `request body is not JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
await handleVerb(res, spec, args, scope);
|
|
969
|
+
}
|
|
970
|
+
catch (cause) {
|
|
971
|
+
// A throw out of a handler is still a refusal with a code, because a
|
|
972
|
+
// caller that got a bare 500 would have nothing to branch on.
|
|
973
|
+
if (!res.headersSent) {
|
|
974
|
+
refuse(res, 500, "serve-body-unreadable", `the request failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
res.end();
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
})();
|
|
981
|
+
});
|
|
982
|
+
http.on("connection", (socket) => {
|
|
983
|
+
sockets.add(socket);
|
|
984
|
+
socket.on("close", () => sockets.delete(socket));
|
|
985
|
+
});
|
|
986
|
+
const socketPath = options.socketPath ?? null;
|
|
987
|
+
if (socketPath !== null) {
|
|
988
|
+
const checked = checkUnixSocketTarget(socketPath);
|
|
989
|
+
if (!checked.ok)
|
|
990
|
+
throw new Error(checked.message);
|
|
991
|
+
await clearStaleSocket(socketPath);
|
|
992
|
+
}
|
|
993
|
+
await new Promise((settle, fail) => {
|
|
994
|
+
const onError = (cause) => fail(cause);
|
|
995
|
+
http.once("error", onError);
|
|
996
|
+
const ready = () => {
|
|
997
|
+
http.off("error", onError);
|
|
998
|
+
settle();
|
|
999
|
+
};
|
|
1000
|
+
if (socketPath !== null)
|
|
1001
|
+
http.listen(socketPath, ready);
|
|
1002
|
+
else
|
|
1003
|
+
http.listen(options.port, host, ready);
|
|
1004
|
+
});
|
|
1005
|
+
// APRV-445 refutation (L3). The socket is opened to 0666 only where nobody
|
|
1006
|
+
// but the serving uid can write the directory. Between the bind and the
|
|
1007
|
+
// chmod the socket carries the umask's narrower mode, which fails closed;
|
|
1008
|
+
// what a group- or other-writable directory would add is someone able to
|
|
1009
|
+
// swap the socket inside that window, so there the chmod is skipped and the
|
|
1010
|
+
// socket keeps the umask's mode (the operator widens the umask or the
|
|
1011
|
+
// directory's ACL instead). Other-writable directories are refused outright.
|
|
1012
|
+
const widen = socketPath !== null && (statSync(dirname(socketPath)).mode & 0o022) === 0;
|
|
1013
|
+
if (socketPath !== null && !widen) {
|
|
1014
|
+
notice(`approval: serve left ${socketPath} at its umask mode: its directory is group-writable, and only a directory the serving uid alone can write is trusted to hold a 0666 socket\n`);
|
|
1015
|
+
}
|
|
1016
|
+
if (socketPath !== null && widen) {
|
|
1017
|
+
// The directory is the access control (see `checkUnixSocketTarget`); the
|
|
1018
|
+
// socket itself is opened to every uid that can reach it through that
|
|
1019
|
+
// directory, which is the sandbox user the operator put there. Narrowing
|
|
1020
|
+
// the socket instead would make the operator's group or ACL on the
|
|
1021
|
+
// directory a dead letter for a uid it was written for.
|
|
1022
|
+
try {
|
|
1023
|
+
chmodSync(socketPath, UNIX_SOCKET_MODE);
|
|
1024
|
+
}
|
|
1025
|
+
catch (cause) {
|
|
1026
|
+
await new Promise((settle) => http.close(() => settle()));
|
|
1027
|
+
throw new Error(`the socket ${socketPath} was bound and could not be opened to mode ${UNIX_SOCKET_MODE.toString(8)}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
const address = http.address();
|
|
1031
|
+
const boundPort = socketPath === null && typeof address === "object" && address !== null ? address.port : options.port;
|
|
1032
|
+
const boundHost = socketPath !== null
|
|
1033
|
+
? `unix:${socketPath}`
|
|
1034
|
+
: typeof address === "object" && address !== null
|
|
1035
|
+
? address.address
|
|
1036
|
+
: host;
|
|
1037
|
+
notice(socketPath === null
|
|
1038
|
+
? `approval: serve bound ${boundHost}:${String(boundPort)}\n`
|
|
1039
|
+
: `approval: serve bound ${boundHost}\n`);
|
|
1040
|
+
return {
|
|
1041
|
+
host: boundHost,
|
|
1042
|
+
port: boundPort,
|
|
1043
|
+
socketPath,
|
|
1044
|
+
daemonId,
|
|
1045
|
+
requests: () => requests,
|
|
1046
|
+
hookThreads: () => hooks.stats(),
|
|
1047
|
+
close: async () => {
|
|
1048
|
+
if (closing)
|
|
1049
|
+
return;
|
|
1050
|
+
closing = true;
|
|
1051
|
+
// In this order (APRV-427 review). Stop accepting first. Then cancel
|
|
1052
|
+
// every hook call, which stops each waiting one at its next tick with
|
|
1053
|
+
// nothing spent and nothing withdrawn (its question stays open for the
|
|
1054
|
+
// retry grace, as for a killed `approval hook` process), and terminate
|
|
1055
|
+
// the threads from inside the store lock, which also waits out any verb
|
|
1056
|
+
// or mutation section still running. Only then are the sockets
|
|
1057
|
+
// destroyed, so no in-flight append is cut off by its connection going.
|
|
1058
|
+
const stopped = new Promise((settle) => http.close(() => settle()));
|
|
1059
|
+
await hooks.close();
|
|
1060
|
+
for (const socket of sockets)
|
|
1061
|
+
socket.destroy();
|
|
1062
|
+
sockets.clear();
|
|
1063
|
+
await stopped;
|
|
1064
|
+
if (socketPath !== null) {
|
|
1065
|
+
try {
|
|
1066
|
+
unlinkSync(socketPath);
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
// Already gone: the platform removed it on close.
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// APRV-448 review: a closed listener stops stamping the embedding
|
|
1073
|
+
// process's appends. Left alone when a daemon loop runs in this process
|
|
1074
|
+
// (it declared the same id for the same store and still writes), and when
|
|
1075
|
+
// the declaration in force is no longer this listener's.
|
|
1076
|
+
if (!isDaemonProcess() && daemonIdentity()?.id === daemonId)
|
|
1077
|
+
clearDaemonIdentity();
|
|
1078
|
+
},
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
// ---------------------------------------------------------------------------
|
|
1082
|
+
// Unix-socket listen target (APRV-445)
|
|
1083
|
+
// ---------------------------------------------------------------------------
|
|
1084
|
+
/**
|
|
1085
|
+
* The mode a unix socket is given once bound: everyone the DIRECTORY admits.
|
|
1086
|
+
*
|
|
1087
|
+
* A hosted tenant runs the agent's sandbox as a different uid from this server,
|
|
1088
|
+
* on the same machine, and the socket is how the two meet without a TCP port
|
|
1089
|
+
* anybody on the host could dial. Who may connect is therefore decided by the
|
|
1090
|
+
* directory the socket sits in, which the operator owns and sets (`0710` with
|
|
1091
|
+
* the sandbox's group, an ACL, a bind mount), and the socket itself does not
|
|
1092
|
+
* second-guess it.
|
|
1093
|
+
*/
|
|
1094
|
+
export const UNIX_SOCKET_MODE = 0o666;
|
|
1095
|
+
/**
|
|
1096
|
+
* Refuse a socket path this process should not bind (APRV-445).
|
|
1097
|
+
*
|
|
1098
|
+
* Absolute, inside an existing directory OWNED BY THE SERVING UID (the directory
|
|
1099
|
+
* is the access control, so a directory somebody else owns is somebody else's
|
|
1100
|
+
* access control), and not an existing file of any kind other than a socket: a
|
|
1101
|
+
* stale socket is replaced, and anything else at that path is a file this
|
|
1102
|
+
* server would destroy by binding over it. Synchronous and side-effect free, so
|
|
1103
|
+
* the CLI can refuse before any listener exists.
|
|
1104
|
+
*/
|
|
1105
|
+
export function checkUnixSocketTarget(socketPath) {
|
|
1106
|
+
if (!socketPath.startsWith("/")) {
|
|
1107
|
+
return { ok: false, message: `unix socket path ${JSON.stringify(socketPath)} is not absolute` };
|
|
1108
|
+
}
|
|
1109
|
+
const directory = dirname(socketPath);
|
|
1110
|
+
let owner;
|
|
1111
|
+
let mode;
|
|
1112
|
+
try {
|
|
1113
|
+
const stats = statSync(directory);
|
|
1114
|
+
if (!stats.isDirectory()) {
|
|
1115
|
+
return { ok: false, message: `${directory} is not a directory, so ${socketPath} cannot be bound in it` };
|
|
1116
|
+
}
|
|
1117
|
+
owner = stats.uid;
|
|
1118
|
+
mode = stats.mode & 0o777;
|
|
1119
|
+
}
|
|
1120
|
+
catch (cause) {
|
|
1121
|
+
return {
|
|
1122
|
+
ok: false,
|
|
1123
|
+
message: `the socket's directory ${directory} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
1127
|
+
if (uid === null || owner !== uid) {
|
|
1128
|
+
return {
|
|
1129
|
+
ok: false,
|
|
1130
|
+
message: `the socket's directory ${directory} is owned by uid ${String(owner)}, not by the serving uid ${String(uid)}. The directory is what decides who may connect to this socket (the socket itself is opened ${UNIX_SOCKET_MODE.toString(8)}), so it must be this process's own`,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
// APRV-445 refutation (L3). An other-writable directory (`/tmp` is the
|
|
1134
|
+
// usual one) lets anybody on the host unlink this socket and bind their own
|
|
1135
|
+
// in its place, between this process's bind and every client's connect, so
|
|
1136
|
+
// the directory would decide nothing. Refused rather than used.
|
|
1137
|
+
if ((mode & 0o002) !== 0) {
|
|
1138
|
+
return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
message: `the socket's directory ${directory} is writable by every user (mode ${mode.toString(8)}), so anyone could replace the socket; use a directory only the serving uid (and, if you mean it, its group) can write`,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
const existing = lstatSync(socketPath);
|
|
1145
|
+
if (!existing.isSocket()) {
|
|
1146
|
+
return {
|
|
1147
|
+
ok: false,
|
|
1148
|
+
message: `${socketPath} exists and is not a socket; binding there would destroy it, so nothing was bound`,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
// Absent: the ordinary case.
|
|
1154
|
+
}
|
|
1155
|
+
return { ok: true };
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Remove a socket file nothing answers on (APRV-445).
|
|
1159
|
+
*
|
|
1160
|
+
* A server killed without closing leaves its socket file behind, and the next
|
|
1161
|
+
* bind fails `EADDRINUSE` on a path nobody is listening at. A socket something
|
|
1162
|
+
* DOES answer on is another live server, possibly another tenant's gate, and is
|
|
1163
|
+
* refused rather than taken over.
|
|
1164
|
+
*/
|
|
1165
|
+
async function clearStaleSocket(socketPath) {
|
|
1166
|
+
try {
|
|
1167
|
+
if (!lstatSync(socketPath).isSocket())
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
// APRV-445 refutation (L2): only a refusal that PROVES nobody listens makes
|
|
1174
|
+
// the file stale. ECONNREFUSED is a socket with no listener and ENOENT is a
|
|
1175
|
+
// file that vanished meanwhile; anything else (EACCES, EAGAIN on a full
|
|
1176
|
+
// backlog, a timeout) is a socket this process cannot judge, and it is left
|
|
1177
|
+
// alone rather than unlinked from under whoever owns it.
|
|
1178
|
+
const probed = await new Promise((settle) => {
|
|
1179
|
+
const probe = connect(socketPath);
|
|
1180
|
+
probe.once("connect", () => {
|
|
1181
|
+
probe.destroy();
|
|
1182
|
+
settle("live");
|
|
1183
|
+
});
|
|
1184
|
+
probe.once("error", (cause) => {
|
|
1185
|
+
settle(cause.code === "ECONNREFUSED" || cause.code === "ENOENT" ? "stale" : cause.code ?? "unknown");
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
if (probed === "live") {
|
|
1189
|
+
throw new Error(`${socketPath} is a live socket: another process is listening on it, so this server will not take it over`);
|
|
1190
|
+
}
|
|
1191
|
+
if (probed !== "stale") {
|
|
1192
|
+
throw new Error(`${socketPath} exists and probing it failed with ${probed}, which does not prove nothing listens there; it was left in place`);
|
|
1193
|
+
}
|
|
1194
|
+
try {
|
|
1195
|
+
unlinkSync(socketPath);
|
|
1196
|
+
}
|
|
1197
|
+
catch (cause) {
|
|
1198
|
+
if (cause.code !== "ENOENT")
|
|
1199
|
+
throw cause;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* One lock per store this process serves, keyed by the store's log path
|
|
1204
|
+
* (APRV-427).
|
|
1205
|
+
*
|
|
1206
|
+
* A listener serves one store today (`--dir`, `--log` and `--policy` are pinned
|
|
1207
|
+
* at launch), so each listener finds exactly one entry here. The map is what
|
|
1208
|
+
* makes the lock a property of the STORE rather than of a listener: two
|
|
1209
|
+
* listeners in one process over one store (a test, or a future multi-tenant
|
|
1210
|
+
* host) share a lock, and a host serving several stores gets one lock each, so
|
|
1211
|
+
* one tenant's writes never wait on another's. It lives for the process and
|
|
1212
|
+
* holds one closure per store, which is the whole of its cost.
|
|
1213
|
+
*/
|
|
1214
|
+
const STORE_LOCKS = new Map();
|
|
1215
|
+
/**
|
|
1216
|
+
* The lock for the store whose log is `logPath` (resolved against `root`).
|
|
1217
|
+
*
|
|
1218
|
+
* Exported so a test can hold the SAME lock the listener takes and watch what
|
|
1219
|
+
* waits on it: that is how the suite shows a hook call's appends are inside
|
|
1220
|
+
* the lock and its wait is outside it, rather than inferring it from timing.
|
|
1221
|
+
*/
|
|
1222
|
+
export function storeLock(logPath, root) {
|
|
1223
|
+
const key = storeKey(resolve(root, logPath));
|
|
1224
|
+
let lock = STORE_LOCKS.get(key);
|
|
1225
|
+
if (lock === undefined) {
|
|
1226
|
+
lock = serializer();
|
|
1227
|
+
STORE_LOCKS.set(key, lock);
|
|
1228
|
+
}
|
|
1229
|
+
return lock;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* The key a store's lock is filed under: the log path with its DIRECTORY
|
|
1233
|
+
* resolved through `realpath`, so two spellings of one store (a symlinked
|
|
1234
|
+
* root, `/tmp` against `/private/tmp`) share one lock instead of each getting
|
|
1235
|
+
* their own. The directory rather than the file, because a store whose log has
|
|
1236
|
+
* not been written yet has no file to resolve; where even the directory is
|
|
1237
|
+
* missing, the resolved spelling is the best key there is.
|
|
1238
|
+
*/
|
|
1239
|
+
function storeKey(logPath) {
|
|
1240
|
+
try {
|
|
1241
|
+
return resolve(realpathSync(dirname(logPath)), basename(logPath));
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
return logPath;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/** The default log under a store root, from the CLI's own constant. */
|
|
1248
|
+
function logPathOf(root) {
|
|
1249
|
+
return resolvePath(null, DEFAULT_LOG_PATH, root);
|
|
1250
|
+
}
|
|
1251
|
+
//# sourceMappingURL=server.js.map
|