@manyoars/oars 0.4.0 → 0.5.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.
Files changed (185) hide show
  1. package/README.md +82 -32
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +206 -20
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/issuer.d.ts +1 -1
  15. package/dist/issuer.js +2 -2
  16. package/dist/main.js +5 -0
  17. package/dist/node-floor.d.ts +10 -0
  18. package/dist/node-floor.js +23 -0
  19. package/dist/oars-hook.d.ts +2 -0
  20. package/dist/oars-hook.js +7 -0
  21. package/dist/sessions/allow.d.ts +1 -0
  22. package/dist/sessions/allow.js +1 -1
  23. package/dist/sessions/call-shape.d.ts +111 -0
  24. package/dist/sessions/call-shape.js +464 -0
  25. package/dist/sessions/candidates.d.ts +65 -0
  26. package/dist/sessions/candidates.js +236 -0
  27. package/dist/sessions/card-command.d.ts +24 -0
  28. package/dist/sessions/card-command.js +71 -0
  29. package/dist/sessions/card-font.d.ts +6 -0
  30. package/dist/sessions/card-font.js +36 -0
  31. package/dist/sessions/card-line.d.ts +30 -0
  32. package/dist/sessions/card-line.js +105 -0
  33. package/dist/sessions/card-render.d.ts +15 -0
  34. package/dist/sessions/card-render.js +146 -0
  35. package/dist/sessions/card.d.ts +59 -0
  36. package/dist/sessions/card.js +107 -0
  37. package/dist/sessions/cell-increments.d.ts +14 -3
  38. package/dist/sessions/cell-increments.js +79 -12
  39. package/dist/sessions/cells.d.ts +42 -4
  40. package/dist/sessions/cells.js +72 -18
  41. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  42. package/dist/sessions/codex-hook-bin.js +24 -0
  43. package/dist/sessions/codex-hook-install.d.ts +21 -0
  44. package/dist/sessions/codex-hook-install.js +47 -0
  45. package/dist/sessions/codex-hook-view.d.ts +20 -0
  46. package/dist/sessions/codex-hook-view.js +20 -0
  47. package/dist/sessions/codex-hook.d.ts +77 -0
  48. package/dist/sessions/codex-hook.js +255 -0
  49. package/dist/sessions/codex.d.ts +9 -0
  50. package/dist/sessions/codex.js +104 -18
  51. package/dist/sessions/command.d.ts +17 -2
  52. package/dist/sessions/command.js +145 -33
  53. package/dist/sessions/copilot.d.ts +3 -0
  54. package/dist/sessions/copilot.js +12 -4
  55. package/dist/sessions/derive.js +2 -1
  56. package/dist/sessions/empty-state.d.ts +17 -0
  57. package/dist/sessions/empty-state.js +38 -0
  58. package/dist/sessions/exclusion.js +48 -3
  59. package/dist/sessions/extract.d.ts +16 -3
  60. package/dist/sessions/extract.js +281 -24
  61. package/dist/sessions/gemini.d.ts +2 -0
  62. package/dist/sessions/gemini.js +29 -2
  63. package/dist/sessions/harness-files.d.ts +27 -0
  64. package/dist/sessions/harness-files.js +109 -0
  65. package/dist/sessions/head-only.d.ts +7 -0
  66. package/dist/sessions/head-only.js +21 -0
  67. package/dist/sessions/hook-calls.js +4 -3
  68. package/dist/sessions/hooks.d.ts +1 -1
  69. package/dist/sessions/hooks.js +6 -2
  70. package/dist/sessions/identity.d.ts +6 -0
  71. package/dist/sessions/injected.d.ts +13 -0
  72. package/dist/sessions/injected.js +212 -0
  73. package/dist/sessions/install.d.ts +31 -20
  74. package/dist/sessions/install.js +80 -82
  75. package/dist/sessions/load-status.d.ts +41 -0
  76. package/dist/sessions/load-status.js +156 -0
  77. package/dist/sessions/machine-child-deps.d.ts +5 -0
  78. package/dist/sessions/machine-child-deps.js +83 -0
  79. package/dist/sessions/machine-child.d.ts +43 -0
  80. package/dist/sessions/machine-child.js +120 -0
  81. package/dist/sessions/machine-credential.d.ts +10 -0
  82. package/dist/sessions/machine-credential.js +20 -0
  83. package/dist/sessions/machine-enroll.d.ts +47 -0
  84. package/dist/sessions/machine-enroll.js +230 -0
  85. package/dist/sessions/machine-hold.d.ts +49 -0
  86. package/dist/sessions/machine-hold.js +88 -0
  87. package/dist/sessions/machine-ids.d.ts +6 -0
  88. package/dist/sessions/machine-ids.js +9 -0
  89. package/dist/sessions/machine-notice.d.ts +21 -0
  90. package/dist/sessions/machine-notice.js +89 -0
  91. package/dist/sessions/machine-pull.d.ts +40 -0
  92. package/dist/sessions/machine-pull.js +220 -0
  93. package/dist/sessions/machine-repo.d.ts +7 -0
  94. package/dist/sessions/machine-repo.js +30 -0
  95. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  96. package/dist/sessions/machine-scan-cache.js +47 -0
  97. package/dist/sessions/machine-send.d.ts +12 -0
  98. package/dist/sessions/machine-send.js +156 -0
  99. package/dist/sessions/machine-stop.d.ts +22 -0
  100. package/dist/sessions/machine-stop.js +53 -0
  101. package/dist/sessions/machine.d.ts +41 -0
  102. package/dist/sessions/machine.js +89 -0
  103. package/dist/sessions/opencode.d.ts +2 -0
  104. package/dist/sessions/opencode.js +6 -0
  105. package/dist/sessions/otel.d.ts +21 -0
  106. package/dist/sessions/otel.js +109 -38
  107. package/dist/sessions/owner-only.d.ts +7 -0
  108. package/dist/sessions/owner-only.js +34 -0
  109. package/dist/sessions/pi.d.ts +13 -1
  110. package/dist/sessions/pi.js +27 -13
  111. package/dist/sessions/pool-books.d.ts +1 -1
  112. package/dist/sessions/pool-books.js +1 -1
  113. package/dist/sessions/pool-choices.d.ts +4 -2
  114. package/dist/sessions/pool-choices.js +4 -2
  115. package/dist/sessions/pool-command.d.ts +46 -6
  116. package/dist/sessions/pool-command.js +577 -133
  117. package/dist/sessions/pool-delta.d.ts +7 -0
  118. package/dist/sessions/pool-delta.js +28 -5
  119. package/dist/sessions/pool-due.d.ts +43 -0
  120. package/dist/sessions/pool-due.js +113 -0
  121. package/dist/sessions/pool-nightly.d.ts +9 -1
  122. package/dist/sessions/pool-nightly.js +65 -21
  123. package/dist/sessions/pool-outbox.d.ts +51 -5
  124. package/dist/sessions/pool-outbox.js +217 -24
  125. package/dist/sessions/pool-print.d.ts +1 -1
  126. package/dist/sessions/pool-raw.js +4 -3
  127. package/dist/sessions/pool-replica.d.ts +4 -4
  128. package/dist/sessions/pool-replica.js +26 -7
  129. package/dist/sessions/pool-send-lock.js +1 -1
  130. package/dist/sessions/pool-sent.d.ts +2 -0
  131. package/dist/sessions/pool-sent.js +21 -4
  132. package/dist/sessions/pool-wire.d.ts +172 -12
  133. package/dist/sessions/pool-wire.js +284 -30
  134. package/dist/sessions/pool.js +4 -2
  135. package/dist/sessions/public-vocabulary.d.ts +5 -0
  136. package/dist/sessions/public-vocabulary.js +16 -0
  137. package/dist/sessions/read-call-shape.js +12 -6
  138. package/dist/sessions/read-fit.d.ts +5 -0
  139. package/dist/sessions/read-fit.js +23 -2
  140. package/dist/sessions/read.d.ts +1 -1
  141. package/dist/sessions/recall-consequence.d.ts +2 -2
  142. package/dist/sessions/recall-consequence.js +14 -9
  143. package/dist/sessions/recall-object.d.ts +9 -2
  144. package/dist/sessions/receipt.d.ts +8 -0
  145. package/dist/sessions/receipt.js +78 -0
  146. package/dist/sessions/recovery-calls.d.ts +3 -2
  147. package/dist/sessions/recovery-calls.js +9 -3
  148. package/dist/sessions/redact.d.ts +2 -2
  149. package/dist/sessions/redact.js +116 -14
  150. package/dist/sessions/reversion.d.ts +8 -0
  151. package/dist/sessions/reversion.js +114 -0
  152. package/dist/sessions/schema.d.ts +78 -4
  153. package/dist/sessions/schema.js +31 -1
  154. package/dist/sessions/session-shape.d.ts +2 -1
  155. package/dist/sessions/session-shape.js +15 -0
  156. package/dist/sessions/sources.d.ts +9 -0
  157. package/dist/sessions/sources.js +16 -5
  158. package/dist/sessions/steps.js +3 -2
  159. package/dist/sessions/store.d.ts +41 -2
  160. package/dist/sessions/store.js +68 -16
  161. package/dist/sessions/try-demo.d.ts +8 -0
  162. package/dist/sessions/try-demo.js +90 -0
  163. package/dist/sessions/try.d.ts +13 -0
  164. package/dist/sessions/try.js +50 -0
  165. package/dist/sessions/vscode-copilot.d.ts +4 -0
  166. package/dist/sessions/vscode-copilot.js +25 -18
  167. package/dist/telemetry/command.js +12 -6
  168. package/dist/telemetry/consent.d.ts +9 -3
  169. package/dist/telemetry/consent.js +25 -9
  170. package/dist/telemetry/fields.d.ts +11 -0
  171. package/dist/telemetry/fields.js +56 -0
  172. package/dist/telemetry/measures.d.ts +13 -0
  173. package/dist/telemetry/measures.js +30 -0
  174. package/dist/telemetry/observe.d.ts +15 -2
  175. package/dist/telemetry/observe.js +34 -7
  176. package/dist/telemetry/region.d.ts +6 -0
  177. package/dist/telemetry/region.js +28 -0
  178. package/dist/telemetry/report.js +1 -1
  179. package/dist/telemetry/schema.d.ts +3 -0
  180. package/dist/telemetry/schema.js +11 -1
  181. package/dist/telemetry/send.d.ts +5 -2
  182. package/dist/telemetry/send.js +15 -7
  183. package/dist/telemetry/words.d.ts +6 -0
  184. package/dist/telemetry/words.js +6 -0
  185. package/package.json +23 -4
