@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.
- package/README.md +82 -32
- package/dist/bin.js +3 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +206 -20
- package/dist/club.d.ts +19 -0
- package/dist/club.js +132 -0
- package/dist/gate/hook-fill-main.js +1 -1
- package/dist/gate/hook-fill.d.ts +1 -1
- package/dist/gate/hook-fill.js +1 -1
- package/dist/gate/hook-main.js +7 -5
- package/dist/gate/hook.d.ts +12 -1
- package/dist/gate/hook.js +38 -7
- package/dist/hook-bin.js +23 -4
- package/dist/issuer.d.ts +1 -1
- package/dist/issuer.js +2 -2
- package/dist/main.js +5 -0
- package/dist/node-floor.d.ts +10 -0
- package/dist/node-floor.js +23 -0
- package/dist/oars-hook.d.ts +2 -0
- package/dist/oars-hook.js +7 -0
- package/dist/sessions/allow.d.ts +1 -0
- package/dist/sessions/allow.js +1 -1
- package/dist/sessions/call-shape.d.ts +111 -0
- package/dist/sessions/call-shape.js +464 -0
- package/dist/sessions/candidates.d.ts +65 -0
- package/dist/sessions/candidates.js +236 -0
- package/dist/sessions/card-command.d.ts +24 -0
- package/dist/sessions/card-command.js +71 -0
- package/dist/sessions/card-font.d.ts +6 -0
- package/dist/sessions/card-font.js +36 -0
- package/dist/sessions/card-line.d.ts +30 -0
- package/dist/sessions/card-line.js +105 -0
- package/dist/sessions/card-render.d.ts +15 -0
- package/dist/sessions/card-render.js +146 -0
- package/dist/sessions/card.d.ts +59 -0
- package/dist/sessions/card.js +107 -0
- package/dist/sessions/cell-increments.d.ts +14 -3
- package/dist/sessions/cell-increments.js +79 -12
- package/dist/sessions/cells.d.ts +42 -4
- package/dist/sessions/cells.js +72 -18
- package/dist/sessions/codex-hook-bin.d.ts +1 -0
- package/dist/sessions/codex-hook-bin.js +24 -0
- package/dist/sessions/codex-hook-install.d.ts +21 -0
- package/dist/sessions/codex-hook-install.js +47 -0
- package/dist/sessions/codex-hook-view.d.ts +20 -0
- package/dist/sessions/codex-hook-view.js +20 -0
- package/dist/sessions/codex-hook.d.ts +77 -0
- package/dist/sessions/codex-hook.js +255 -0
- package/dist/sessions/codex.d.ts +9 -0
- package/dist/sessions/codex.js +104 -18
- package/dist/sessions/command.d.ts +17 -2
- package/dist/sessions/command.js +145 -33
- package/dist/sessions/copilot.d.ts +3 -0
- package/dist/sessions/copilot.js +12 -4
- package/dist/sessions/derive.js +2 -1
- package/dist/sessions/empty-state.d.ts +17 -0
- package/dist/sessions/empty-state.js +38 -0
- package/dist/sessions/exclusion.js +48 -3
- package/dist/sessions/extract.d.ts +16 -3
- package/dist/sessions/extract.js +281 -24
- package/dist/sessions/gemini.d.ts +2 -0
- package/dist/sessions/gemini.js +29 -2
- package/dist/sessions/harness-files.d.ts +27 -0
- package/dist/sessions/harness-files.js +109 -0
- package/dist/sessions/head-only.d.ts +7 -0
- package/dist/sessions/head-only.js +21 -0
- package/dist/sessions/hook-calls.js +4 -3
- package/dist/sessions/hooks.d.ts +1 -1
- package/dist/sessions/hooks.js +6 -2
- package/dist/sessions/identity.d.ts +6 -0
- package/dist/sessions/injected.d.ts +13 -0
- package/dist/sessions/injected.js +212 -0
- package/dist/sessions/install.d.ts +31 -20
- package/dist/sessions/install.js +80 -82
- package/dist/sessions/load-status.d.ts +41 -0
- package/dist/sessions/load-status.js +156 -0
- package/dist/sessions/machine-child-deps.d.ts +5 -0
- package/dist/sessions/machine-child-deps.js +83 -0
- package/dist/sessions/machine-child.d.ts +43 -0
- package/dist/sessions/machine-child.js +120 -0
- package/dist/sessions/machine-credential.d.ts +10 -0
- package/dist/sessions/machine-credential.js +20 -0
- package/dist/sessions/machine-enroll.d.ts +47 -0
- package/dist/sessions/machine-enroll.js +230 -0
- package/dist/sessions/machine-hold.d.ts +49 -0
- package/dist/sessions/machine-hold.js +88 -0
- package/dist/sessions/machine-ids.d.ts +6 -0
- package/dist/sessions/machine-ids.js +9 -0
- package/dist/sessions/machine-notice.d.ts +21 -0
- package/dist/sessions/machine-notice.js +89 -0
- package/dist/sessions/machine-pull.d.ts +40 -0
- package/dist/sessions/machine-pull.js +220 -0
- package/dist/sessions/machine-repo.d.ts +7 -0
- package/dist/sessions/machine-repo.js +30 -0
- package/dist/sessions/machine-scan-cache.d.ts +12 -0
- package/dist/sessions/machine-scan-cache.js +47 -0
- package/dist/sessions/machine-send.d.ts +12 -0
- package/dist/sessions/machine-send.js +156 -0
- package/dist/sessions/machine-stop.d.ts +22 -0
- package/dist/sessions/machine-stop.js +53 -0
- package/dist/sessions/machine.d.ts +41 -0
- package/dist/sessions/machine.js +89 -0
- package/dist/sessions/opencode.d.ts +2 -0
- package/dist/sessions/opencode.js +6 -0
- package/dist/sessions/otel.d.ts +21 -0
- package/dist/sessions/otel.js +109 -38
- package/dist/sessions/owner-only.d.ts +7 -0
- package/dist/sessions/owner-only.js +34 -0
- package/dist/sessions/pi.d.ts +13 -1
- package/dist/sessions/pi.js +27 -13
- package/dist/sessions/pool-books.d.ts +1 -1
- package/dist/sessions/pool-books.js +1 -1
- package/dist/sessions/pool-choices.d.ts +4 -2
- package/dist/sessions/pool-choices.js +4 -2
- package/dist/sessions/pool-command.d.ts +46 -6
- package/dist/sessions/pool-command.js +577 -133
- package/dist/sessions/pool-delta.d.ts +7 -0
- package/dist/sessions/pool-delta.js +28 -5
- package/dist/sessions/pool-due.d.ts +43 -0
- package/dist/sessions/pool-due.js +113 -0
- package/dist/sessions/pool-nightly.d.ts +9 -1
- package/dist/sessions/pool-nightly.js +65 -21
- package/dist/sessions/pool-outbox.d.ts +51 -5
- package/dist/sessions/pool-outbox.js +217 -24
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.js +4 -3
- package/dist/sessions/pool-replica.d.ts +4 -4
- package/dist/sessions/pool-replica.js +26 -7
- package/dist/sessions/pool-send-lock.js +1 -1
- package/dist/sessions/pool-sent.d.ts +2 -0
- package/dist/sessions/pool-sent.js +21 -4
- package/dist/sessions/pool-wire.d.ts +172 -12
- package/dist/sessions/pool-wire.js +284 -30
- package/dist/sessions/pool.js +4 -2
- package/dist/sessions/public-vocabulary.d.ts +5 -0
- package/dist/sessions/public-vocabulary.js +16 -0
- package/dist/sessions/read-call-shape.js +12 -6
- package/dist/sessions/read-fit.d.ts +5 -0
- package/dist/sessions/read-fit.js +23 -2
- package/dist/sessions/read.d.ts +1 -1
- package/dist/sessions/recall-consequence.d.ts +2 -2
- package/dist/sessions/recall-consequence.js +14 -9
- package/dist/sessions/recall-object.d.ts +9 -2
- package/dist/sessions/receipt.d.ts +8 -0
- package/dist/sessions/receipt.js +78 -0
- package/dist/sessions/recovery-calls.d.ts +3 -2
- package/dist/sessions/recovery-calls.js +9 -3
- package/dist/sessions/redact.d.ts +2 -2
- package/dist/sessions/redact.js +116 -14
- package/dist/sessions/reversion.d.ts +8 -0
- package/dist/sessions/reversion.js +114 -0
- package/dist/sessions/schema.d.ts +78 -4
- package/dist/sessions/schema.js +31 -1
- package/dist/sessions/session-shape.d.ts +2 -1
- package/dist/sessions/session-shape.js +15 -0
- package/dist/sessions/sources.d.ts +9 -0
- package/dist/sessions/sources.js +16 -5
- package/dist/sessions/steps.js +3 -2
- package/dist/sessions/store.d.ts +41 -2
- package/dist/sessions/store.js +68 -16
- package/dist/sessions/try-demo.d.ts +8 -0
- package/dist/sessions/try-demo.js +90 -0
- package/dist/sessions/try.d.ts +13 -0
- package/dist/sessions/try.js +50 -0
- package/dist/sessions/vscode-copilot.d.ts +4 -0
- package/dist/sessions/vscode-copilot.js +25 -18
- package/dist/telemetry/command.js +12 -6
- package/dist/telemetry/consent.d.ts +9 -3
- package/dist/telemetry/consent.js +25 -9
- package/dist/telemetry/fields.d.ts +11 -0
- package/dist/telemetry/fields.js +56 -0
- package/dist/telemetry/measures.d.ts +13 -0
- package/dist/telemetry/measures.js +30 -0
- package/dist/telemetry/observe.d.ts +15 -2
- package/dist/telemetry/observe.js +34 -7
- package/dist/telemetry/region.d.ts +6 -0
- package/dist/telemetry/region.js +28 -0
- package/dist/telemetry/report.js +1 -1
- package/dist/telemetry/schema.d.ts +3 -0
- package/dist/telemetry/schema.js +11 -1
- package/dist/telemetry/send.d.ts +5 -2
- package/dist/telemetry/send.js +15 -7
- package/dist/telemetry/words.d.ts +6 -0
- package/dist/telemetry/words.js +6 -0
- package/package.json +23 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// What the detached child of a machine Stop does (`hook-bin.js machine-child <input>`, started by `machine-stop.ts`): capture the session's transcript, build its batch into the outbox, then hold the batch on the session's own branch. One child at a time per outbox, on the send
|
|
2
|
+
// lock: a Stop that finds the lock held writes the dirty mark (`send.dirty`, one input per line) and leaves, and the holder takes the mark and runs those sessions too before it lets go, so the last Stop of a burst is
|
|
3
|
+
// always built from the transcript as it ended. Before each extraction the child waits for the transcript to stop growing, because Stop can fire while the harness is still writing the last lines.
|
|
4
|
+
import { rename, readFile, rm, stat } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { appendOwnerOnly, mkdirOwnerOnly } from './owner-only.js';
|
|
7
|
+
import { SendLockBusy, withSendLock } from './pool-send-lock.js';
|
|
8
|
+
export const SETTLE = { quietMs: 2000, maxMs: 15_000, pollMs: 250 };
|
|
9
|
+
const DIRTY = 'send.dirty';
|
|
10
|
+
/** The input a Stop forwarded, or `undefined` when it names no session or transcript. */
|
|
11
|
+
export const parseChildInput = (text) => {
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(text);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
20
|
+
return undefined;
|
|
21
|
+
const session = Reflect.get(parsed, 'session_id');
|
|
22
|
+
const transcript = Reflect.get(parsed, 'transcript_path');
|
|
23
|
+
const cwd = Reflect.get(parsed, 'cwd');
|
|
24
|
+
if (typeof session !== 'string' || session === '' || typeof transcript !== 'string' || transcript === '')
|
|
25
|
+
return undefined;
|
|
26
|
+
return { session, transcript, cwd: typeof cwd === 'string' && cwd !== '' ? cwd : undefined };
|
|
27
|
+
};
|
|
28
|
+
/** Waits until the transcript has been the same size for `quietMs`, at most `maxMs` in all. */
|
|
29
|
+
export const settle = async (path, deps, times = SETTLE) => {
|
|
30
|
+
const start = deps.now();
|
|
31
|
+
let last = await deps.size(path);
|
|
32
|
+
let changed = start;
|
|
33
|
+
for (;;) {
|
|
34
|
+
const at = deps.now();
|
|
35
|
+
if (at - changed >= times.quietMs)
|
|
36
|
+
return 'quiet';
|
|
37
|
+
if (at - start >= times.maxMs)
|
|
38
|
+
return 'timeout';
|
|
39
|
+
await deps.sleep(times.pollMs);
|
|
40
|
+
const size = await deps.size(path);
|
|
41
|
+
if (size !== last) {
|
|
42
|
+
last = size;
|
|
43
|
+
changed = deps.now();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/** The session's own latest input wins, in the order the sessions were first named. */
|
|
48
|
+
const byLatest = (inputs) => [...new Map(inputs.map((input) => [input.session, input])).values()];
|
|
49
|
+
/** One line in the wire shape a Stop forwards, so the holder reads a mark with the one parser. */
|
|
50
|
+
const markDirty = async (outbox, input) => {
|
|
51
|
+
// The mark holds a transcript path and a folder, so it is owner-only like the rest of the outbox.
|
|
52
|
+
await mkdirOwnerOnly(outbox);
|
|
53
|
+
await appendOwnerOnly(join(outbox, DIRTY), `${JSON.stringify({ session_id: input.session, transcript_path: input.transcript, cwd: input.cwd })}\n`);
|
|
54
|
+
};
|
|
55
|
+
const hasDirty = (outbox) => stat(join(outbox, DIRTY)).then(() => true, () => false);
|
|
56
|
+
/** Claims the mark by renaming it, so two takers never read one mark and a Stop writing after the claim starts a mark of its own. */
|
|
57
|
+
const takeDirty = async (outbox) => {
|
|
58
|
+
const claimed = join(outbox, `${DIRTY}.${String(process.pid)}`);
|
|
59
|
+
try {
|
|
60
|
+
await rename(join(outbox, DIRTY), claimed);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const lines = (await readFile(claimed, 'utf8').catch(() => '')).split('\n');
|
|
66
|
+
await rm(claimed, { force: true });
|
|
67
|
+
return lines.flatMap((line) => {
|
|
68
|
+
const input = line === '' ? undefined : parseChildInput(line);
|
|
69
|
+
return input === undefined ? [] : [input];
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
/** One session: check its repository against the allow list, wait for the transcript to settle, capture it, build its batch, and hold the batch. A session off the list is not captured, so its transcript is never copied into the store; nothing is withdrawn either. */
|
|
73
|
+
const extract = async (deps, input) => {
|
|
74
|
+
if (!(await deps.allowed(input)))
|
|
75
|
+
return;
|
|
76
|
+
await settle(input.transcript, deps, deps.settle);
|
|
77
|
+
await deps.capture(input);
|
|
78
|
+
await deps.build(input);
|
|
79
|
+
await deps.hold(input);
|
|
80
|
+
await deps.send?.();
|
|
81
|
+
};
|
|
82
|
+
/** Takes the lock without waiting and runs `work`, then every session marked dirty meanwhile; `false` when another child holds it. The first failure is rethrown once every session has had its turn. */
|
|
83
|
+
const locked = async (deps, work) => {
|
|
84
|
+
try {
|
|
85
|
+
await withSendLock(deps.outbox, async () => {
|
|
86
|
+
let failure;
|
|
87
|
+
for (let pending = byLatest([...work, ...(await takeDirty(deps.outbox))]); pending.length > 0; pending = byLatest(await takeDirty(deps.outbox))) {
|
|
88
|
+
for (const input of pending) {
|
|
89
|
+
try {
|
|
90
|
+
await extract(deps, input);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
failure ??= error instanceof Error ? error : new Error(String(error));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (failure !== undefined)
|
|
98
|
+
throw failure;
|
|
99
|
+
}, { ...deps.lock, waitMs: 0 });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof SendLockBusy)
|
|
104
|
+
return false;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* `built` when this child ran the session (and anything marked while it did), `marked` when another child held the lock and the mark is left for it. Two windows are closed on purpose: a Stop that marks just after the holder's last look
|
|
110
|
+
* finds the lock free on its second try, and a holder that lets go while a mark is being written looks once more after it has released.
|
|
111
|
+
*/
|
|
112
|
+
export const machineChild = async (deps, input) => {
|
|
113
|
+
if (await locked(deps, [input])) {
|
|
114
|
+
if (await hasDirty(deps.outbox))
|
|
115
|
+
await locked(deps, []);
|
|
116
|
+
return 'built';
|
|
117
|
+
}
|
|
118
|
+
await markDirty(deps.outbox, input);
|
|
119
|
+
return (await locked(deps, [])) ? 'built' : 'marked';
|
|
120
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MachineClass } from './machine.js';
|
|
2
|
+
export interface MachineCredential {
|
|
3
|
+
source: 'proxy' | 'key' | 'token';
|
|
4
|
+
/** Absent for the proxy: the request carries no Authorization header. */
|
|
5
|
+
token?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const keyFile: (storeDir: string) => string;
|
|
8
|
+
export declare const tokenFile: (env: NodeJS.ProcessEnv, storeDir: string) => string;
|
|
9
|
+
/** The credential this machine presents, or `undefined` when it holds none (a hold-only machine, or one whose enrollment token was never put on it). */
|
|
10
|
+
export declare const credentialOf: (hostClass: MachineClass, env: NodeJS.ProcessEnv, storeDir: string) => Promise<MachineCredential | undefined>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The machine child's pool credential, in one place. A cloud machine's secret is held by the runtime's proxy (a network secret on the pool's host, added outside the VM, so the VM never sees it), so the child sends no Authorization header. Any other class holds
|
|
2
|
+
// its own: the key an earlier run rotated to (`machine-key`, owner-only in the store), else the enrollment's token (`machine-token` in the store, or the file `OARS_MACHINE_TOKEN_FILE` names). The value is read when asked and never kept, logged, or put in a notice.
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
export const keyFile = (storeDir) => join(storeDir, 'machine-key');
|
|
6
|
+
export const tokenFile = (env, storeDir) => {
|
|
7
|
+
const named = env.OARS_MACHINE_TOKEN_FILE?.trim();
|
|
8
|
+
return named === undefined || named === '' ? join(storeDir, 'machine-token') : named;
|
|
9
|
+
};
|
|
10
|
+
const valueOf = (file) => readFile(file, 'utf8').then((text) => (text.trim() === '' ? undefined : text.trim()), () => undefined);
|
|
11
|
+
/** The credential this machine presents, or `undefined` when it holds none (a hold-only machine, or one whose enrollment token was never put on it). */
|
|
12
|
+
export const credentialOf = async (hostClass, env, storeDir) => {
|
|
13
|
+
if (hostClass === 'cloud')
|
|
14
|
+
return { source: 'proxy' };
|
|
15
|
+
const key = await valueOf(keyFile(storeDir));
|
|
16
|
+
if (key !== undefined)
|
|
17
|
+
return { source: 'key', token: key };
|
|
18
|
+
const token = await valueOf(tokenFile(env, storeDir));
|
|
19
|
+
return token === undefined ? undefined : { source: 'token', token };
|
|
20
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Outcome, RunDeps } from '../cli.js';
|
|
2
|
+
/** How a session starts on the machine; the pool records it with the machine (cloud self-deposit, enrollment). */
|
|
3
|
+
export declare const LAUNCHERS: readonly ["interactive", "orchestrated", "scheduled"];
|
|
4
|
+
/** What a repository's record says about it. `unknown` is any answer that does not say, and is refused like `public`. */
|
|
5
|
+
export type Visibility = (repo: string) => Promise<'private' | 'public' | 'unknown'>;
|
|
6
|
+
export interface EnrollOptions {
|
|
7
|
+
holdOnly: boolean;
|
|
8
|
+
hostClass: string | undefined;
|
|
9
|
+
launcher: string | undefined;
|
|
10
|
+
/** `owner/name` entries, each possibly several joined by commas or spaces. */
|
|
11
|
+
allowRepos: readonly string[];
|
|
12
|
+
/** The full enrollment's label for the machine, as the pool lists it. */
|
|
13
|
+
name?: string | undefined;
|
|
14
|
+
/** `--days` as typed: how long the key lives from its activation. */
|
|
15
|
+
days?: string | undefined;
|
|
16
|
+
server?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
/** The check against GitHub's own repository record, `private` true or false. A repository the token cannot see answers 404, which says nothing, so it is `unknown`, as is every error and every record with no flag. */
|
|
19
|
+
export declare const githubVisibility: (get: typeof fetch, token: string | undefined) => Visibility;
|
|
20
|
+
/** The trusted machine's record of the machines it enrolled, one JSON line each. `oars pool hold pull` reads it (`readMachines`) to refuse a branch naming a machine it does not hold, and to write that machine's `claimed` into the batch it admits. */
|
|
21
|
+
export declare const machinesFile: (storeDir: string) => string;
|
|
22
|
+
/** What an enrollment records about its machine. */
|
|
23
|
+
export interface MachineRecord {
|
|
24
|
+
machine: string;
|
|
25
|
+
hostClass: string;
|
|
26
|
+
launcher: string;
|
|
27
|
+
allowRepos: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The machines this one enrolled, in the order it enrolled them. A line that is not a record (not JSON, a missing field, a repository that is not owner/name) is left out, so its machine reads as unknown and its branches are refused
|
|
31
|
+
* rather than admitted on a claim nobody can read. A file that exists and cannot be read throws, as `recordMachine` does.
|
|
32
|
+
*/
|
|
33
|
+
export declare const readMachines: (storeDir: string) => Promise<MachineRecord[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Prints the env block for a machine that holds its deposit in its own private repository. Without `--hold-only` it also asks the pool for a one-time enrollment token after every check has passed, and records the
|
|
36
|
+
* machine only after the pool has answered. Everything it refuses, it refuses before it asks GitHub anything: the flags, then the term list (a block with no terms
|
|
37
|
+
* would exclude every session, so nothing would be held). A repository that is public, or whose record cannot be read, refuses the whole block, and the message names each such repository.
|
|
38
|
+
*/
|
|
39
|
+
export declare const poolEnroll: (deps: RunDeps, options: EnrollOptions, visibility: Visibility) => Promise<Outcome>;
|
|
40
|
+
/** `oars pool machines`: the account's machines, one line each, with the id `machines revoke` takes. */
|
|
41
|
+
export declare const poolMachines: (deps: RunDeps, options: {
|
|
42
|
+
server?: string | undefined;
|
|
43
|
+
}) => Promise<Outcome>;
|
|
44
|
+
/** `oars pool machines revoke <id>`: effective on the machine's next request. The pool answers 404 for an id that is not this account's and for one already revoked. */
|
|
45
|
+
export declare const poolMachineRevoke: (deps: RunDeps, id: string, options: {
|
|
46
|
+
server?: string | undefined;
|
|
47
|
+
}) => Promise<Outcome>;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// `oars pool enroll`: the whole configuration a cloud VM needs to hold its deposit in its own private repository, printed as one env block. With `--hold-only` it uses no pool credential (the pool may not exist yet); without it,
|
|
2
|
+
// the pool is also asked for a one-time enrollment token after every check below, and the machine is recorded only after the pool answers.
|
|
3
|
+
// The trusted machine mints the machine id, derives the two salts from its own (so a local capture and a VM's key one repository the same way), reads its term file, and refuses any repository that is not
|
|
4
|
+
// private: a hold branch is exactly as readable as the repository it sits in.
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { readFile, rename } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
9
|
+
import { exclusionPaths, loadTerms } from './exclusion.js';
|
|
10
|
+
import { derivedSalt } from './machine-ids.js';
|
|
11
|
+
import { HOST_CLASSES } from './machine.js';
|
|
12
|
+
import { isMissing, mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
|
|
13
|
+
import { ask, poolServer, refusal, runtimeOf, signedIn } from './pool-command.js';
|
|
14
|
+
import { isObject, numberIn, textIn } from './pool-replica.js';
|
|
15
|
+
import { localSalt } from './store.js';
|
|
16
|
+
const STEP = 'pool-enroll';
|
|
17
|
+
/** How a session starts on the machine; the pool records it with the machine (cloud self-deposit, enrollment). */
|
|
18
|
+
export const LAUNCHERS = ['interactive', 'orchestrated', 'scheduled'];
|
|
19
|
+
const usage = (message) => new CliError(message, ExitCode.USAGE, STEP);
|
|
20
|
+
const REPO_NAME = /^[\w.-]+\/[\w.-]+$/u;
|
|
21
|
+
/** The check against GitHub's own repository record, `private` true or false. A repository the token cannot see answers 404, which says nothing, so it is `unknown`, as is every error and every record with no flag. */
|
|
22
|
+
export const githubVisibility = (get, token) => async (repo) => {
|
|
23
|
+
try {
|
|
24
|
+
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'oars' };
|
|
25
|
+
if (token !== undefined && token !== '')
|
|
26
|
+
headers.authorization = `Bearer ${token}`;
|
|
27
|
+
const reply = await get(`https://api.github.com/repos/${repo}`, { headers, redirect: 'error', signal: AbortSignal.timeout(15_000) });
|
|
28
|
+
if (!reply.ok)
|
|
29
|
+
return 'unknown';
|
|
30
|
+
const record = await reply.json();
|
|
31
|
+
const flag = typeof record === 'object' && record !== null ? Reflect.get(record, 'private') : undefined;
|
|
32
|
+
if (flag === true)
|
|
33
|
+
return 'private';
|
|
34
|
+
return flag === false ? 'public' : 'unknown';
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 'unknown';
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** The trusted machine's record of the machines it enrolled, one JSON line each. `oars pool hold pull` reads it (`readMachines`) to refuse a branch naming a machine it does not hold, and to write that machine's `claimed` into the batch it admits. */
|
|
41
|
+
export const machinesFile = (storeDir) => join(storeDir, 'machines.jsonl');
|
|
42
|
+
/** The file's text; a store that has enrolled no machine has none yet, and any other read error is the caller's. */
|
|
43
|
+
const machinesText = (storeDir) => readFile(machinesFile(storeDir), 'utf8').catch((error) => {
|
|
44
|
+
if (isMissing(error))
|
|
45
|
+
return '';
|
|
46
|
+
throw error;
|
|
47
|
+
});
|
|
48
|
+
/** Appends `record` as one line. The new file is staged beside the old one, owner-only, and renamed onto its name, so a reader sees the old list or the new one, and a crash leaves the old one. */
|
|
49
|
+
const recordMachine = async (storeDir, record) => {
|
|
50
|
+
const file = machinesFile(storeDir);
|
|
51
|
+
const staged = `${file}.${String(process.pid)}`;
|
|
52
|
+
await writeOwnerOnly(staged, `${await machinesText(storeDir)}${JSON.stringify(record)}\n`);
|
|
53
|
+
await rename(staged, file);
|
|
54
|
+
};
|
|
55
|
+
const recordOf = (line) => {
|
|
56
|
+
let value;
|
|
57
|
+
try {
|
|
58
|
+
value = JSON.parse(line);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if (typeof value !== 'object' || value === null)
|
|
64
|
+
return undefined;
|
|
65
|
+
const machine = Reflect.get(value, 'machine');
|
|
66
|
+
const hostClass = Reflect.get(value, 'hostClass');
|
|
67
|
+
const launcher = Reflect.get(value, 'launcher');
|
|
68
|
+
const allowRepos = Reflect.get(value, 'allowRepos');
|
|
69
|
+
if (typeof machine !== 'string' || typeof hostClass !== 'string' || typeof launcher !== 'string' || !Array.isArray(allowRepos))
|
|
70
|
+
return undefined;
|
|
71
|
+
const repos = allowRepos;
|
|
72
|
+
return repos.every((repo) => typeof repo === 'string' && REPO_NAME.test(repo)) ? { machine, hostClass, launcher, allowRepos: repos } : undefined;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* The machines this one enrolled, in the order it enrolled them. A line that is not a record (not JSON, a missing field, a repository that is not owner/name) is left out, so its machine reads as unknown and its branches are refused
|
|
76
|
+
* rather than admitted on a claim nobody can read. A file that exists and cannot be read throws, as `recordMachine` does.
|
|
77
|
+
*/
|
|
78
|
+
export const readMachines = async (storeDir) => (await machinesText(storeDir)).split('\n').flatMap((line) => recordOf(line) ?? []);
|
|
79
|
+
const quoted = (value) => `"${value.replace(/[\\"]/gu, '\\$&')}"`;
|
|
80
|
+
const classOf = (given) => {
|
|
81
|
+
const found = HOST_CLASSES.find((name) => name === given && name !== 'local');
|
|
82
|
+
if (found === undefined)
|
|
83
|
+
throw usage(`--host-class takes one of ${HOST_CLASSES.filter((name) => name !== 'local').join(', ')}; got ${given ?? 'nothing'}. Nothing was checked.`);
|
|
84
|
+
return found;
|
|
85
|
+
};
|
|
86
|
+
const launcherOf = (given) => {
|
|
87
|
+
const found = LAUNCHERS.find((name) => name === given);
|
|
88
|
+
if (found === undefined)
|
|
89
|
+
throw usage(`--launcher takes one of ${LAUNCHERS.join(', ')}; got ${given ?? 'nothing'}. Nothing was checked.`);
|
|
90
|
+
return found;
|
|
91
|
+
};
|
|
92
|
+
const reposOf = (given) => {
|
|
93
|
+
const repos = [...new Set(given.flatMap((entry) => entry.split(/[\s,]+/u)).filter((entry) => entry !== '').map((entry) => entry.toLowerCase()))];
|
|
94
|
+
if (repos.length === 0)
|
|
95
|
+
throw usage('--allow-repos takes the repositories this machine may hold, as owner/name. Nothing was checked.');
|
|
96
|
+
const bad = repos.find((repo) => !REPO_NAME.test(repo) || repo.split('/').some((part) => /^\.+$/u.test(part)));
|
|
97
|
+
if (bad !== undefined)
|
|
98
|
+
throw usage(`--allow-repos takes owner/name; got ${bad}. Nothing was checked.`);
|
|
99
|
+
return repos;
|
|
100
|
+
};
|
|
101
|
+
/** `--days` as the pool takes it, or left out for the pool's default. The wording is the pool's own. */
|
|
102
|
+
const daysOf = (given) => {
|
|
103
|
+
if (given === undefined)
|
|
104
|
+
return undefined;
|
|
105
|
+
const days = /^\d+$/u.test(given) ? Number(given) : Number.NaN;
|
|
106
|
+
if (!Number.isSafeInteger(days) || days < 1 || days > 365)
|
|
107
|
+
throw usage('--days is a whole number of days from 1 to 365. Nothing was checked.');
|
|
108
|
+
return days;
|
|
109
|
+
};
|
|
110
|
+
const enrolledOf = (body) => {
|
|
111
|
+
const machine = isObject(body) ? Reflect.get(body, 'machine') : undefined;
|
|
112
|
+
const token = isObject(body) ? textIn(body, 'token') : undefined;
|
|
113
|
+
const poolId = isObject(machine) ? textIn(machine, 'id') : undefined;
|
|
114
|
+
const tokenExpiresAt = isObject(machine) ? numberIn(machine, 'expiresAt') : undefined;
|
|
115
|
+
if (token === undefined || poolId === undefined || tokenExpiresAt === undefined)
|
|
116
|
+
throw new CliError('The pool did not answer the enrollment with a token and a machine.', ExitCode.UNEXPECTED, STEP);
|
|
117
|
+
return { token, tokenExpiresAt, poolId };
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Prints the env block for a machine that holds its deposit in its own private repository. Without `--hold-only` it also asks the pool for a one-time enrollment token after every check has passed, and records the
|
|
121
|
+
* machine only after the pool has answered. Everything it refuses, it refuses before it asks GitHub anything: the flags, then the term list (a block with no terms
|
|
122
|
+
* would exclude every session, so nothing would be held). A repository that is public, or whose record cannot be read, refuses the whole block, and the message names each such repository.
|
|
123
|
+
*/
|
|
124
|
+
export const poolEnroll = async (deps, options, visibility) => {
|
|
125
|
+
const hostClass = classOf(options.hostClass);
|
|
126
|
+
if (!options.holdOnly && hostClass === 'hosted')
|
|
127
|
+
throw usage('The pool enrolls a vps, cloud, or ci machine; a hosted run uses a per-run token, so it does not enroll. Nothing was checked.');
|
|
128
|
+
const launcher = launcherOf(options.launcher);
|
|
129
|
+
const repos = reposOf(options.allowRepos);
|
|
130
|
+
if (options.holdOnly && (options.days !== undefined || options.name !== undefined || options.server !== undefined))
|
|
131
|
+
throw usage('--name, --days, and --server belong to the enrollment that asks the pool for a token; --hold-only sends nothing to the pool. Nothing was checked.');
|
|
132
|
+
const days = options.holdOnly ? undefined : daysOf(options.days);
|
|
133
|
+
if (!options.holdOnly && (options.name === undefined || options.name === ''))
|
|
134
|
+
throw usage('--name takes the label the pool lists this machine by. Nothing was checked.');
|
|
135
|
+
const runtime = runtimeOf(deps);
|
|
136
|
+
const termsFile = exclusionPaths(runtime.projectsRoot, deps.env).terms;
|
|
137
|
+
const terms = [...new Set(await loadTerms(termsFile))];
|
|
138
|
+
if (terms.length === 0)
|
|
139
|
+
throw usage(`No exclusion terms in ${termsFile}: a block without them would exclude every session, and nothing would be held. Nothing was checked.`);
|
|
140
|
+
// The sign-in is read from this machine's store (no network), so a signed-out run is refused before GitHub is asked about any repository. The pool itself is asked only after every repository is confirmed private, and the machine is recorded only after the pool has answered: a refusal at either leaves nothing behind.
|
|
141
|
+
const server = options.holdOnly ? '' : poolServer(deps.env, STEP, options.server);
|
|
142
|
+
const credential = options.holdOnly ? undefined : await signedIn(deps, runtime.storeDir, server, STEP);
|
|
143
|
+
const refused = [];
|
|
144
|
+
for (const repo of repos) {
|
|
145
|
+
const seen = await visibility(repo);
|
|
146
|
+
if (seen === 'public')
|
|
147
|
+
refused.push(` ${repo} is public: a hold branch there is readable by anyone.`);
|
|
148
|
+
else if (seen === 'unknown')
|
|
149
|
+
refused.push(` ${repo}: could not confirm it is private (the check needs a GH_TOKEN or GITHUB_TOKEN that can read it, and the repository's current name).`);
|
|
150
|
+
}
|
|
151
|
+
if (refused.length > 0)
|
|
152
|
+
throw usage(`Refusing to enroll: every repository must be private.\n${refused.join('\n')}`);
|
|
153
|
+
await mkdirOwnerOnly(runtime.storeDir);
|
|
154
|
+
const localKey = (await (runtime.accountSalt ?? localSalt)(runtime.storeDir)).salt;
|
|
155
|
+
const env = {
|
|
156
|
+
OARS_HOST_CLASS: hostClass,
|
|
157
|
+
OARS_LAUNCHER: launcher,
|
|
158
|
+
OARS_MACHINE: randomBytes(8).toString('hex'),
|
|
159
|
+
OARS_ALLOW_REPOS: repos.join(','),
|
|
160
|
+
OARS_REPO_SALT: derivedSalt(localKey, 'repoKey/1'),
|
|
161
|
+
OARS_SESSION_SALT: derivedSalt(localKey, 'sessionId/1'),
|
|
162
|
+
OARS_EXCLUSION_TERMS_INLINE: terms.join('\n'),
|
|
163
|
+
// A hold-only machine has no pool key; the marker keeps its child from reporting an unknown credential.
|
|
164
|
+
...(options.holdOnly ? { OARS_HOLD_ONLY: '1' } : {}),
|
|
165
|
+
};
|
|
166
|
+
const enrolled = credential === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: await askToEnroll(deps, server, credential.token, { label: options.name ?? '', hostClass, launcher, allowRepos: repos, ...(days === undefined ? {} : { days }) });
|
|
169
|
+
// Recorded after every check has passed (and the pool has answered, in a full enrollment) and before the block is printed: a block the operator can copy always names a machine the trusted side knows.
|
|
170
|
+
await recordMachine(runtime.storeDir, { machine: env.OARS_MACHINE, hostClass, launcher, allowRepos: repos });
|
|
171
|
+
const lines = Object.entries(env).map(([name, value]) => `${name}=${name === 'OARS_EXCLUSION_TERMS_INLINE' ? quoted(value) : value}`);
|
|
172
|
+
const count = `${String(repos.length)} private ${repos.length === 1 ? 'repository' : 'repositories'}`;
|
|
173
|
+
const block = ['', ...lines, '', 'A change to your term list needs this run again and the new block set in every environment.'];
|
|
174
|
+
if (enrolled === undefined) {
|
|
175
|
+
const text = [`Hold-only enrollment for ${count}. No pool credential was used or needed.`, 'Set these variables in the cloud environment. The block holds two salts and your exclusion terms, so treat it as a secret:', ...block].join('\n');
|
|
176
|
+
return { document: { ok: true, holdOnly: true, hostClass, launcher, machine: env.OARS_MACHINE, allowRepos: repos, env }, text };
|
|
177
|
+
}
|
|
178
|
+
const text = [
|
|
179
|
+
`Enrolled ${options.name ?? ''} with the pool for ${count}.`,
|
|
180
|
+
`One-time enrollment token (shown once; it must be activated within 24 hours, by ${new Date(enrolled.tokenExpiresAt).toISOString()}, or it is useless):`,
|
|
181
|
+
` ${enrolled.token}`,
|
|
182
|
+
`Hold it where the runtime keeps secrets: a network secret on the cloud environment (Bearer, host ${new URL(server).host}), or an owner-only file on a server. Never in a repository.`,
|
|
183
|
+
'Set these variables in the cloud environment. The block holds two salts and your exclusion terms, so treat it as a secret:',
|
|
184
|
+
...block,
|
|
185
|
+
].join('\n');
|
|
186
|
+
return { document: { ok: true, holdOnly: false, hostClass, launcher, machine: env.OARS_MACHINE, poolMachine: enrolled.poolId, token: enrolled.token, tokenExpiresAt: enrolled.tokenExpiresAt, allowRepos: repos, env }, text };
|
|
187
|
+
};
|
|
188
|
+
/** `POST /v1/machines/enroll` with the account's credential. Any answer but the 201 is the pool's own refusal, and nothing is recorded. */
|
|
189
|
+
const askToEnroll = async (deps, server, token, declared) => {
|
|
190
|
+
const reply = await ask(deps, STEP, server, '/v1/machines/enroll', { token, body: declared });
|
|
191
|
+
if (reply.status !== 201)
|
|
192
|
+
throw refusal(STEP, reply);
|
|
193
|
+
return enrolledOf(reply.body);
|
|
194
|
+
};
|
|
195
|
+
const MACHINES = 'pool-machines';
|
|
196
|
+
const listedOf = (row) => {
|
|
197
|
+
const [id, label, hostClass, launcher, state] = ['id', 'label', 'hostClass', 'launcher', 'state'].map((name) => (isObject(row) ? textIn(row, name) : undefined));
|
|
198
|
+
const expiresAt = isObject(row) ? numberIn(row, 'expiresAt') : undefined;
|
|
199
|
+
if (id === undefined || label === undefined || hostClass === undefined || launcher === undefined || state === undefined || expiresAt === undefined) {
|
|
200
|
+
throw new CliError('The pool listed a machine this client cannot read.', ExitCode.UNEXPECTED, MACHINES);
|
|
201
|
+
}
|
|
202
|
+
return { id, label, hostClass, launcher, state, lastDepositDay: isObject(row) ? (textIn(row, 'lastDepositDay') ?? null) : null, expiresAt };
|
|
203
|
+
};
|
|
204
|
+
/** `oars pool machines`: the account's machines, one line each, with the id `machines revoke` takes. */
|
|
205
|
+
export const poolMachines = async (deps, options) => {
|
|
206
|
+
const { storeDir } = runtimeOf(deps);
|
|
207
|
+
const server = poolServer(deps.env, MACHINES, options.server);
|
|
208
|
+
const { token } = await signedIn(deps, storeDir, server, MACHINES);
|
|
209
|
+
const reply = await ask(deps, MACHINES, server, '/v1/machines', { token });
|
|
210
|
+
const rows = isObject(reply.body) ? Reflect.get(reply.body, 'machines') : undefined;
|
|
211
|
+
if (reply.status === 200 && !Array.isArray(rows))
|
|
212
|
+
throw new CliError('The pool did not answer with a list of machines.', ExitCode.UNEXPECTED, MACHINES);
|
|
213
|
+
if (reply.status !== 200 || !Array.isArray(rows))
|
|
214
|
+
throw refusal(MACHINES, reply);
|
|
215
|
+
const machines = rows.map(listedOf);
|
|
216
|
+
const lines = machines.map((one) => `${one.label} ${one.id} ${one.hostClass} ${one.launcher} ${one.state} last deposit ${one.lastDepositDay ?? 'never'} expires ${new Date(one.expiresAt).toISOString().slice(0, 10)}`);
|
|
217
|
+
return { document: { ok: true, server, machines }, text: machines.length === 0 ? `No machines are enrolled with ${server}. oars pool enroll adds one.` : lines.join('\n') };
|
|
218
|
+
};
|
|
219
|
+
/** `oars pool machines revoke <id>`: effective on the machine's next request. The pool answers 404 for an id that is not this account's and for one already revoked. */
|
|
220
|
+
export const poolMachineRevoke = async (deps, id, options) => {
|
|
221
|
+
const { storeDir } = runtimeOf(deps);
|
|
222
|
+
const server = poolServer(deps.env, MACHINES, options.server);
|
|
223
|
+
const { token } = await signedIn(deps, storeDir, server, MACHINES);
|
|
224
|
+
const reply = await ask(deps, MACHINES, server, `/v1/machines/${encodeURIComponent(id)}`, { method: 'DELETE', token });
|
|
225
|
+
if (reply.status === 404)
|
|
226
|
+
throw new CliError(`No machine ${id} to revoke: it is not one of this account's machines, or it is already revoked. oars pool machines lists them.`, ExitCode.USAGE, MACHINES);
|
|
227
|
+
if (reply.status !== 204)
|
|
228
|
+
throw refusal(MACHINES, reply);
|
|
229
|
+
return { document: { ok: true, server, revoked: id }, text: `Revoked machine ${id}. Its key is refused from its next request.` };
|
|
230
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Git } from './survival.js';
|
|
2
|
+
/** What one commit needs: the folder the session ran in and the operator's list (the repository is checked again here), the branch's two names, the three files' contents, and the salt the terms digest is keyed with. */
|
|
3
|
+
export interface HoldInput {
|
|
4
|
+
cwd: string | undefined;
|
|
5
|
+
allowRepos: readonly string[];
|
|
6
|
+
machine: string;
|
|
7
|
+
id: string;
|
|
8
|
+
/** The batch's bytes, as the outbox holds them. */
|
|
9
|
+
batch: string;
|
|
10
|
+
stamp: string;
|
|
11
|
+
/** The term list the batch was gated under, as the term file holds it. */
|
|
12
|
+
terms: readonly string[];
|
|
13
|
+
/** `OARS_SESSION_SALT`. */
|
|
14
|
+
salt: string;
|
|
15
|
+
}
|
|
16
|
+
/** The branch a session's batch is held on. The proxy of a cloud session takes branches only, so the namespace is a branch prefix. */
|
|
17
|
+
export declare const holdBranch: (machine: string, id: string) => string;
|
|
18
|
+
/**
|
|
19
|
+
* `HMAC-SHA-256(salt, list)` in hex, where the list is the terms trimmed and lowercased (as `loadTerms` reads them), blanks dropped, then deduped, sorted, and joined with newlines: one list gives one digest in any order, case, or
|
|
20
|
+
* blank lines. It is keyed so that a reader of the branch cannot test a guessed term against it; it detects a stale list, not a forged one, since the VM holds the salt and the list both.
|
|
21
|
+
*/
|
|
22
|
+
export declare const termsDigest: (salt: string, terms: readonly string[]) => string;
|
|
23
|
+
/**
|
|
24
|
+
* Commits the batch to a tree of its own and returns the commit's sha, or `undefined` when the repository is not on the operator's list now: a batch whose repository left `OARS_ALLOW_REPOS` after it was built is not committed.
|
|
25
|
+
* Nothing but objects is written (no ref, no `HEAD`, no index, no file), and the commit's message says `[skip ci]`, so a hold push starts no workflow. `parent` is the branch's tip when it has one.
|
|
26
|
+
*/
|
|
27
|
+
export declare const holdCommit: (git: Git, input: HoldInput, parent?: string) => Promise<string | undefined>;
|
|
28
|
+
/**
|
|
29
|
+
* Runs one git command that talks to a remote (`push`, `fetch`), with the transports a remote needs (`runGit` allows none) and no terminal prompt, so a missing credential fails the push instead of asking. Rejects on any non-zero exit;
|
|
30
|
+
* the message is kept for the caller's own use and is never logged by this module, since a remote's error text can carry its URL.
|
|
31
|
+
*/
|
|
32
|
+
export declare const runNetGit: Git;
|
|
33
|
+
export interface HoldDeps {
|
|
34
|
+
/** Local plumbing and the `origin` URL read (`runGit`, which allows no transport). */
|
|
35
|
+
git: Git;
|
|
36
|
+
/** Push and fetch (`runNetGit`). */
|
|
37
|
+
net: Git;
|
|
38
|
+
/** One line in the step log, for a push that was refused every time. */
|
|
39
|
+
log: (error: unknown) => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export type HoldResult = 'pushed' | 'refused' | 'skipped';
|
|
42
|
+
/** A refused push is tried again on top of the branch's fetched tip, three attempts in all. */
|
|
43
|
+
export declare const HOLD_ATTEMPTS = 3;
|
|
44
|
+
/**
|
|
45
|
+
* Commits the batch (`holdCommit`) and pushes it to `oars-hold/<machine>/<id>`. The first push builds on the tip this VM last knew, so a session's later turns are fast-forwards. A refused push is treated as refused whatever the reason, and its message is not read:
|
|
46
|
+
* the child fetches the branch's tip (an earlier VM of the session may have moved it), commits again on top of it, and pushes, up to three attempts in all. A third refusal writes one log row and returns `refused`; the batch stays in the outbox
|
|
47
|
+
* and the next Stop rebuilds the session whole. `skipped` is a repository that is not on the operator's list now, or a session with no folder. Nothing here throws for a refusal.
|
|
48
|
+
*/
|
|
49
|
+
export declare const holdBatch: (deps: HoldDeps, input: HoldInput) => Promise<HoldResult>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The holding route of a machine, the VM side: a cloud VM keeps its deposit in its own private repository. Each batch the machine child builds is committed to the branch `oars-hold/<machine>/<id>` with git plumbing
|
|
2
|
+
// (`hash-object`, `mktree`, `commit-tree`), so the session's `HEAD`, index, and working tree are never touched, and the commit is pushed from there (`holdBatch`, below). The commit holds three files: `batch.json` (the batch
|
|
3
|
+
// as `oars pool outbox` prints it), `stamp` (the client's batch stamp), and `terms` (a keyed digest of the exclusion terms the batch was gated under, so a reader with another list can tell). Admission is the trusted side's.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { createHmac } from 'node:crypto';
|
|
6
|
+
import { repoAllowed } from './machine-repo.js';
|
|
7
|
+
/** The branch a session's batch is held on. The proxy of a cloud session takes branches only, so the namespace is a branch prefix. */
|
|
8
|
+
export const holdBranch = (machine, id) => `oars-hold/${machine}/${id}`;
|
|
9
|
+
/**
|
|
10
|
+
* `HMAC-SHA-256(salt, list)` in hex, where the list is the terms trimmed and lowercased (as `loadTerms` reads them), blanks dropped, then deduped, sorted, and joined with newlines: one list gives one digest in any order, case, or
|
|
11
|
+
* blank lines. It is keyed so that a reader of the branch cannot test a guessed term against it; it detects a stale list, not a forged one, since the VM holds the salt and the list both.
|
|
12
|
+
*/
|
|
13
|
+
export const termsDigest = (salt, terms) => {
|
|
14
|
+
const list = [...new Set(terms.map((term) => term.trim().toLowerCase()).filter((term) => term !== ''))].sort().join('\n');
|
|
15
|
+
return createHmac('sha256', salt).update(list).digest('hex');
|
|
16
|
+
};
|
|
17
|
+
// A hold commit is made by oars, not by the person: its own identity, and no signing, so a VM with no `user.name` or a signing key set globally still commits.
|
|
18
|
+
const IDENTITY = ['-c', 'user.name=oars', '-c', 'user.email=oars@users.noreply.github.com', '-c', 'commit.gpgsign=false'];
|
|
19
|
+
/** Writes `content` as a blob exactly as given: `--no-filters`, so a repository's line-ending settings cannot change the batch's bytes. */
|
|
20
|
+
const blobOf = async (git, cwd, content) => (await git(cwd, ['hash-object', '-w', '--no-filters', '--stdin'], content)).trim();
|
|
21
|
+
/**
|
|
22
|
+
* Commits the batch to a tree of its own and returns the commit's sha, or `undefined` when the repository is not on the operator's list now: a batch whose repository left `OARS_ALLOW_REPOS` after it was built is not committed.
|
|
23
|
+
* Nothing but objects is written (no ref, no `HEAD`, no index, no file), and the commit's message says `[skip ci]`, so a hold push starts no workflow. `parent` is the branch's tip when it has one.
|
|
24
|
+
*/
|
|
25
|
+
export const holdCommit = async (git, input, parent) => {
|
|
26
|
+
if (input.cwd === undefined || !(await repoAllowed(input.allowRepos, input.cwd, git)))
|
|
27
|
+
return undefined;
|
|
28
|
+
const files = [
|
|
29
|
+
['batch.json', input.batch],
|
|
30
|
+
['stamp', `${input.stamp}\n`],
|
|
31
|
+
['terms', `${termsDigest(input.salt, input.terms)}\n`],
|
|
32
|
+
];
|
|
33
|
+
const entries = [];
|
|
34
|
+
for (const [name, content] of files)
|
|
35
|
+
entries.push(`100644 blob ${await blobOf(git, input.cwd, content)}\t${name}\n`);
|
|
36
|
+
const tree = (await git(input.cwd, ['mktree'], entries.join(''))).trim();
|
|
37
|
+
const message = `oars hold ${input.id} [skip ci]`;
|
|
38
|
+
return (await git(input.cwd, [...IDENTITY, 'commit-tree', tree, ...(parent === undefined ? [] : ['-p', parent]), '-m', message])).trim();
|
|
39
|
+
};
|
|
40
|
+
/** Where a push or a fetch may wait: the cloud proxy answers a push in seconds, and a hung one must not hold the child's lock for good. */
|
|
41
|
+
const NET_TIMEOUT_MS = 120_000;
|
|
42
|
+
/**
|
|
43
|
+
* Runs one git command that talks to a remote (`push`, `fetch`), with the transports a remote needs (`runGit` allows none) and no terminal prompt, so a missing credential fails the push instead of asking. Rejects on any non-zero exit;
|
|
44
|
+
* the message is kept for the caller's own use and is never logged by this module, since a remote's error text can carry its URL.
|
|
45
|
+
*/
|
|
46
|
+
export const runNetGit = (root, args) => new Promise((resolve, reject) => {
|
|
47
|
+
execFile('git', ['-C', root, ...args], { encoding: 'utf8', timeout: NET_TIMEOUT_MS, windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined } }, (error, stdout) => {
|
|
48
|
+
if (error === null)
|
|
49
|
+
resolve(stdout);
|
|
50
|
+
else
|
|
51
|
+
reject(new Error(error.message));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
/** A refused push is tried again on top of the branch's fetched tip, three attempts in all. */
|
|
55
|
+
export const HOLD_ATTEMPTS = 3;
|
|
56
|
+
/** The branch's last known tip, kept under a private ref that no checkout shows (`git branch` lists `refs/heads` only): the commit this VM pushed last, or the tip a refused push fetched. */
|
|
57
|
+
const tipOf = async (git, cwd, seen) => (await git(cwd, ['rev-parse', '--verify', '--quiet', `${seen}^{commit}`]).catch(() => '')).trim() || undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Commits the batch (`holdCommit`) and pushes it to `oars-hold/<machine>/<id>`. The first push builds on the tip this VM last knew, so a session's later turns are fast-forwards. A refused push is treated as refused whatever the reason, and its message is not read:
|
|
60
|
+
* the child fetches the branch's tip (an earlier VM of the session may have moved it), commits again on top of it, and pushes, up to three attempts in all. A third refusal writes one log row and returns `refused`; the batch stays in the outbox
|
|
61
|
+
* and the next Stop rebuilds the session whole. `skipped` is a repository that is not on the operator's list now, or a session with no folder. Nothing here throws for a refusal.
|
|
62
|
+
*/
|
|
63
|
+
export const holdBatch = async (deps, input) => {
|
|
64
|
+
const cwd = input.cwd;
|
|
65
|
+
if (cwd === undefined)
|
|
66
|
+
return 'skipped';
|
|
67
|
+
const branch = holdBranch(input.machine, input.id);
|
|
68
|
+
const seen = `refs/oars-hold/${input.machine}/${input.id}`;
|
|
69
|
+
let parent = await tipOf(deps.git, cwd, seen);
|
|
70
|
+
for (let attempt = 1; attempt <= HOLD_ATTEMPTS; attempt += 1) {
|
|
71
|
+
const commit = await holdCommit(deps.git, input, parent);
|
|
72
|
+
if (commit === undefined)
|
|
73
|
+
return 'skipped';
|
|
74
|
+
// `--no-verify`: the session repository's own `pre-push` hook (husky, or a `core.hooksPath` its install step set) is the project's, not ours, and could run its checks at every Stop under the send lock or refuse every hold push, losing the batch with the VM.
|
|
75
|
+
const accepted = await deps.net(cwd, ['push', '--no-verify', 'origin', `${commit}:refs/heads/${branch}`]).then(() => true, () => false);
|
|
76
|
+
if (accepted) {
|
|
77
|
+
// Bookkeeping only: a failure here must not turn an accepted push into a retry.
|
|
78
|
+
await deps.git(cwd, ['update-ref', seen, commit]).catch(() => undefined);
|
|
79
|
+
return 'pushed';
|
|
80
|
+
}
|
|
81
|
+
if (attempt < HOLD_ATTEMPTS) {
|
|
82
|
+
await deps.net(cwd, ['fetch', '--quiet', 'origin', `+refs/heads/${branch}:${seen}`]).catch(() => undefined);
|
|
83
|
+
parent = (await tipOf(deps.git, cwd, seen)) ?? parent;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await deps.log(new Error(`a hold push of ${branch} was refused ${String(HOLD_ATTEMPTS)} times; the batch stays in the outbox`));
|
|
87
|
+
return 'refused';
|
|
88
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The salt a purpose gets: `repoKey/1` keys a repository's hash, `sessionId/1` a machine's session ids. A derived salt computes that one hash and nothing else, so it can leave the trusted machine and the store salt cannot. */
|
|
2
|
+
export declare const derivedSalt: (localSalt: string, purpose: "repoKey/1" | "sessionId/1") => string;
|
|
3
|
+
/** A session's name without the prefix the cloud puts on it: the variable carries `cse_`, the id a teleport takes carries `session_`, and unstripped the two would hash apart and one session would count twice. */
|
|
4
|
+
export declare const sessionName: (session: string) => string;
|
|
5
|
+
/** The skeleton `id` of a machine's session: the first 16 hex digits of the session's HMAC under the environment's session salt, the one shape the wire and the outbox accept. */
|
|
6
|
+
export declare const machineIdOf: (sessionSalt: string, session: string) => string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The two keyed values a machine and its trusted machine must compute the same way: the salts the enrollment derives from the store salt, and the skeleton `id` of a session a machine holds. Only node:crypto, so the
|
|
2
|
+
// Stop hook's child pays for no pool module to read them.
|
|
3
|
+
import { createHmac } from 'node:crypto';
|
|
4
|
+
/** The salt a purpose gets: `repoKey/1` keys a repository's hash, `sessionId/1` a machine's session ids. A derived salt computes that one hash and nothing else, so it can leave the trusted machine and the store salt cannot. */
|
|
5
|
+
export const derivedSalt = (localSalt, purpose) => createHmac('sha256', localSalt).update(purpose).digest('hex');
|
|
6
|
+
/** A session's name without the prefix the cloud puts on it: the variable carries `cse_`, the id a teleport takes carries `session_`, and unstripped the two would hash apart and one session would count twice. */
|
|
7
|
+
export const sessionName = (session) => session.replace(/^(?:cse_|session_)/u, '');
|
|
8
|
+
/** The skeleton `id` of a machine's session: the first 16 hex digits of the session's HMAC under the environment's session salt, the one shape the wire and the outbox accept. */
|
|
9
|
+
export const machineIdOf = (sessionSalt, session) => createHmac('sha256', sessionSalt).update(sessionName(session)).digest('hex').slice(0, 16);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const NOTICE_CODES: readonly ["vocabulary_expired", "hold_unconfigured", "machine_key_expiring", "machine_key_expired", "machine_key_revoked", "machine_key_retired", "machine_key_unknown"];
|
|
2
|
+
export type NoticeCode = (typeof NOTICE_CODES)[number];
|
|
3
|
+
export interface NoticeEntry {
|
|
4
|
+
code: NoticeCode;
|
|
5
|
+
/** Epoch milliseconds when the child wrote it. */
|
|
6
|
+
at: number;
|
|
7
|
+
/** Epoch milliseconds the machine key expires, kept for `machine_key_expiring` so the message can name the day. */
|
|
8
|
+
expiresAt?: number;
|
|
9
|
+
}
|
|
10
|
+
/** A code is written at most this often, so an expired client prints its message once a day, not at every Stop. */
|
|
11
|
+
export declare const NOTICE_INTERVAL_MS: number;
|
|
12
|
+
export declare const noticeFile: (storeDir: string) => string;
|
|
13
|
+
/** The child records a condition: its entry replaces an earlier one of the same code and keeps its place, so the file lists the codes in the order they were first raised. A notice file it cannot read is rewritten. */
|
|
14
|
+
export declare const recordNotice: (storeDir: string, code: NoticeCode, at: number, expiresAt?: number) => Promise<void>;
|
|
15
|
+
/** The one message for a set of entries, or `undefined` for none. */
|
|
16
|
+
export declare const messageOf: (entries: readonly NoticeEntry[]) => string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* The Stop reads the notice and clears it: the file is renamed first, so two Stops never both print it, then read and removed. The text, or `undefined` when there is no notice. Never throws: a hook that cannot read its notice goes on to
|
|
19
|
+
* start its child, and the condition, still true, is written again by that child.
|
|
20
|
+
*/
|
|
21
|
+
export declare const takeNotice: (storeDir: string) => string | undefined;
|