@@ -1,7 +1,9 @@
1
- // `oars pool`: the only part of the sessions code that talks to a remote. The server is the one named by flag or environment, else the Many Oars pool; nothing leaves until a command that sends is run or, after covenant acceptance, the nightly task sends. Sign-in runs the provider's device flow here and reads no harness token; a session leaves only as a skeleton of a closed list of fields, each value redacted first, and only after the exclusion check found it clean. Local capture, recall,
1
+ // `oars pool`: the only part of the sessions code that talks to a remote. The server is the one named by flag or environment, else the Many Oars pool; nothing leaves until a command that sends is run or, after covenant acceptance, the detached sender (hook-bin.js nightly) sends. Sign-in runs the provider's device flow here and reads no harness token; a session leaves only as a skeleton of a closed list of fields, each value redacted first, and only after the exclusion check found it clean. Local capture, recall,
2
2
  // and reads never come through here.
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
- import { dirname } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { createInterface } from 'node:readline';
5
7
  import { emitOf } from '../telemetry/emit.js';
6
8
  import { CliError, exitCodeForProblem, ExitCode, unreachableError } from '../errors.js';
7
9
  import { compileAllow } from './allow.js';
@@ -9,27 +11,36 @@ import { makeCoverage, readCoverageSetting, writeCoverageSetting } from './cover
9
11
  import { checkSession, exclusionPaths, lastWritten, loadTerms, transcriptFilesOf } from './exclusion.js';
10
12
  import { localNames, makeRedactor, REDACTION_VERSION, redactTranscript } from './redact.js';
11
13
  import { recordSend, recordWire, newTally, tallyBatch } from '../telemetry/wire.js';
12
- import { discardStale, FIELD_ROWS, fieldsOf, heldBatches, lockedBatch, markShown, outboxFolder, partsOf, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch } from './pool-outbox.js';
13
- import { HARNESSES, hasEnded, ORG_SIZES, REPO_VISIBILITIES, SESSION_IDLE_HOURS, isBundleVersion, isHarness, parseSkeleton, sessionsOf, skeletonOf, VOCAB_VERSION, verifyBundle } from './pool-wire.js';
14
- import { carriedDeltas, markDeltasSent, placedOf, sentDeltas } from './pool-delta.js';
14
+ import { discardStale, FIELD_ROWS, fieldsOf, heldAcross, lockedAcross, rescanPulled, machineKeys, markShown, noteMachineKey, outboxFolder, partsOf, pulledAt, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch } from './pool-outbox.js';
15
+ import { deriveConsequences } from './recall-consequence.js';
16
+ import { readRecalls } from './recall-object.js';
17
+ import { HARNESSES, hasEnded, ORG_SIZES, REPO_VISIBILITIES, SESSION_IDLE_HOURS, isBundleVersion, isHarness, parseSkeleton, recommendationsOf, sessionsOf, skeletonOf, unpinnedKid, VOCAB_VERSION, verifyBundle } from './pool-wire.js';
18
+ import { carriedDeltas, heldDeltas, keptDeltas, markDeltasSent, placedOf, sentDeltas } from './pool-delta.js';
15
19
  import { Masker, printed, TRANSPORT_HEADERS, waitWords } from './pool-print.js';
16
- import { rawAllowed, sendsOf } from './pool-choices.js';
20
+ import { rawAllowed, rawStale, sendsOf } from './pool-choices.js';
17
21
  import { heldRaw, rawParts, removeRawExcept, writeRaw } from './pool-raw.js';
18
22
  import { SendLockBusy, withSendLock } from './pool-send-lock.js';
19
- import { markSent, sentLog } from './pool-sent.js';
23
+ import { markSent, retirePulled, sentLog } from './pool-sent.js';
20
24
  import { shellReader } from './projection.js';
21
25
  import { isVocabularyExpired, REGISTRY_MAX_AGE_DAYS, VOCAB_SNAPSHOT_DATE } from './public-vocabulary.js';
22
26
  import { contentHash, FIRST_ORDER_FAMILIES } from './read.js';
23
27
  import { isObject, keptReplica, loadSignIns, numberIn, replicaFile, signInOf, signInsFile, textIn } from './pool-replica.js';
24
28
  import { CAPTURE_VERSION, harnessOf as sessionHarness } from './schema.js';
25
- import { loadStore, readRedactDomains, readSkeletons, readState, updatePoolChoices } from './store.js';
29
+ import { machineIdOf } from './machine-ids.js';
30
+ import { machineOf } from './machine.js';
31
+ import { scanCache } from './machine-scan-cache.js';
32
+ import { loadStore, localSalt, readRedactDomains, readSkeletons, readState, updatePoolChoices } from './store.js';
26
33
  import { runGit } from './survival.js';
27
34
  import { vocabulary } from './vocab-load.js';
35
+ import { mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
28
36
  const LOGIN = 'pool-login';
37
+ const LOGOUT = 'pool-logout';
29
38
  export const DEPOSIT = 'pool-deposit';
30
39
  const OUTBOX = 'pool-outbox';
31
40
  const POOL_DEPOSITS = '/v1/deposits';
32
41
  const FETCH = 'pool-fetch';
42
+ const EXPORT = 'pool-export';
43
+ const PROVE = 'pool-prove';
33
44
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
34
45
  export const POOL_PROVIDERS = ['github', 'google'];
35
46
  const DEFAULT_INTERVAL_SEC = 5;
@@ -87,10 +98,10 @@ const outgoing = (deps, server, path, init) => {
87
98
  /** One call to the pool. A redirect is an error: it could carry a batch or a token somewhere the checks on the named server never saw. */
88
99
  export const ask = (deps, step, server, path, init, via) => {
89
100
  const request = outgoing(deps, server, path, init);
90
- return via === undefined ? send(deps, step, request, init.show) : via(request);
101
+ return via === undefined ? send(deps, step, request, init.show, init.binary === true) : via(request);
91
102
  };
92
103
  /** Prints the request when asked to, then sends exactly those headers and that body. */
93
- const send = async (deps, step, request, show) => {
104
+ const send = async (deps, step, request, show, binary = false) => {
94
105
  if (show !== undefined)
95
106
  deps.io.stderr(`${printed(request, show)}
96
107
  `);
@@ -103,7 +114,7 @@ const send = async (deps, step, request, show) => {
103
114
  }
104
115
  let body;
105
116
  try {
106
- body = await response.json();
117
+ body = binary && response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : await response.json();
107
118
  }
108
119
  catch {
109
120
  body = undefined;
@@ -113,113 +124,150 @@ const send = async (deps, step, request, show) => {
113
124
  /** The sender `deposit --send` uses when none is given: the request goes out over the network as built. The loopback test passes it to read what reaches the socket. */
114
125
  export const networkSender = (deps, step) => (request) => send(deps, step, request, undefined);
115
126
  /** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
116
- const refusal = (step, { status, body }) => {
127
+ export const refusal = (step, { status, body }) => {
117
128
  const code = isObject(body) ? textIn(body, 'error') : undefined;
118
129
  const message = (isObject(body) ? textIn(body, 'message') : undefined) ?? `The pool answered HTTP ${String(status)}.`;
119
130
  return new CliError(message, exitCodeForProblem(status, code === undefined ? undefined : { code }), step, { title: message, status, ...(code === undefined ? {} : { code }) });
120
131
  };
121
- const PROVIDER_FLOWS = new Map([
122
- ['github', { deviceUrl: 'https://github.com/login/device/code', tokenUrl: 'https://github.com/login/oauth/access_token', scope: '', clientIdVar: 'OARS_GITHUB_CLIENT_ID' }],
123
- ['google', { deviceUrl: 'https://oauth2.googleapis.com/device/code', tokenUrl: 'https://oauth2.googleapis.com/token', scope: 'openid', clientIdVar: 'OARS_GOOGLE_CLIENT_ID', secretVar: 'OARS_GOOGLE_CLIENT_SECRET' }],
124
- ]);
125
- const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
126
- /** The provider's side of the flow, read from the environment. An unset value stops the command before anything is sent, and names what is missing. */
127
- const configOf = (env, provider) => {
128
- const set = (name) => (env[name] === undefined || env[name] === '' ? undefined : env[name]);
129
- const clientId = set(provider.clientIdVar);
130
- if (clientId === undefined)
131
- throw usage(`Set ${provider.clientIdVar} to the client ID of the Many Oars OAuth app; it is not set.`, LOGIN);
132
- const clientSecret = provider.secretVar === undefined ? undefined : set(provider.secretVar);
133
- if (provider.secretVar !== undefined && clientSecret === undefined)
134
- throw usage(`Set ${provider.secretVar} to the client secret of the Many Oars OAuth app; it is not set.`, LOGIN);
135
- return { clientId, clientSecret };
136
- };
137
- /** One form POST to a provider, printed before it is sent. The provider answers JSON on whatever status it chooses (Google's pending poll is a 428), so the caller reads the body and not the status. */
138
- const askProvider = (deps, url, form, masker) => {
139
- const headers = [['user-agent', `oars/${deps.version}`], ['accept', 'application/json'], ['content-type', 'application/x-www-form-urlencoded'], ...TRANSPORT_HEADERS];
140
- return send(deps, LOGIN, { method: 'POST', url: new URL(url), headers, body: new URLSearchParams(form).toString() }, masker);
141
- };
142
- const deviceOf = ({ body }) => {
143
- // Google names the link `verification_url`, against the RFC's `verification_uri`.
144
- const [deviceCode, userCode, verificationUri] = isObject(body) ? [textIn(body, 'device_code'), textIn(body, 'user_code'), textIn(body, 'verification_uri') ?? textIn(body, 'verification_url')] : [];
145
- // A provider that refuses here (GitHub's `device_flow_disabled`, Google's `invalid_client`) is how an unfinished OAuth app shows, so its word goes to the operator.
146
- const refusal = isObject(body) ? textIn(body, 'error') : undefined;
147
- if (refusal !== undefined) {
148
- const title = `The provider refused the sign-in: ${refusal}.`;
149
- throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'login_refused', title });
150
- }
132
+ const startedOf = ({ body }) => {
133
+ const [handle, userCode, verificationUrl] = isObject(body) ? [textIn(body, 'handle'), textIn(body, 'userCode'), textIn(body, 'verificationUrl')] : [];
151
134
  // `openBrowser` parses the link before it returns its promise, so one that cannot parse has to stop here or it throws past the login's catch.
152
- if (!isObject(body) || deviceCode === undefined || userCode === undefined || verificationUri === undefined || !URL.canParse(verificationUri))
153
- throw new CliError('The provider did not start a sign-in.', ExitCode.UNEXPECTED, LOGIN);
135
+ if (!isObject(body) || handle === undefined || userCode === undefined || verificationUrl === undefined || !URL.canParse(verificationUrl))
136
+ throw new CliError('The pool did not start a sign-in.', ExitCode.UNEXPECTED, LOGIN);
154
137
  // An interval of 0 or below would poll with no wait and never run out the clock, so it takes the default like a missing one.
155
138
  const interval = numberIn(body, 'interval');
156
- return { deviceCode, userCode, verificationUri, intervalMs: (interval === undefined || interval <= 0 ? DEFAULT_INTERVAL_SEC : interval) * 1000, expiresMs: (numberIn(body, 'expires_in') ?? DEFAULT_EXPIRY_SEC) * 1000 };
157
- };
158
- /** Polls the provider's token route until the operator approves, and returns the provider's token. Time is counted in the waits asked for, so a machine that slept through the code still gets one poll that says it expired. */
159
- const awaitToken = async (deps, provider, config, device, masker) => {
160
- let { intervalMs } = device;
161
- const form = [['client_id', config.clientId], ['device_code', device.deviceCode], ['grant_type', DEVICE_GRANT]];
162
- if (config.clientSecret !== undefined)
163
- form.push(['client_secret', config.clientSecret]);
164
- for (let waited = 0; waited < device.expiresMs; waited += intervalMs) {
139
+ return { handle, userCode, verificationUrl, intervalMs: (interval === undefined || interval <= 0 ? DEFAULT_INTERVAL_SEC : interval) * 1000, expiresMs: (numberIn(body, 'expiresIn') ?? DEFAULT_EXPIRY_SEC) * 1000 };
140
+ };
141
+ /** The two lines the operator compares with `accounts.proved_at` (GDPR Art. 12(6)): the account, and the proof time in milliseconds exactly as the pool stored it. */
142
+ const proofLines = (account, provedAt) => `account: ${account}
143
+ provedAt: ${String(provedAt)}`;
144
+ /** The pool refused a sign-in because the account is closed, and kept the sign-in as proof of control: the refusal carries the account and the time, and the failure prints both, text and `--json`, so the person can send them to the operator who asks for proof. Undefined for any other refusal. */
145
+ const closedProof = (reply) => {
146
+ const body = reply.body;
147
+ const account = isObject(body) ? textIn(body, 'account') : undefined;
148
+ const provedAt = isObject(body) ? numberIn(body, 'provedAt') : undefined;
149
+ if (reply.status !== 403 || !isObject(body) || textIn(body, 'error') !== 'account_closed' || account === undefined || provedAt === undefined)
150
+ return undefined;
151
+ const refused = refusal(LOGIN, reply);
152
+ const detail = `The operator will ask for these two lines when you write to privacy@manyoars.com:
153
+ ${proofLines(account, provedAt)}`;
154
+ return new CliError(refused.message, refused.exitCode, LOGIN, { ...refused.problem, detail }, { account, provedAt });
155
+ };
156
+ /** Polls the pool until the person approves, and returns the pool's answer with the credential. Time is counted in the waits asked for, so a machine that slept through the code still gets one poll that says it expired. */
157
+ const awaitCredential = async (deps, server, started, show) => {
158
+ let { intervalMs } = started;
159
+ for (let waited = 0; waited < started.expiresMs; waited += intervalMs) {
165
160
  await deps.sleep(intervalMs);
166
- const { body } = await askProvider(deps, provider.tokenUrl, form, masker);
167
- if (!isObject(body))
168
- throw new CliError('The provider answered a sign-in poll with no body.', ExitCode.UNEXPECTED, LOGIN);
169
- const accessToken = textIn(body, 'access_token');
170
- if (accessToken !== undefined)
171
- return accessToken;
172
- const error = textIn(body, 'error') ?? 'no_token';
173
- if (error === 'slow_down')
161
+ const reply = await ask(deps, LOGIN, server, '/v1/login/poll', { body: { handle: started.handle }, show });
162
+ if (reply.status !== 200 || !isObject(reply.body))
163
+ throw closedProof(reply) ?? refusal(LOGIN, reply);
164
+ const status = textIn(reply.body, 'status');
165
+ if (status === 'authorized')
166
+ return reply;
167
+ if (status === 'slow_down')
174
168
  intervalMs += SLOW_DOWN_MS;
175
- else if (error === 'expired_token')
169
+ else if (status === 'expired')
176
170
  break;
177
- else if (error !== 'authorization_pending') {
178
- const title = `The provider refused the sign-in: ${error}.`;
171
+ else if (status === 'denied') {
172
+ const title = 'The provider refused the sign-in: access_denied.';
179
173
  throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'login_refused', title });
180
174
  }
175
+ else if (status !== 'pending')
176
+ throw new CliError('The pool answered a sign-in poll with a state this client does not know.', ExitCode.UNEXPECTED, LOGIN);
181
177
  }
182
178
  const title = 'The sign-in code expired. Run oars pool login again.';
183
179
  throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'EXPIRED_TOKEN', title });
184
180
  };
185
181
  /**
186
- * `oars pool login` (spec Section 1.4): the device flow runs here, against the provider, and the pool sees only the provider's token, once, and answers its own credential. Every request prints before it is sent, with the device code, the provider's token,
187
- * and the credential masked as `<credential: n bytes>`. A client ID, scope, grant type, and Google's `client_secret` print as they are: none is secret.
182
+ * `oars pool login` (spec Section 1.4): the pool runs the provider's device flow and this client only shows the code and polls the pool, so no provider token, device code, client ID, or secret is ever here. With `--verbose`, every request, which goes to the pool alone,
183
+ * prints before it is sent, with the login handle and the credential masked as `<credential: n bytes>`; without it, a failure's cause is the first line printed.
188
184
  */
189
185
  export const poolLogin = async (deps, options) => {
190
186
  const { storeDir } = runtimeOf(deps);
191
187
  const server = poolServer(deps.env, LOGIN, options.server);
192
188
  const { provider } = options;
193
- const flow = PROVIDER_FLOWS.get(provider ?? '');
194
- if (provider === undefined || flow === undefined)
189
+ if (provider === undefined || POOL_PROVIDERS.every((name) => name !== provider))
195
190
  throw usage(`The provider is one of ${POOL_PROVIDERS.join(', ')}.`, LOGIN);
196
- const config = configOf(deps.env, flow);
197
191
  const saved = await loadSignIns(storeDir, LOGIN);
198
192
  const masker = new Masker();
199
- const device = deviceOf(await askProvider(deps, flow.deviceUrl, [['client_id', config.clientId], ['scope', flow.scope]], masker));
200
- masker.add(device.deviceCode);
201
- deps.io.stderr(`To sign in, open ${device.verificationUri}\nand confirm the code ${device.userCode}\n`);
202
- await deps.openBrowser(device.verificationUri).catch(() => {
193
+ // The raw request is for a person debugging a pool; without `--verbose` a failure's one-line cause is the first thing printed.
194
+ const show = options.verbose === true ? masker : undefined;
195
+ const startReply = await ask(deps, LOGIN, server, '/v1/login/start', { body: { provider }, show });
196
+ if (startReply.status !== 200)
197
+ throw refusal(LOGIN, startReply);
198
+ const started = startedOf(startReply);
199
+ masker.add(started.handle);
200
+ deps.io.stderr(`To sign in, open ${started.verificationUrl}\nand confirm the code ${started.userCode}\n`);
201
+ await deps.openBrowser(started.verificationUrl).catch(() => {
203
202
  deps.io.stderr('Could not open a browser; open the link above yourself.\n');
204
203
  });
205
- const accessToken = await awaitToken(deps, flow, config, device, masker);
206
- masker.add(accessToken);
207
- const reply = await ask(deps, LOGIN, server, '/v1/login', { body: { provider, token: accessToken }, show: masker });
208
- if (reply.status !== 200)
209
- throw refusal(LOGIN, reply);
204
+ const reply = await awaitCredential(deps, server, started, show);
210
205
  const signIn = signInOf(isObject(reply.body) ? { ...reply.body, server } : undefined);
211
206
  if (signIn === undefined)
212
207
  throw new CliError('The pool answered a sign-in with no account, token, or signing key.', ExitCode.UNEXPECTED, LOGIN);
213
208
  const known = saved.find((entry) => entry.server === server);
214
- // The key is trusted once, here, and never replaced by a later login: a server that swaps it could otherwise sign anything it likes.
215
- if (known !== undefined && known.signingKey.x !== signIn.signingKey.x)
216
- throw new CliError(`${server} now signs with a different key than the one pinned at your first login. Nothing was saved. If the pool really did change its key, remove ${server} from ${signInsFile(storeDir)} and sign in again.`, ExitCode.AUTH, LOGIN, { code: 'signing_key_changed', title: 'The pool signing key changed.' });
217
- await mkdir(storeDir, { recursive: true });
218
- await writeFile(signInsFile(storeDir), `${JSON.stringify([...saved.filter((entry) => entry !== known), signIn])}\n`, { mode: 0o600 });
209
+ // A key is trusted once, here, under the id the pool names it by, and never replaced: a server that swaps the key behind an id it already holds could otherwise sign anything it likes. A server cannot add an id either: a new id enters only in a release of oars (RELEASE_KEYS, docs/pool.md), so a server at this URL that names one is refused.
210
+ const held = known?.signingKeys ?? {};
211
+ for (const [kid, key] of Object.entries(signIn.signingKeys)) {
212
+ if (Object.hasOwn(held, kid) && held[kid]?.x !== key.x) {
213
+ const message = `${server} now signs with a different key than the one pinned for ${kid} at your first login. Nothing was saved. If the pool really did change this key, remove ${server} from ${signInsFile(storeDir)} and sign in again.`;
214
+ throw new CliError(message, ExitCode.AUTH, LOGIN, { code: 'signing_key_changed', title: 'The pool signing key changed.', detail: message });
215
+ }
216
+ }
217
+ const unpinned = known === undefined ? undefined : unpinnedKid(held, signIn.signingKeys);
218
+ if (unpinned !== undefined) {
219
+ const message = `${server} signs with the key ${unpinned}, which this machine never pinned for it and this release of oars does not ship. Nothing was saved. Upgrade with npm install -g @manyoars/oars; if the pool really did rotate its key, remove ${server} from ${signInsFile(storeDir)} and sign in again.`;
220
+ throw new CliError(message, ExitCode.AUTH, LOGIN, { code: 'signing_key_unpinned', title: 'The pool signing key is not pinned.', detail: message });
221
+ }
222
+ // Only the keys already held are kept on a repeat login: a key the server offers under a released id is never stored beside the release's own.
223
+ const pinned = { ...signIn, signingKeys: known === undefined ? signIn.signingKeys : held };
224
+ await mkdirOwnerOnly(storeDir);
225
+ await writeOwnerOnly(signInsFile(storeDir), `${JSON.stringify([...saved.filter((entry) => entry !== known), pinned])}\n`);
219
226
  return { document: { ok: true, server, account: signIn.account, expiresAt: signIn.expiresAt }, text: `Signed in to ${server} as ${signIn.account}.` };
220
227
  };
228
+ /** The pool is not answering: it could not be reached, or a gateway in front of it says so. */
229
+ const poolDown = (status) => status === 502 || status === 503 || status === 504;
230
+ /**
231
+ * `oars pool logout` (S8): revokes this machine's credential at the pool (`DELETE /v1/login`), then removes the local entry. A pool that cannot be reached does not keep the entry: the person asked to be signed out here, so the entry
232
+ * goes and the output says the credential was not revoked there (it lapses on its own, and a login past the pool's cap revokes the oldest). A refusal for any other reason keeps the entry, so the command can be run again.
233
+ */
234
+ export const poolLogout = async (deps, options) => {
235
+ const { storeDir } = runtimeOf(deps);
236
+ const server = poolServer(deps.env, LOGOUT, options.server);
237
+ const saved = await loadSignIns(storeDir, LOGOUT);
238
+ const known = saved.find((entry) => entry.server === server);
239
+ if (known === undefined)
240
+ return { document: { ok: true, server, revoked: false, removed: false }, text: `Not signed in to ${server}.` };
241
+ // The request prints before it is sent with the credential masked, as every other pool request does.
242
+ const credential = new Masker();
243
+ credential.add(known.token);
244
+ let reply;
245
+ try {
246
+ reply = await ask(deps, LOGOUT, server, '/v1/login', { method: 'DELETE', token: known.token, show: credential });
247
+ }
248
+ catch (error) {
249
+ if (!(error instanceof CliError) || error.exitCode !== ExitCode.UNREACHABLE)
250
+ throw error;
251
+ }
252
+ if (reply !== undefined && reply.status !== 204 && reply.status !== 401 && !poolDown(reply.status))
253
+ throw refusal(LOGOUT, reply);
254
+ await writeOwnerOnly(signInsFile(storeDir), `${JSON.stringify(saved.filter((entry) => entry !== known))}\n`);
255
+ const revoked = reply?.status === 204;
256
+ const text = revoked
257
+ ? `Signed out of ${server}: the credential is revoked there and removed here.`
258
+ : reply?.status === 401
259
+ ? `Signed out of ${server}: the pool had already revoked the credential, and it is removed here.`
260
+ : `Signed out of ${server} here, but the pool could not be reached, so the credential was not revoked there. It lapses on its own.`;
261
+ return { document: { ok: true, server, revoked, removed: true }, text };
262
+ };
263
+ const REGION_FLAG = '--in-eu-eea-uk';
264
+ const REGION_QUESTION = 'Are you in the EU, the EEA, or the UK? (yes/no)';
265
+ const regionOf = (text) => {
266
+ const answer = text?.trim().toLowerCase();
267
+ return answer === 'yes' || answer === 'no' ? answer : undefined;
268
+ };
221
269
  /** A send stops here when the server is closed or wants a covenant accepted, before the batch is read for the last time. */
222
- const openCovenant = async (deps, server, token, accept, via) => {
270
+ const openCovenant = async (deps, server, token, accept, region, via) => {
223
271
  const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', { token }, via);
224
272
  if (reply.status !== 200 || !isObject(reply.body))
225
273
  throw refusal(DEPOSIT, reply);
@@ -228,15 +276,80 @@ const openCovenant = async (deps, server, token, accept, via) => {
228
276
  const title = 'This pool is not taking deposits: its covenant is not published.';
229
277
  throw new CliError(title, ExitCode.AUTH, DEPOSIT, { code: 'deposits_closed', title, status: 403 });
230
278
  }
231
- if (accept !== undefined) {
232
- const accepted = await ask(deps, DEPOSIT, server, '/v1/covenant/accept', { token, body: { version: accept } }, via);
233
- if (accepted.status !== 200)
234
- throw refusal(DEPOSIT, accepted);
279
+ // A flag is refused whatever the account's state: off a terminal it would be a script saying yes, and a version other than the pool's names a text this pool does not hold.
280
+ if (accept !== undefined && !isTerminal(deps))
281
+ throw usage('--accept-covenant does not accept for you: acceptance is a person typing the phrase at a terminal, and this is not one. Nothing was accepted.', DEPOSIT);
282
+ if (accept !== undefined && accept !== version)
283
+ throw usage(`--accept-covenant names covenant ${accept}, but the covenant of this pool is ${version}. Nothing was accepted.`, DEPOSIT);
284
+ if (Reflect.get(reply.body, 'accepted') === true)
235
285
  return;
286
+ const notAccepted = (title) => new CliError(title, ExitCode.AUTH, DEPOSIT, { code: 'covenant_not_accepted', title, status: 403 });
287
+ const screen = screenIn(reply.body);
288
+ if (screen === undefined)
289
+ throw notAccepted(`The pool has no text for covenant ${version} yet, so there is nothing to show and nothing can be accepted. Nothing was accepted.`);
290
+ // Assent is a person typing the phrase the screen states. A flag or a script on stdin is not one, so off a terminal nothing is asked and nothing is accepted.
291
+ if (!isTerminal(deps)) {
292
+ throw notAccepted(`Covenant ${version} is not accepted for this account. Accepting it takes a person at a terminal who reads it and types its phrase: run this command from one. Nothing was accepted.`);
293
+ }
294
+ deps.io.stderr(`\n${screen.text}\n\n> `);
295
+ emitOf(deps)('covenant-shown', { version });
296
+ // One reader for both lines: a second one opened after the first closed would lose a line the first had already taken from a pipe.
297
+ const lines = createInterface({ input: runtimeOf(deps).stdin, terminal: false });
298
+ const iterator = lines[Symbol.asyncIterator]();
299
+ const nextLine = async () => {
300
+ const next = await iterator.next();
301
+ return next.done === true ? undefined : next.value;
302
+ };
303
+ let answered = region;
304
+ try {
305
+ const typed = await nextLine();
306
+ if (typed?.trim() !== screen.phrase)
307
+ throw notAccepted(`${typed === undefined ? 'No phrase was typed' : 'That was not the phrase'}. To accept covenant ${version}, type exactly: ${screen.phrase}. Nothing was accepted.`);
308
+ if (region === undefined) {
309
+ deps.io.stderr(`${REGION_QUESTION} > `);
310
+ answered = regionOf(await nextLine());
311
+ }
312
+ }
313
+ finally {
314
+ lines.close();
315
+ }
316
+ if (answered === undefined)
317
+ throw notAccepted('Answer yes or no: are you in the EU, the EEA, or the UK? Nothing was accepted.');
318
+ const body = { version, textSha256: createHash('sha256').update(screen.text).digest('hex'), input: 'typed', client: `oars/${deps.version}`, euEeaUk: answered };
319
+ const accepted = await ask(deps, DEPOSIT, server, '/v1/covenant/accept', { token, body }, via);
320
+ if (accepted.status !== 200)
321
+ throw refusal(DEPOSIT, accepted);
322
+ // A yes to the raw prompt belongs to the covenant it was given under, and a covenant can change what that prompt covers: accepting another version asks the raw question again.
323
+ const reset = { done: false };
324
+ await updatePoolChoices(runtimeOf(deps).storeDir, (choices) => {
325
+ if (choices.rawConsent !== 'granted' || choices.rawConsentVersion === version)
326
+ return;
327
+ choices.rawConsent = null;
328
+ choices.rawConsentVersion = null;
329
+ reset.done = true;
330
+ });
331
+ if (reset.done)
332
+ deps.io.stderr('Your earlier yes to the raw prompt was given under another covenant, so it no longer applies. Run oars pool raw to read the prompt and answer again.\n');
333
+ };
334
+ const screenIn = (body) => {
335
+ const screen = Reflect.get(body, 'screen');
336
+ const text = isObject(screen) ? textIn(screen, 'text') : undefined;
337
+ const link = isObject(screen) ? textIn(screen, 'link') : undefined;
338
+ const phrase = isObject(screen) ? textIn(screen, 'phrase') : undefined;
339
+ return text === undefined || link === undefined || phrase === undefined ? undefined : { text, link, phrase };
340
+ };
341
+ /** A person is here when input and the screen's stream are both terminals: a typed phrase counts only for a text that reached a screen. */
342
+ export const isTerminal = (deps) => Reflect.get(runtimeOf(deps).stdin, 'isTTY') === true && runtimeOf(deps).stderr?.isTTY === true;
343
+ /** The next line a person types, or `undefined` when the input ends first. The interface is closed after it, so the terminal is released. */
344
+ export const lineFrom = async (input) => {
345
+ const lines = createInterface({ input, terminal: false });
346
+ try {
347
+ for await (const line of lines)
348
+ return line;
349
+ return undefined;
236
350
  }
237
- if (Reflect.get(reply.body, 'accepted') !== true) {
238
- const title = `Read covenant ${version} of this pool, then rerun with --accept-covenant ${version}.`;
239
- throw new CliError(title, ExitCode.AUTH, DEPOSIT, { code: 'covenant_not_accepted', title, status: 403 });
351
+ finally {
352
+ lines.close();
240
353
  }
241
354
  };
242
355
  const excludedKey = (reason) => (reason === 'no-raw' ? 'noRaw' : reason === 'no-terms' ? 'noTerms' : reason);
@@ -257,7 +370,7 @@ const lockMessages = {
257
370
  /** The calls one batch takes, in order: one, or one per part when it is past a request's bytes. `outbox` prints these and `deposit --send` sends these, so what is printed is what is sent. */
258
371
  const callsOf = (token, bytes, hash) => {
259
372
  const parts = partsOf(bytes);
260
- return parts.map((piece, at) => ({ token, bytes: piece, ...(parts.length === 1 ? {} : { part: { index: at + 1, count: parts.length, batch: hash } }) }));
373
+ return parts.map((piece, at) => ({ ...(token === undefined ? {} : { token }), bytes: piece, ...(parts.length === 1 ? {} : { part: { index: at + 1, count: parts.length, batch: hash } }) }));
261
374
  };
262
375
  /** One batch to the pool: a batch past one request's bytes goes in parts, in order, each answered 202 until the last completes the deposit with 201. A part sent again is harmless, so the retry covers every part alike. */
263
376
  export const postBatch = async (deps, options) => {
@@ -285,8 +398,9 @@ export const postBatch = async (deps, options) => {
285
398
  * the caller sends only for a person who did. A part sent again replaces itself, and one for an object that stands writes nothing, so a copy that failed halfway is sent again whole.
286
399
  */
287
400
  export const postRaw = async (deps, options) => {
288
- const { server, token, hash, body, bytes, via } = options;
289
- const extra = [['oars-redaction-version', REDACTION_VERSION], ['oars-capture-version', CAPTURE_VERSION], ['oars-raw-consent', 'granted']];
401
+ const { server, token, hash, body, bytes, consent, via } = options;
402
+ // The yes names the covenant version it answered: the pool takes it only for the version in force.
403
+ const extra = [['oars-redaction-version', REDACTION_VERSION], ['oars-capture-version', CAPTURE_VERSION], ['oars-raw-consent', 'granted'], ['oars-raw-consent-version', consent]];
290
404
  const parts = rawParts(body);
291
405
  for (const [at, piece] of parts.entries()) {
292
406
  const reply = await ask(deps, DEPOSIT, server, `/v1/raw/${hash}/parts/${String(at + 1)}`, { token, bytes: piece, method: 'PUT', type: 'application/x-ndjson', extra }, via);
@@ -298,7 +412,9 @@ export const postRaw = async (deps, options) => {
298
412
  throw refusal(DEPOSIT, done);
299
413
  };
300
414
  const rawNote = (consent) => consent === 'granted' ? '' : consent === 'declined' ? 'You answered no to the raw prompt; oars pool raw --consent yes changes it.' : 'None is queued until you answer the raw prompt with oars pool raw --consent yes.';
301
- /** `deposit --send <hash>`: the send lock the nightly task takes too, then a skip for a hash already in `sent.log`, then the batch lock, then the covenant (accepted here or before, or nothing is sent), then the request. The body is read and hashed again just before the request. */
415
+ /** The exclusion terms in force now, re-read from the term file each time it is asked: empty when the file is missing or unreadable. */
416
+ export const currentTerms = (deps) => loadTerms(exclusionPaths(runtimeOf(deps).projectsRoot, deps.env).terms);
417
+ /** `deposit --send <hash>`: the send lock the detached sender (hook-bin.js nightly) takes too, then a skip for a hash already in `sent.log`, then the batch lock, then the covenant (accepted here or before, or nothing is sent), then the request. The body is read and hashed again just before the request. */
302
418
  const sendBatch = async (deps, options) => {
303
419
  const { outbox, server, token, hash } = options;
304
420
  const via = options.via ?? networkSender(deps, DEPOSIT);
@@ -309,17 +425,26 @@ const sendBatch = async (deps, options) => {
309
425
  return await withSendLock(outbox, async () => {
310
426
  // The rule the nightly follows: a hash `sent.log` holds is not posted again. Read under the lock, so two runs cannot both see it unsent.
311
427
  const sentAt = (await sentLog(outbox)).get(hash);
312
- if (sentAt !== undefined)
428
+ if (sentAt !== undefined) {
429
+ await retirePulled(outbox, DEPOSIT);
313
430
  return { document: { ok: true, server, hash, sent: false, sentAt }, text: `already sent ${hash} at ${sentAt}` };
314
- const locked = await lockedBatch(outbox, hash);
431
+ }
432
+ // A pulled batch is rescanned first: one that holds a term of the file now is removed, and with no terms to check against none is sent.
433
+ const scan = await rescanPulled(outbox, await currentTerms(deps));
434
+ if (scan.removed.includes(hash))
435
+ throw refuse('term_excluded', 'This batch holds a term of your exclusion list now, so it was removed from the outbox and not sent.');
436
+ const locked = await lockedAcross(outbox, hash);
315
437
  if (!locked.ok)
316
438
  throw refuse(locked.code, lockMessages[locked.code]);
317
- await openCovenant(deps, server, token, options.acceptCovenant, via);
439
+ if (!scan.open && (await heldAcross(outbox)).batches.some((batch) => batch.hash === hash && batch.pulled))
440
+ throw refuse('terms_missing', 'This batch was held by a cloud session, and your exclusion term file is missing or empty, so it cannot be checked and was not sent.');
441
+ await openCovenant(deps, server, token, options.acceptCovenant, options.region, via);
318
442
  const bytes = await unchangedBytes(locked.file, hash);
319
443
  if (bytes === undefined)
320
444
  throw refuse('batch_changed', lockMessages.batch_changed);
321
445
  await postBatch(deps, { server, token, bytes, hash, via });
322
446
  await markSent(outbox, hash, deps.now());
447
+ await retirePulled(outbox, DEPOSIT);
323
448
  return { document: { ok: true, server, hash, sent: true }, text: `Sent batch ${hash} to ${server}.` };
324
449
  });
325
450
  }
@@ -329,33 +454,63 @@ const sendBatch = async (deps, options) => {
329
454
  throw error;
330
455
  }
331
456
  };
457
+ /** The covenant version the pool names, or `undefined` when it cannot be asked or names none. The route is public, so no credential goes with it. */
458
+ const covenantOrUndefined = async (deps, server) => {
459
+ try {
460
+ const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', {});
461
+ return reply.status === 200 && isObject(reply.body) ? textIn(reply.body, 'version') : undefined;
462
+ }
463
+ catch {
464
+ return undefined;
465
+ }
466
+ };
467
+ const machineAccount = (machine) => `machine:${machine.machine ?? machine.hostClass}`;
468
+ /** The outbox key, and the skeleton `id`, of a machine's session: from the environment's session salt (per account, not per machine), else its own name standing in for the account, which a machine has none of. The holding route names its branch by the same key. */
469
+ export const machineKeyOf = (machine, session) => machine.sessionSalt === undefined ? contentHash([machineAccount(machine), session]) : machineIdOf(machine.sessionSalt, machine.remoteSession ?? session);
332
470
  /**
333
- * `oars pool deposit`: every session in the store that passes both session gates is built into a canonical batch in the outbox, and the command stops: nothing is sent. The gates run before any projection does: the folder
471
+ * `oars pool deposit`: every session in the store that passes both session gates is built into a canonical batch in the outbox, and the command stops: no session data is sent. The gates run before any projection does: the folder
334
472
  * allowlist, then the exclusion check. A session that fails either, or has no raw to scan, is counted and never built. A session is built only once it has ended: no transcript of it, main or subagent, has gained a line (the later of its capture
335
473
  * and its raw file's last write) for `session_idle_hours` (6 hours). One that has not is counted under `notEnded`, and any batch of it from an earlier pass is withdrawn. `--send <hash>` is the lock in `sendBatch`.
336
474
  */
337
475
  export const poolDeposit = async (deps, options) => {
338
476
  const runtime = runtimeOf(deps);
339
477
  const server = poolServer(deps.env, DEPOSIT, options.server);
340
- const { account, token } = await signedIn(deps, runtime.storeDir, server, DEPOSIT);
478
+ // A machine (`OARS_HOST_CLASS`, see machine.ts) builds at every Stop and sends nothing from here, so it signs nobody in: the account part of its batch key is its own name, and the holding route's `id` replaces it.
479
+ const machine = machineOf(deps.env);
480
+ if (machine !== undefined && options.send !== undefined)
481
+ throw usage('A machine builds batches into its outbox and sends none: --send is for a signed-in person.', DEPOSIT);
482
+ const { account, token } = machine === undefined ? await signedIn(deps, runtime.storeDir, server, DEPOSIT) : { account: machineAccount(machine), token: '' };
341
483
  // A client past `registry_max_age` builds no batch (spec Section 1.3): its sessions wait in `A` and nothing is lost. This comes before anything is written or sent.
342
484
  const expired = vocabularyExpired(deps);
343
485
  if (expired !== undefined)
344
486
  throw expired;
345
487
  // Accepting the covenant is a step of sending a batch: alone it would exit 0 as though something had been sent under it.
346
488
  if (options.send === undefined && options.acceptCovenant !== undefined)
347
- throw usage('--accept-covenant belongs to --send: it is accepted as a step of sending a batch. Nothing was accepted.', DEPOSIT);
489
+ throw usage('--accept-covenant belongs to --send: it names the covenant version expected when a person accepts before a send. Nothing was accepted.', DEPOSIT);
490
+ if (options.send === undefined && options.inEuEeaUk !== undefined)
491
+ throw usage(`${REGION_FLAG} belongs to --send: it answers the question asked when a person accepts before a send. Nothing was accepted.`, DEPOSIT);
492
+ const region = options.inEuEeaUk === undefined ? undefined : regionOf(options.inEuEeaUk);
493
+ if (options.inEuEeaUk !== undefined && region === undefined)
494
+ throw usage(`${REGION_FLAG} takes yes or no, not "${options.inEuEeaUk}". Nothing was accepted.`, DEPOSIT);
348
495
  const outbox = outboxFolder(runtime.storeDir, server);
496
+ await mkdirOwnerOnly(outbox);
349
497
  if (options.send !== undefined)
350
- return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, via: options.via });
498
+ return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, region, via: options.via });
351
499
  const { terms } = exclusionPaths(runtime.projectsRoot, deps.env);
352
500
  const saved = await readState(runtime.storeDir);
501
+ // A yes to the raw prompt counts only for the covenant it was given under, so an interactive build asks the pool which is in force; the nightly already knows and passes it. A pool that cannot be asked, or names none, leaves it unknown and the build goes on as before.
502
+ const covenant = options.covenant ?? (saved.pool.rawConsent === 'granted' ? await covenantOrUndefined(deps, server) : undefined);
353
503
  const names = localNames();
504
+ // The key an injected block's hash is made under: this machine's salt, so the same block hashes the same in every session and the key never leaves it.
505
+ const injectedKey = `injected-block:${(await localSalt(runtime.storeDir)).salt}`;
354
506
  const allow = compileAllow(saved.allow);
355
507
  const discarded = await discardStale(outbox);
356
508
  const counts = { sessions: 0, batched: 0, excluded: { folder: 0, term: 0, noRaw: 0, noTerms: 0, unreadable: 0 }, off: 0, raw: { copies: 0, bytes: 0, masked: 0 }, uncaptured: 0, notEnded: 0, refused: 0, refusedFields: [], discarded, withdrawn: 0 };
357
509
  // The one gate on a name: the operator's redactor and exclusion terms (t881). Everything else the pool classes itself.
358
- const gate = { redact: makeRedactor(await readRedactDomains(runtime.storeDir)), terms: await loadTerms(terms) };
510
+ const termList = await loadTerms(terms);
511
+ const gate = { redact: makeRedactor(await readRedactDomains(runtime.storeDir)), terms: termList };
512
+ // A Stop that names a session reads the others only to fill windows: a pass of the gate one of them made in the last minutes is not repeated.
513
+ const scanned = await scanCache(runtime.storeDir, contentHash(termList), deps.now);
359
514
  const built = new Set();
360
515
  const rawKept = new Set();
361
516
  // The bytes of the batches written in this pass: telemetry counts them, and the command's own document does not carry them.
@@ -370,29 +525,50 @@ export const poolDeposit = async (deps, options) => {
370
525
  counts.withdrawn += await removeBatchOf(outbox, key);
371
526
  await removeRawExcept(outbox, new Set(), key);
372
527
  };
373
- for (const records of sessionsOf(await loadStore(runtime.storeDir), roots)) {
374
- counts.sessions += 1;
528
+ // The sessions a Stop does not name that still pass the read-only gates: they only fill the windows of a delta, and are never counted, withdrawn, or built.
529
+ const history = [];
530
+ const localStore = await loadStore(runtime.storeDir);
531
+ for (const records of sessionsOf(localStore, roots)) {
532
+ // The Stop of a machine builds the one session that stopped: the others in the store belong to Stops of their own, so their batches stay as they are. They run the three read-only gates below and no more.
533
+ const named = options.session === undefined || records.main.session === options.session;
534
+ if (named)
535
+ counts.sessions += 1;
375
536
  // Hashed with the account, so a redeposit replaces its own batch and two accounts' copies of one session cannot be joined.
376
- const key = contentHash([account, records.main.session]);
537
+ const key = machine === undefined ? contentHash([account, records.main.session]) : machineKeyOf(machine, records.main.session);
377
538
  if (!allow.allows(records.main.project)) {
378
- counts.excluded.folder += 1;
379
- await withdraw(key);
539
+ if (named) {
540
+ counts.excluded.folder += 1;
541
+ await withdraw(key);
542
+ }
380
543
  continue;
381
544
  }
382
545
  // A folder switched off sends nothing from now on; what it sent before stays where it went.
383
546
  if (sendsOf(saved.pool, records.main.project) === 'off') {
384
- counts.off += 1;
385
- await withdraw(key);
547
+ if (named) {
548
+ counts.off += 1;
549
+ await withdraw(key);
550
+ }
551
+ continue;
552
+ }
553
+ if (!named && scanned.fresh(records.main.project, records.main.session)) {
554
+ history.push(records);
386
555
  continue;
387
556
  }
388
557
  const verdict = await checkSession(runtime, terms, records.main.project, records.main.session);
389
558
  if (verdict.reason !== 'clean') {
390
- counts.excluded[excludedKey(verdict.reason)] += 1;
391
- await withdraw(key);
559
+ if (named) {
560
+ counts.excluded[excludedKey(verdict.reason)] += 1;
561
+ await withdraw(key);
562
+ }
392
563
  continue;
393
564
  }
394
- // The batch of a session is built once the session and its subagents have ended; a session still in progress waits, and an earlier batch of it is withdrawn.
395
- if (!hasEnded(records, deps.now(), await lastWritten(runtime, records.main.project, records.main.session))) {
565
+ if (!named) {
566
+ scanned.note(records.main.project, records.main.session);
567
+ history.push(records);
568
+ continue;
569
+ }
570
+ // The batch of a session is built once the session and its subagents have ended; a session still in progress waits, and an earlier batch of it is withdrawn. A machine's Stop is the end of a turn, not of a session: its VM may be reclaimed before the session ends, so it builds the open session as it stands.
571
+ if (machine === undefined && !hasEnded(records, deps.now(), await lastWritten(runtime, records.main.project, records.main.session))) {
396
572
  counts.notEnded += 1;
397
573
  await withdraw(key);
398
574
  continue;
@@ -400,13 +576,18 @@ export const poolDeposit = async (deps, options) => {
400
576
  ready.push({ records, key });
401
577
  }
402
578
  // A delta is built from the sessions that passed, carried by the batch of the session that filled its after window, and marked sent only once that batch is written: a deposit that stops first builds it again.
403
- const placed = ready.flatMap(({ records }) => placedOf(records) ?? []);
579
+ // A cloud machine has no history of its own to compare a window against (every VM starts clean), so it carries none.
580
+ const placed = machine?.hostClass === 'cloud' ? [] : [...ready.map(({ records }) => records), ...history].flatMap((records) => placedOf(records) ?? []);
404
581
  const carried = carriedDeltas(account, placed, await sentDeltas(outbox));
405
582
  // The coverage bands (t871): the operator's two settings, read here, and the team band counted from the local git log. Nothing is looked up, and anything unread goes as unknown.
406
583
  const coverageOf = makeCoverage(runtime, await readCoverageSetting(runtime.storeDir), options.git ?? runGit);
584
+ // The recommendations each session was shown or held back from, read from the local rows with their windows, so a shown row stays shown at the pool and a session with none sends none (the pool reads that as unknown).
585
+ const recalls = deriveConsequences(localStore, await readRecalls(runtime.storeDir));
407
586
  for (const { records, key } of ready) {
408
587
  const delta = carried.get(records.main.session);
409
- const skeleton = skeletonOf(records, key, shellReader(vocabulary()), contentHash, delta?.entries, gate, await coverageOf(records.main));
588
+ // A machine builds the open session at every Stop and SessionEnd, so the one it names keeps what its own earlier batch carried and takes only what is new; any other deposit builds a session once.
589
+ const entries = machine !== undefined && options.session !== undefined ? keptDeltas(await heldDeltas(outbox, key), delta?.entries ?? []) : delta?.entries;
590
+ const skeleton = skeletonOf(records, key, shellReader(vocabulary()), contentHash, entries, gate, await coverageOf(records.main), recommendationsOf(recalls, records.main.session));
410
591
  if (skeleton === null) {
411
592
  counts.uncaptured += 1;
412
593
  await withdraw(key);
@@ -421,12 +602,15 @@ export const poolDeposit = async (deps, options) => {
421
602
  await withdraw(key);
422
603
  continue;
423
604
  }
605
+ // Noted before the batch is written: a person's sweep that runs between the two lines would delete a batch it cannot yet tell is a machine's.
606
+ if (machine !== undefined)
607
+ await noteMachineKey(outbox, key);
424
608
  builtBytes += (await writeBatch(outbox, key, parsed.skeleton)).bytes;
425
609
  // The raw copy comes only after both gates and only with a yes to the raw prompt and a folder not at skeleton only: each transcript of the session, redacted here, before it is queued.
426
- if (rawAllowed(saved.pool, records.main.project)) {
610
+ if (rawAllowed(saved.pool, records.main.project, covenant)) {
427
611
  let place = 0;
428
612
  for (const file of await transcriptFilesOf(runtime, records.main.project, records.main.session)) {
429
- const redacted = redactTranscript(await readFile(file.path, 'utf8'), saved.redactDomains, names);
613
+ const redacted = redactTranscript(await readFile(file.path, 'utf8'), saved.redactDomains, names, injectedKey);
430
614
  counts.raw.bytes += (await writeRaw(outbox, key, place, redacted.text)).bytes;
431
615
  counts.raw.copies += 1;
432
616
  counts.raw.masked += redacted.masked;
@@ -439,9 +623,13 @@ export const poolDeposit = async (deps, options) => {
439
623
  built.add(key);
440
624
  counts.batched += 1;
441
625
  }
442
- // The batch of a session or account this store no longer holds goes too.
443
- counts.withdrawn += await removeBatchesExcept(outbox, built);
444
- await removeRawExcept(outbox, rawKept);
626
+ // The batch of a session or account this store no longer holds goes too. A machine builds one session at a time and nothing here has the others in hand, so it sweeps nothing: nothing is withdrawn but what a gate refused.
627
+ if (machine === undefined) {
628
+ // The batches a machine's Stop built in this outbox are not this pass's to withdraw: their keys cannot be recomputed here. The key log is read after the folder is listed, so a batch a machine writes during the sweep is already in it.
629
+ counts.withdrawn += await removeBatchesExcept(outbox, async () => new Set([...built, ...(await machineKeys(outbox)), ...(await pulledAt(outbox))]));
630
+ await removeRawExcept(outbox, new Set([...rawKept, ...(await machineKeys(outbox))]));
631
+ }
632
+ await scanned.save();
445
633
  counts.refusedFields = [...refusedFields].sort();
446
634
  recordWire(deps, wireTally);
447
635
  const { folder, term, noRaw, noTerms, unreadable } = counts.excluded;
@@ -454,6 +642,7 @@ export const poolDeposit = async (deps, options) => {
454
642
  ...(counts.withdrawn === 0 ? [] : [`Removed ${String(counts.withdrawn)} ${counts.withdrawn === 1 ? 'batch' : 'batches'} of sessions that no longer pass the gates, have not ended, or are no longer in the store.`]),
455
643
  ...(discarded === 0 ? [] : [`Discarded ${String(discarded)} ${discarded === 1 ? 'batch' : 'batches'} built under an older vocabulary, field list, or skeleton format.`]),
456
644
  `Raw copies queued: ${String(counts.raw.copies)} (${String(counts.raw.bytes)} bytes, ${String(counts.raw.masked)} values masked). ${rawNote(saved.pool.rawConsent)}`.trim(),
645
+ ...(rawStale(saved.pool, covenant) ? [`Your yes to the raw prompt was given under covenant ${saved.pool.rawConsentVersion ?? 'none'}, not ${covenant ?? ''}, so no raw copy is queued. Run oars pool raw --consent yes to answer for the covenant now in force.`] : []),
457
646
  ...(counts.off === 0 ? [] : [`${String(counts.off)} ${counts.off === 1 ? 'session' : 'sessions'} in a folder switched off: nothing of them is queued.`]),
458
647
  'Run oars pool outbox to print exactly what would be sent.',
459
648
  ];
@@ -478,7 +667,7 @@ export const poolOutbox = async (deps, options) => {
478
667
  const server = poolServer(deps.env, OUTBOX, options.server);
479
668
  const { token } = await signedIn(deps, runtime.storeDir, server, OUTBOX);
480
669
  const outbox = outboxFolder(runtime.storeDir, server);
481
- const { batches, edited } = await heldBatches(outbox);
670
+ const { batches, edited } = await heldAcross(outbox);
482
671
  const masker = new Masker();
483
672
  masker.add(token);
484
673
  const sent = await sentLog(outbox);
@@ -562,10 +751,10 @@ export const poolFetch = async (deps, options) => {
562
751
  const { harnessVersion } = options;
563
752
  if (!isBundleVersion(harnessVersion))
564
753
  throw usage(`${harnessVersion} is not a public harness version.`, FETCH);
565
- const { token, signingKey } = await signedIn(deps, storeDir, server, FETCH);
754
+ const { token, signingKeys } = await signedIn(deps, storeDir, server, FETCH);
566
755
  const harness = await fetchHarness(deps, storeDir, options.harness);
567
756
  const file = replicaFile(storeDir, server, harness, harnessVersion);
568
- const kept = await keptReplica(file, signingKey, harness, harnessVersion);
757
+ const kept = await keptReplica(file, signingKeys, harness, harnessVersion);
569
758
  const credential = new Masker();
570
759
  credential.add(token);
571
760
  const reply = await ask(deps, FETCH, server, `/bundle/${harness}/${encodeURIComponent(harnessVersion)}/${encodeURIComponent(VOCAB_VERSION)}`, { token, show: credential, ...(kept?.etag === undefined ? {} : { ifNoneMatch: kept.etag }) });
@@ -592,7 +781,7 @@ export const poolFetch = async (deps, options) => {
592
781
  emit('fetch', { harness, fetches: 1 });
593
782
  throw refusal(FETCH, reply);
594
783
  }
595
- const verified = await verifyBundle(reply.body, signingKey, harness, harnessVersion);
784
+ const verified = await verifyBundle(reply.body, signingKeys, harness, harnessVersion);
596
785
  if (!verified.ok) {
597
786
  const title = `Refused the bundle: ${verified.reason}.`;
598
787
  emit('fetch', { harness, fetches: 1 });
@@ -610,13 +799,104 @@ export const poolFetch = async (deps, options) => {
610
799
  };
611
800
  };
612
801
  const RAW = 'pool-raw';
613
- /** The raw prompt, in plain words: shown by `oars pool raw` until it is answered, and what the nightly send relies on. Redaction is best effort and the text says so. */
802
+ /** The raw prompt, in plain words: shown by `oars pool raw` until it is answered, and what the detached sender relies on. Redaction is best effort and the text says so. */
614
803
  export const RAW_PROMPT = [
615
- 'Raw transcripts are separate from the covenant. If you say yes, then once the pool says your account accepted its covenant, the nightly task uploads a redacted copy of each full transcript of every folder you allowed, beside its skeleton, except a folder you set to skeleton only or off.',
804
+ 'Raw copies are covered by the covenant you accepted, including its license to sell them and to train on them. If you say yes, then once the pool says your account accepted its covenant, the sender that a session-end hook or any oars command starts (at most about once an hour) uploads a redacted copy of each full transcript of every folder you allowed, beside its skeleton, except a folder you set to skeleton only or off.',
805
+ 'Many Oars Co. may sell these copies and use them to train AI models, and it may let anyone it sells them to do the same.',
616
806
  'Redaction runs on this machine, before anything is queued, and it is best effort: it masks secret shapes it knows, emails, your username, home paths, environment values, and hosts, and it can miss a secret that has no recognizable shape or a name written in prose.',
617
807
  'It also does not catch a host with no dot, a public host name outside a link, quoted file contents and command output, a secret split across lines, images and attachments, a JSON env value, your username written another way, another person\'s hyphenated name in a project folder path, or an IPv6 address with a zone or inside a longer word, and a common-word account name is masked everywhere it appears.',
618
- 'Say no, or say nothing, and only skeletons go to the pool. Change your mind any time: oars pool raw --consent yes|no. A no stops later sends only; it cannot recall what was already sent.',
808
+ 'The blocks the harness adds on its own (instruction files, memory, skill lists, the system prompt) are replaced by a short marker, but a block in a shape the redactor does not list yet, a summary the harness writes for a session you left, a file it quotes after compaction, the echo of a command you ran, a reminder the harness puts in the middle of a message, and what an agent reports back are not, and stay as written; the project folder name and working directory on each line also stay readable, apart from the masking above.',
809
+ 'Yes is also your explicit consent to processing any health, belief, or other special category data about you that a copy holds, for these purposes. You can withdraw it at any time with oars pool raw --consent no, and that reaches copies already sent. Your access does not depend on it.',
810
+ 'Say no, or say nothing, and only skeletons go to the pool. Change your mind any time: oars pool raw --consent yes|no. A no stops later sends. If the GDPR or the UK GDPR protects you, it also reaches copies already sent. The pool starts erasing the raw copies it already holds for you at the pool\'s next nightly run; a large account, or a busy night, can take more than one night.',
619
811
  ].join('\n');
812
+ /** What a person types to say yes to the raw prompt: consent is a statement made at a terminal (GDPR Articles 7 and 9(2)(a)), not a flag a script can pass. */
813
+ export const RAW_PHRASE = 'I consent to sending raw copies';
814
+ /** The covenant version the pool has in force (`GET /v1/covenant` is public). With none published a yes cannot be tied to a covenant, so nothing is recorded. */
815
+ const covenantInForce = async (deps) => {
816
+ const reply = await ask(deps, RAW, poolServer(deps.env, RAW), '/v1/covenant', {});
817
+ if (reply.status !== 200 || !isObject(reply.body))
818
+ throw refusal(RAW, reply);
819
+ const version = textIn(reply.body, 'version');
820
+ if (version === undefined) {
821
+ const title = 'This pool has not published its covenant, so a yes to the raw prompt cannot be tied to one. Nothing was recorded.';
822
+ throw new CliError(title, ExitCode.AUTH, RAW, { code: 'deposits_closed', title, status: 403 });
823
+ }
824
+ return version;
825
+ };
826
+ /** A yes to the raw prompt is a person typing its phrase at a terminal. The covenant in force is read first, so no one is asked to type for a pool that has none; a wrong phrase, a closed terminal, or no terminal records nothing. Returns the covenant version the yes belongs to. */
827
+ const typedYes = async (deps) => {
828
+ const notRecorded = (title) => new CliError(title, ExitCode.AUTH, RAW, { code: 'raw_consent_not_given', title, status: 403 });
829
+ if (!isTerminal(deps))
830
+ throw notRecorded('Saying yes to the raw prompt takes a person at a terminal who reads it and types its phrase: run oars pool raw --consent yes from one. Nothing was recorded.');
831
+ const covenant = await covenantInForce(deps);
832
+ deps.io.stderr(`
833
+ ${RAW_PROMPT}
834
+
835
+ To say yes, type exactly: ${RAW_PHRASE}
836
+
837
+ > `);
838
+ const typed = await lineFrom(runtimeOf(deps).stdin);
839
+ if (typed?.trim() !== RAW_PHRASE)
840
+ throw notRecorded(`${typed === undefined ? 'No phrase was typed' : 'That was not the phrase'}. To say yes, type exactly: ${RAW_PHRASE}. Nothing was recorded.`);
841
+ return covenant;
842
+ };
843
+ /** The sign-in this machine holds for the pool, if any. */
844
+ const signInAtPool = async (deps, server) => (await loadSignIns(runtimeOf(deps).storeDir, RAW)).find((found) => found.server === server);
845
+ /** One call to `/v1/consent/raw` with the account credential; `fail` words what a pool that was not reached or refused means for the command. */
846
+ const consentCall = async (deps, server, token, init, fail) => {
847
+ let reply;
848
+ try {
849
+ reply = await ask(deps, RAW, server, '/v1/consent/raw', { ...init, token });
850
+ }
851
+ catch (error) {
852
+ if (!(error instanceof CliError) || error.exitCode !== ExitCode.UNREACHABLE)
853
+ throw error;
854
+ throw fail(`${error.message}.`, ExitCode.UNREACHABLE);
855
+ }
856
+ if (reply.status !== 200) {
857
+ const refused = refusal(RAW, reply);
858
+ throw fail(refused.message, refused.exitCode, refused.problem);
859
+ }
860
+ };
861
+ /**
862
+ * After a yes is typed, tells the pool before it is saved here: once an account has withdrawn, the pool takes a yes only through this call (GDPR Article 7(3)), so a yes it never heard would only be refused at the next send.
863
+ * A machine with no sign-in has no account to tell; the yes stands here and the sender asks the pool when there is one. Anything else that keeps the pool from hearing it throws, and nothing is recorded.
864
+ */
865
+ const grantAtPool = async (deps, covenant) => {
866
+ const server = poolServer(deps.env, RAW);
867
+ const entry = await signInAtPool(deps, server);
868
+ if (entry === undefined)
869
+ return 'no-account';
870
+ const notRecorded = (why, exitCode, problem) => {
871
+ const title = `The pool at ${server} did not take your yes: ${why} Nothing was recorded.`;
872
+ return new CliError(title, exitCode, RAW, { ...problem, title });
873
+ };
874
+ if (entry.expiresAt <= deps.now())
875
+ throw notRecorded('your sign-in has lapsed. Run oars pool login first.', ExitCode.AUTH);
876
+ await consentCall(deps, server, entry.token, { method: 'PUT', body: { version: covenant } }, notRecorded);
877
+ return 'told';
878
+ };
879
+ /**
880
+ * After a no is saved here, tells the pool so it stops accepting raw copies from this account and records the withdrawal (GDPR Article 7(3)). A machine with no sign-in at this pool has no account there to tell, which is no failure unless it held a yes: then the pool may still hold that yes, and the command says it was not told.
881
+ * Anything else that keeps the pool from being told (unreachable, a lapsed sign-in, a refusal) throws, with the saved answer standing: the command exits nonzero and a rerun retries.
882
+ */
883
+ const withdrawAtPool = async (deps, heldYes) => {
884
+ const server = poolServer(deps.env, RAW);
885
+ const entry = await signInAtPool(deps, server);
886
+ const notTold = (why, exitCode, problem) => {
887
+ const title = `Your no is saved here, so no raw copy leaves this machine, but the pool at ${server} was not told: ${why} Run oars pool raw --consent no again to retry.`;
888
+ return new CliError(title, exitCode, RAW, { ...problem, title });
889
+ };
890
+ if (entry === undefined) {
891
+ if (heldYes)
892
+ throw notTold('this machine is signed out. Run oars pool login first.', ExitCode.AUTH);
893
+ return 'no-account';
894
+ }
895
+ if (entry.expiresAt <= deps.now())
896
+ throw notTold('your sign-in has lapsed. Run oars pool login first.', ExitCode.AUTH);
897
+ await consentCall(deps, server, entry.token, { method: 'DELETE' }, notTold);
898
+ return 'told';
899
+ };
620
900
  /** `oars pool raw`: shows the raw prompt and the answer so far; `--consent yes|no` records the answer; `--folder <entry> --mode all|skeleton|off` sets what one folder may send. */
621
901
  export const poolRaw = async (deps, options) => {
622
902
  const runtime = runtimeOf(deps);
@@ -627,9 +907,19 @@ export const poolRaw = async (deps, options) => {
627
907
  if (options.mode !== undefined && options.mode !== 'all' && options.mode !== 'skeleton' && options.mode !== 'off')
628
908
  throw usage('--mode takes all, skeleton, or off.', RAW);
629
909
  const { folder, mode } = options;
910
+ // A yes is tied to the covenant in force when it is given, so the pool is asked which that is before anything is stored. A no needs none.
911
+ const covenant = options.consent === 'yes' ? await typedYes(deps) : null;
912
+ const heardYes = covenant === null ? undefined : await grantAtPool(deps, covenant);
913
+ // What this machine held before the save: a no on a signed-out machine that held a yes, or whose earlier no never reached the pool, leaves the pool's yes standing.
914
+ const before = { yes: false };
630
915
  const choices = await updatePoolChoices(runtime.storeDir, (current) => {
631
- if (options.consent !== undefined)
916
+ before.yes = current.rawConsent === 'granted' || current.withdrawalPending;
917
+ if (options.consent !== undefined) {
632
918
  current.rawConsent = options.consent === 'yes' ? 'granted' : 'declined';
919
+ current.rawConsentVersion = covenant;
920
+ // A no the pool has not heard stays pending until it does; a yes replaces whatever the pool was owed.
921
+ current.withdrawalPending = options.consent === 'no' && before.yes;
922
+ }
633
923
  if (folder !== undefined) {
634
924
  if (mode === 'all')
635
925
  Reflect.deleteProperty(current.folders, folder);
@@ -637,10 +927,23 @@ export const poolRaw = async (deps, options) => {
637
927
  current.folders[folder] = mode;
638
928
  }
639
929
  });
640
- const answer = choices.rawConsent === null ? 'not answered yet: only skeletons go to the pool' : choices.rawConsent === 'granted' ? 'yes: redacted raw copies leave' : 'no: only skeletons go to the pool';
930
+ // The local answer is saved above, so a pool that cannot be told leaves it standing and the command fails: a rerun retries.
931
+ const told = options.consent === 'no' ? await withdrawAtPool(deps, before.yes) : heardYes;
932
+ // The pool heard the withdrawal: nothing is owed it any more.
933
+ if (told === 'told' && options.consent === 'no') {
934
+ await updatePoolChoices(runtime.storeDir, (current) => {
935
+ current.withdrawalPending = false;
936
+ });
937
+ }
938
+ const answer = choices.rawConsent === null ? 'not answered yet: only skeletons go to the pool' : choices.rawConsent === 'granted' ? `yes, given under covenant ${choices.rawConsentVersion ?? 'none'}: redacted raw copies leave while that covenant is in force` : 'no: only skeletons go to the pool';
641
939
  const switched = Object.entries(choices.folders).map(([entry, set]) => ` ${entry}: ${set === 'off' ? 'nothing sent to the pool' : 'skeletons only'}`);
642
- const lines = [...(choices.rawConsent === null ? [RAW_PROMPT, ''] : []), `Raw copies: ${answer}.`, ...(switched.length === 0 ? [] : ['Folders switched down:', ...switched])];
643
- return { document: { ok: true, rawConsent: choices.rawConsent, folders: choices.folders }, text: lines.join('\n') };
940
+ const toldLine = told === undefined
941
+ ? []
942
+ : options.consent === 'yes'
943
+ ? [told === 'told' ? 'The pool heard it: raw copies are accepted again from this account for the covenant in force.' : 'This machine holds no sign-in to the pool, so it told the pool nothing. Sign in with oars pool login; if you withdrew before, run this again then.']
944
+ : [told === 'told' ? 'The pool was told: it no longer accepts raw copies from this account, keeps a record of the withdrawal, and starts erasing at the pool\'s next nightly run; a large account, or a busy night, can take more than one night.' : 'This machine holds no sign-in to the pool, so it told the pool nothing. If you said yes from another machine, sign in with oars pool login and run this again.'];
945
+ const lines = [...(choices.rawConsent === null ? [RAW_PROMPT, ''] : []), `Raw copies: ${answer}.`, ...toldLine, ...(switched.length === 0 ? [] : ['Folders switched down:', ...switched])];
946
+ return { document: { ok: true, rawConsent: choices.rawConsent, rawConsentVersion: choices.rawConsentVersion, folders: choices.folders, ...(told === undefined ? {} : { pool: told }) }, text: lines.join('\n') };
644
947
  };
645
948
  const COVERAGE = 'pool-coverage';
646
949
  /** A named value from a closed list, or a usage error that lists the members. */
@@ -667,3 +970,144 @@ export const poolCoverage = async (deps, options) => {
667
970
  const lines = [`Organization size: ${setting.orgSize}`, `Repository visibility: ${setting.repoVisibility}`, 'Both stay on this machine until a deposit states them as bands; nothing is looked up. The team band is counted from each repository\'s local git log at deposit.'];
668
971
  return { document: { ok: true, saved: changed, ...setting }, text: lines.join('\n') };
669
972
  };
973
+ /**
974
+ * `oars pool prove`: the account proves control of itself with its sign-in token alone (`POST /v1/account/proof`, never a machine key), and the pool stores the time. It prints the account and the time to send, in reply to the operator's request for proof, so the operator can compare them with the stored row (GDPR Art. 12(6)). A lapsed sign-in says to run `oars pool login` first; a closed account proves by that login instead.
975
+ */
976
+ export const poolProve = async (deps, options) => {
977
+ const { storeDir } = runtimeOf(deps);
978
+ const server = poolServer(deps.env, PROVE, options.server);
979
+ const { token } = await signedIn(deps, storeDir, server, PROVE);
980
+ const credential = new Masker();
981
+ credential.add(token);
982
+ const reply = await ask(deps, PROVE, server, '/v1/account/proof', { body: {}, token, show: credential });
983
+ if (reply.status !== 200)
984
+ throw refusal(PROVE, reply);
985
+ const account = isObject(reply.body) ? textIn(reply.body, 'account') : undefined;
986
+ const provedAt = isObject(reply.body) ? numberIn(reply.body, 'provedAt') : undefined;
987
+ if (account === undefined || provedAt === undefined)
988
+ throw new CliError('The pool answered a proof with no account or proof time.', ExitCode.UNEXPECTED, PROVE);
989
+ return {
990
+ document: { ok: true, server, account, provedAt },
991
+ text: `Proof recorded for ${account} at ${new Date(provedAt).toISOString()}. Send these two lines in reply to the privacy@manyoars.com message that asked you for proof:
992
+ ${proofLines(account, provedAt)}`,
993
+ };
994
+ };
995
+ /** What the pool's export pages say they are (`export.ts` in the pool). A page of another format or version is refused, not guessed at. */
996
+ const EXPORT_FORMAT = 'oars-pool-account-export';
997
+ const EXPORT_VERSION = 1;
998
+ /** One page's file, numbered from 1 in the order the pool gave the pages. */
999
+ const exportPageFile = (page) => `account-export-${String(page).padStart(4, '0')}.json`;
1000
+ const EXPORT_PAGE_FILE = /^account-export-(\d{4,})\.json$/;
1001
+ const exportPageOf = (body) => {
1002
+ if (!isObject(body) || Reflect.get(body, 'format') !== EXPORT_FORMAT)
1003
+ return 'is not an account export page';
1004
+ const version = Reflect.get(body, 'version');
1005
+ if (version !== EXPORT_VERSION)
1006
+ return `is version ${String(version)}, and this client reads version ${String(EXPORT_VERSION)}`;
1007
+ const rows = Reflect.get(body, 'rows');
1008
+ const next = Reflect.get(body, 'next');
1009
+ if (!Array.isArray(rows) || (next !== null && typeof next !== 'string'))
1010
+ return 'carries no rows or no cursor';
1011
+ const held = rows;
1012
+ return { rows: held, next, section: Reflect.get(body, 'section') };
1013
+ };
1014
+ const plural = (count, one, many = `${one}s`) => `${String(count)} ${count === 1 ? one : many}`;
1015
+ const answered = ({ status, body }) => {
1016
+ const message = isObject(body) ? textIn(body, 'message') : undefined;
1017
+ return `HTTP ${String(status)}${message === undefined ? '' : `: ${message}`}`;
1018
+ };
1019
+ /** `oars pool export`: the person's own copy of what the pool holds about their account, saved to a folder (GDPR Articles 15(3) and 20). It follows the pool's cursor page by page and keeps each page as a file; with `raw` it also fetches each raw object the ledger lists that still stands. It writes nothing to the pool. A failed page stops the walk (there is no cursor past it); a failed object does not stop the others. Every failure is named in the error, with what was saved. */
1020
+ export const poolExport = async (deps, options) => {
1021
+ const { storeDir } = runtimeOf(deps);
1022
+ const server = poolServer(deps.env, EXPORT, options.server);
1023
+ const { token } = await signedIn(deps, storeDir, server, EXPORT);
1024
+ const folder = resolve(options.out);
1025
+ await mkdirOwnerOnly(folder);
1026
+ const credential = new Masker();
1027
+ credential.add(token);
1028
+ const failures = [];
1029
+ const standing = [];
1030
+ let erased = 0;
1031
+ let rows = 0;
1032
+ let pages = 0;
1033
+ const cursors = new Set();
1034
+ let cursor = null;
1035
+ do {
1036
+ const number = pages + 1;
1037
+ const reply = await ask(deps, EXPORT, server, cursor === null ? '/v1/account/export' : `/v1/account/export?cursor=${encodeURIComponent(cursor)}`, { token, show: credential }, options.via);
1038
+ if (reply.status !== 200) {
1039
+ failures.push({ what: `page ${String(number)} (${answered(reply)})`, reply });
1040
+ break;
1041
+ }
1042
+ const read = exportPageOf(reply.body);
1043
+ if (typeof read === 'string') {
1044
+ failures.push({ what: `page ${String(number)} ${read}`, code: 'export_invalid' });
1045
+ break;
1046
+ }
1047
+ await writeOwnerOnly(join(folder, exportPageFile(number)), `${JSON.stringify(reply.body, null, 2)}\n`);
1048
+ pages = number;
1049
+ rows += read.rows.length;
1050
+ for (const row of read.section === 'rawLedger' ? read.rows : []) {
1051
+ const hash = isObject(row) ? textIn(row, 'sha256') : undefined;
1052
+ if (hash === undefined || !/^[0-9a-f]{64}$/.test(hash))
1053
+ failures.push({ what: `page ${String(number)} lists a raw object with no SHA-256`, code: 'export_invalid' });
1054
+ else if (isObject(row) && Reflect.get(row, 'tombstone') !== null)
1055
+ erased += 1;
1056
+ else
1057
+ standing.push(hash);
1058
+ }
1059
+ cursor = read.next;
1060
+ if (cursor !== null && cursors.has(cursor)) {
1061
+ failures.push({ what: `page ${String(number)} named a cursor the pool had already given`, code: 'export_invalid' });
1062
+ break;
1063
+ }
1064
+ if (cursor !== null)
1065
+ cursors.add(cursor);
1066
+ } while (cursor !== null);
1067
+ let saved = 0;
1068
+ const rawFolder = join(folder, 'raw');
1069
+ if (options.raw === true)
1070
+ await mkdirOwnerOnly(rawFolder);
1071
+ if (options.raw === true) {
1072
+ for (const hash of standing) {
1073
+ const reply = await ask(deps, EXPORT, server, `/v1/account/export/raw/${hash}`, { token, show: credential, binary: true }, options.via);
1074
+ if (reply.status !== 200)
1075
+ failures.push({ what: `raw object ${hash} (${answered(reply)})`, reply });
1076
+ else if (!(reply.body instanceof Uint8Array))
1077
+ failures.push({ what: `raw object ${hash} came with no bytes`, code: 'export_invalid' });
1078
+ else if (createHash('sha256').update(reply.body).digest('hex') !== hash)
1079
+ failures.push({ what: `raw object ${hash} did not hash to its name`, code: 'raw_unverified' });
1080
+ else {
1081
+ await writeOwnerOnly(join(rawFolder, hash), reply.body);
1082
+ saved += 1;
1083
+ }
1084
+ }
1085
+ }
1086
+ const counts = [plural(pages, 'page'), ...(options.raw === true ? [plural(saved, 'raw object')] : [])];
1087
+ const lead = failures[0];
1088
+ if (lead !== undefined) {
1089
+ const message = `The export to ${folder} is incomplete: ${failures.map(({ what }) => what).join('; ')}. Saved ${counts.join(' and ')} there.`;
1090
+ const refused = lead.reply === undefined ? undefined : refusal(EXPORT, lead.reply);
1091
+ throw new CliError(message, refused?.exitCode ?? ExitCode.INVALID, EXPORT, { ...(refused?.problem ?? {}), title: message, ...(lead.code === undefined ? {} : { code: lead.code }) });
1092
+ }
1093
+ // A shorter export than the last one leaves no page of the older one to be read as part of this.
1094
+ for (const name of await readdir(folder)) {
1095
+ const found = EXPORT_PAGE_FILE.exec(name);
1096
+ if (found !== null && Number(found[1]) > pages)
1097
+ await rm(join(folder, name));
1098
+ }
1099
+ // The same for raw copies: one whose object the pool has since erased must not read as a copy of something it still holds. Only a file named like a SHA-256 is ours to remove.
1100
+ if (options.raw === true) {
1101
+ const held = new Set(standing);
1102
+ for (const name of await readdir(rawFolder)) {
1103
+ if (/^[0-9a-f]{64}$/.test(name) && !held.has(name))
1104
+ await rm(join(rawFolder, name));
1105
+ }
1106
+ }
1107
+ const ledger = standing.length + erased;
1108
+ const rawNote = options.raw === true ? (erased > 0 ? ` ${plural(erased, 'raw object')} listed in the ledger ${erased === 1 ? 'was' : 'were'} erased, so no copy of ${erased === 1 ? 'it' : 'them'} remains.` : '') : standing.length > 0 ? ` The ledger lists ${plural(ledger, 'raw object')}: run again with --raw to save your copies of the ${standing.length === 1 ? 'one' : 'ones'} that stand.` : '';
1109
+ return {
1110
+ document: { ok: true, server, folder, pages, rows, raw: { requested: options.raw === true, saved, erased } },
1111
+ text: `Saved your pool export to ${folder}: ${[...counts, plural(rows, 'row')].join(', ')}.${rawNote}`,
1112
+ };
1113
+ };