@agents-ensemble/cli 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/config.example.yaml +2 -0
- package/dist/async-operator-input.js +1 -1
- package/dist/async-operator-input.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/session-log-lines.d.ts.map +1 -1
- package/dist/session-log-lines.js +5 -0
- package/dist/session-log-lines.js.map +1 -1
- package/dist/tui/create-issue-session-tui-host.d.ts.map +1 -1
- package/dist/tui/create-issue-session-tui-host.js +19 -1
- package/dist/tui/create-issue-session-tui-host.js.map +1 -1
- package/dist/tui/create-issue-session-tui-host.test.js +65 -0
- package/dist/tui/create-issue-session-tui-host.test.js.map +1 -1
- package/dist/tui/create-tui-telemetry-sink.d.ts.map +1 -1
- package/dist/tui/create-tui-telemetry-sink.js +2 -1
- package/dist/tui/create-tui-telemetry-sink.js.map +1 -1
- package/dist/tui/issue-session-tui-stream.d.ts.map +1 -1
- package/dist/tui/issue-session-tui-stream.js +5 -3
- package/dist/tui/issue-session-tui-stream.js.map +1 -1
- package/dist/tui/issue-session-tui-stream.test.js +30 -13
- package/dist/tui/issue-session-tui-stream.test.js.map +1 -1
- package/dist/tui/issue-session-tui.d.ts.map +1 -1
- package/dist/tui/issue-session-tui.js +4 -2
- package/dist/tui/issue-session-tui.js.map +1 -1
- package/dist/tui/issue-session-tui.test.js +38 -16
- package/dist/tui/issue-session-tui.test.js.map +1 -1
- package/dist/tui/operator-command-suggestions.d.ts +15 -0
- package/dist/tui/operator-command-suggestions.d.ts.map +1 -0
- package/dist/tui/operator-command-suggestions.js +19 -0
- package/dist/tui/operator-command-suggestions.js.map +1 -0
- package/dist/tui/tui-layout-constants.d.ts +2 -2
- package/dist/tui/tui-layout-constants.d.ts.map +1 -1
- package/dist/tui/tui-layout-constants.js +2 -2
- package/dist/tui/tui-layout-constants.js.map +1 -1
- package/docs/config.md +1 -1
- package/docs/pi-conductor-setup.md +285 -0
- package/package.json +3 -2
- package/scripts/pi/cached-auth.mjs +873 -0
- package/scripts/pi/cached-auth.sh +11 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
readlinkSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
symlinkSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { hostname, homedir } from 'node:os';
|
|
18
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TTL_MS = 300_000;
|
|
23
|
+
const LOCK_DIR_NAME = '.lock.d';
|
|
24
|
+
const LOCK_OWNER_PREFIX = '.lock-owner-';
|
|
25
|
+
const LOCK_RECOVERY_PREFIX = '.lock-recovery-';
|
|
26
|
+
const LOCK_TIMEOUT_MS = 60_000;
|
|
27
|
+
const LOCK_POLL_MS = 25;
|
|
28
|
+
const LOCK_MISSING_OWNER_RETRIES = 4;
|
|
29
|
+
const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
30
|
+
|
|
31
|
+
function usage() {
|
|
32
|
+
return `Usage: cached-auth.mjs [options] -- <command> [args...]
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--cache-dir PATH Cache directory (default: ~/.ensemble/pi/cache/auth or $ENSEMBLE_PI_AUTH_CACHE_DIR)
|
|
36
|
+
--ttl-ms MS Minimum interval before re-running <command> (default: 300000)
|
|
37
|
+
--cache-key KEY Cache file basename (default: default)
|
|
38
|
+
--clear Delete cache entry for this key and exit
|
|
39
|
+
-h, --help Show this help
|
|
40
|
+
|
|
41
|
+
Environment:
|
|
42
|
+
ENSEMBLE_PI_AUTH_CACHE_DIR Default --cache-dir
|
|
43
|
+
ENSEMBLE_PI_AUTH_CACHE_TTL_MS Default --ttl-ms
|
|
44
|
+
|
|
45
|
+
Example (models.json):
|
|
46
|
+
"apiKey": "!/absolute/path/to/agents-ensemble/scripts/pi/cached-auth.mjs --ttl-ms 300000 -- gcloud auth print-identity-token --account=you@example.com"
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseOptions(args, env) {
|
|
51
|
+
const options = {
|
|
52
|
+
cacheDir:
|
|
53
|
+
env.ENSEMBLE_PI_AUTH_CACHE_DIR ||
|
|
54
|
+
join(homedir(), '.ensemble', 'pi', 'cache', 'auth'),
|
|
55
|
+
ttlValue: env.ENSEMBLE_PI_AUTH_CACHE_TTL_MS || String(DEFAULT_TTL_MS),
|
|
56
|
+
cacheKey: 'default',
|
|
57
|
+
clear: false,
|
|
58
|
+
command: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (let index = 0; index < args.length; ) {
|
|
62
|
+
const arg = args[index];
|
|
63
|
+
if (arg === '--') {
|
|
64
|
+
options.command = args.slice(index + 1);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (arg === '-h' || arg === '--help') {
|
|
68
|
+
return { ...options, help: true };
|
|
69
|
+
}
|
|
70
|
+
if (arg === '--clear') {
|
|
71
|
+
options.clear = true;
|
|
72
|
+
index += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (arg === '--cache-dir' || arg === '--ttl-ms' || arg === '--cache-key') {
|
|
76
|
+
const value = args[index + 1];
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
throw new UsageError(`missing value for ${arg}`);
|
|
79
|
+
}
|
|
80
|
+
if (arg === '--cache-dir') options.cacheDir = value;
|
|
81
|
+
if (arg === '--ttl-ms') options.ttlValue = value;
|
|
82
|
+
if (arg === '--cache-key') options.cacheKey = value;
|
|
83
|
+
index += 2;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
throw new UsageError(`unknown option: ${arg}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.command.length === 0) {
|
|
90
|
+
throw new UsageError('missing command after --');
|
|
91
|
+
}
|
|
92
|
+
options.ttlMs = parseTtl(options.ttlValue);
|
|
93
|
+
delete options.ttlValue;
|
|
94
|
+
if (
|
|
95
|
+
options.cacheKey.length === 0 ||
|
|
96
|
+
basename(options.cacheKey) !== options.cacheKey ||
|
|
97
|
+
options.cacheKey.includes('/') ||
|
|
98
|
+
options.cacheKey.includes('\\') ||
|
|
99
|
+
options.cacheKey === '.' ||
|
|
100
|
+
options.cacheKey === '..'
|
|
101
|
+
) {
|
|
102
|
+
throw new UsageError('--cache-key must be a non-empty file basename');
|
|
103
|
+
}
|
|
104
|
+
if (!options.cacheDir) {
|
|
105
|
+
throw new UsageError('--cache-dir must not be empty');
|
|
106
|
+
}
|
|
107
|
+
return options;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseTtl(value) {
|
|
111
|
+
if (!/^\d+$/.test(value)) {
|
|
112
|
+
throw new UsageError('--ttl-ms must be a non-negative integer');
|
|
113
|
+
}
|
|
114
|
+
const ttlMs = Number(value);
|
|
115
|
+
if (!Number.isSafeInteger(ttlMs)) {
|
|
116
|
+
throw new UsageError('--ttl-ms must be a non-negative integer');
|
|
117
|
+
}
|
|
118
|
+
return ttlMs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class UsageError extends Error {}
|
|
122
|
+
|
|
123
|
+
function cachePaths(cacheDir, cacheKey) {
|
|
124
|
+
return {
|
|
125
|
+
token: join(cacheDir, `${cacheKey}.token`),
|
|
126
|
+
meta: join(cacheDir, `${cacheKey}.meta`),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readValidToken(paths, ttlMs, nowMs = Date.now()) {
|
|
131
|
+
let token;
|
|
132
|
+
let fetchedAtMs;
|
|
133
|
+
try {
|
|
134
|
+
token = readFileSync(paths.token, 'utf8');
|
|
135
|
+
fetchedAtMs = Number(readFileSync(paths.meta, 'utf8').trim());
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
if (!Number.isFinite(fetchedAtMs)) return undefined;
|
|
140
|
+
|
|
141
|
+
if (ttlMs > 0 && nowMs - fetchedAtMs < ttlMs) return token;
|
|
142
|
+
|
|
143
|
+
const exp = jwtExpirationSeconds(token);
|
|
144
|
+
if (exp !== undefined && exp > Math.floor(nowMs / 1_000) + 300) {
|
|
145
|
+
return token;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function jwtExpirationSeconds(token) {
|
|
151
|
+
try {
|
|
152
|
+
const parts = token.trim().split('.');
|
|
153
|
+
if (parts.length < 2) return undefined;
|
|
154
|
+
const payload = JSON.parse(
|
|
155
|
+
Buffer.from(parts[1], 'base64url').toString('utf8'),
|
|
156
|
+
);
|
|
157
|
+
return Number.isSafeInteger(payload.exp) ? payload.exp : undefined;
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ensureCacheDir(cacheDir) {
|
|
164
|
+
mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
165
|
+
try {
|
|
166
|
+
chmodSync(cacheDir, 0o700);
|
|
167
|
+
} catch {
|
|
168
|
+
// Preserve the previous helper's best-effort permission tightening.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function writeAtomic(path, contents) {
|
|
173
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(temporaryPath, contents, { flag: 'wx', mode: 0o600 });
|
|
176
|
+
renameSync(temporaryPath, path);
|
|
177
|
+
} finally {
|
|
178
|
+
rmSync(temporaryPath, { force: true });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function refresh(paths, ttlMs, command) {
|
|
183
|
+
const cachedToken = readValidToken(paths, ttlMs);
|
|
184
|
+
if (cachedToken !== undefined) return cachedToken;
|
|
185
|
+
|
|
186
|
+
const result = spawnSync(command[0], command.slice(1), {
|
|
187
|
+
encoding: 'buffer',
|
|
188
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
189
|
+
});
|
|
190
|
+
if (result.error || result.status !== 0) {
|
|
191
|
+
throw new Error('inner command failed');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const token = result.stdout
|
|
195
|
+
.toString('utf8')
|
|
196
|
+
.replace(/[\r\n]/g, '')
|
|
197
|
+
.replace(/\s+$/u, '');
|
|
198
|
+
if (token.length === 0) {
|
|
199
|
+
throw new Error('inner command produced empty stdout');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
writeAtomic(paths.token, token);
|
|
203
|
+
writeAtomic(paths.meta, `${Date.now()}\n`);
|
|
204
|
+
return token;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function psProcessStartIdentity(pid, options = {}) {
|
|
208
|
+
const result = (options.spawnSync ?? spawnSync)(
|
|
209
|
+
'ps',
|
|
210
|
+
['-p', String(pid), '-o', 'lstart='],
|
|
211
|
+
{
|
|
212
|
+
encoding: 'utf8',
|
|
213
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
214
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
if (result.error || result.status !== 0) return undefined;
|
|
218
|
+
const identity = result.stdout.trim().replace(/\s+/gu, ' ');
|
|
219
|
+
return identity || undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function legacyLinuxProcessStartIdentity(pid, options = {}) {
|
|
223
|
+
const currentPidNamespaceIdentity = Object.prototype.hasOwnProperty.call(
|
|
224
|
+
options,
|
|
225
|
+
'currentPidNamespaceIdentity',
|
|
226
|
+
)
|
|
227
|
+
? options.currentPidNamespaceIdentity
|
|
228
|
+
: pidNamespaceIdentity('linux');
|
|
229
|
+
const procfsPidNamespaceIdentity = Object.prototype.hasOwnProperty.call(
|
|
230
|
+
options,
|
|
231
|
+
'procfsPidNamespaceIdentity',
|
|
232
|
+
)
|
|
233
|
+
? options.procfsPidNamespaceIdentity
|
|
234
|
+
: pidNamespaceIdentityForProcfs('linux');
|
|
235
|
+
if (
|
|
236
|
+
typeof currentPidNamespaceIdentity !== 'string' ||
|
|
237
|
+
currentPidNamespaceIdentity !== procfsPidNamespaceIdentity
|
|
238
|
+
) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
return psProcessStartIdentity(pid, options);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function processStartIdentity(pid, options = {}) {
|
|
245
|
+
const platform = options.platform ?? process.platform;
|
|
246
|
+
if (platform === 'linux') {
|
|
247
|
+
const currentPidNamespaceIdentity = Object.prototype.hasOwnProperty.call(
|
|
248
|
+
options,
|
|
249
|
+
'currentPidNamespaceIdentity',
|
|
250
|
+
)
|
|
251
|
+
? options.currentPidNamespaceIdentity
|
|
252
|
+
: pidNamespaceIdentity(platform);
|
|
253
|
+
const procfsPidNamespaceIdentity = Object.prototype.hasOwnProperty.call(
|
|
254
|
+
options,
|
|
255
|
+
'procfsPidNamespaceIdentity',
|
|
256
|
+
)
|
|
257
|
+
? options.procfsPidNamespaceIdentity
|
|
258
|
+
: pidNamespaceIdentityForProcfs(platform);
|
|
259
|
+
if (
|
|
260
|
+
typeof currentPidNamespaceIdentity !== 'string' ||
|
|
261
|
+
typeof procfsPidNamespaceIdentity !== 'string' ||
|
|
262
|
+
currentPidNamespaceIdentity !== procfsPidNamespaceIdentity
|
|
263
|
+
) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
let stat;
|
|
267
|
+
try {
|
|
268
|
+
stat = (options.readFileSync ?? readFileSync)(
|
|
269
|
+
`/proc/${pid}/stat`,
|
|
270
|
+
'utf8',
|
|
271
|
+
);
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
const pidMatch = /^(\d+)\s+\(/u.exec(stat);
|
|
276
|
+
const closingParen = stat.lastIndexOf(')');
|
|
277
|
+
if (
|
|
278
|
+
!pidMatch ||
|
|
279
|
+
Number(pidMatch[1]) !== pid ||
|
|
280
|
+
closingParen < pidMatch[0].length
|
|
281
|
+
) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
// The comm field can contain spaces and parentheses. After its final `)`,
|
|
285
|
+
// field 3 (state) is index 0 and field 22 (starttime) is index 19.
|
|
286
|
+
const fields = stat.slice(closingParen + 1).trim().split(/\s+/u);
|
|
287
|
+
const startTimeTicks = fields[19];
|
|
288
|
+
return /^\d+$/u.test(startTimeTicks ?? '')
|
|
289
|
+
? `linux:${startTimeTicks}`
|
|
290
|
+
: undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return psProcessStartIdentity(pid, options);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function pidNamespaceIdentity(platform = process.platform) {
|
|
297
|
+
if (platform !== 'linux') return undefined;
|
|
298
|
+
try {
|
|
299
|
+
const identity = readlinkSync('/proc/self/ns/pid');
|
|
300
|
+
return /^pid:\[\d+\]$/u.test(identity) ? identity : undefined;
|
|
301
|
+
} catch {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function pidNamespaceIdentityForProcfs(platform = process.platform) {
|
|
307
|
+
if (platform !== 'linux') return undefined;
|
|
308
|
+
try {
|
|
309
|
+
const identity = readlinkSync('/proc/1/ns/pid');
|
|
310
|
+
return /^pid:\[\d+\]$/u.test(identity) ? identity : undefined;
|
|
311
|
+
} catch {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function requirePidNamespaceIdentity(
|
|
317
|
+
identity,
|
|
318
|
+
platform = process.platform,
|
|
319
|
+
) {
|
|
320
|
+
if (
|
|
321
|
+
platform === 'linux' &&
|
|
322
|
+
(typeof identity !== 'string' || !/^pid:\[\d+\]$/u.test(identity))
|
|
323
|
+
) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
'cannot determine the current Linux PID namespace; refusing to use the cache lock',
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return identity ?? null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function processState(
|
|
332
|
+
pid,
|
|
333
|
+
processHost,
|
|
334
|
+
expectedStartIdentity,
|
|
335
|
+
expectedPidNamespaceIdentity,
|
|
336
|
+
options = {},
|
|
337
|
+
) {
|
|
338
|
+
const platform = options.platform ?? process.platform;
|
|
339
|
+
const currentHost = options.currentHost ?? hostname();
|
|
340
|
+
let currentPidNamespaceIdentity;
|
|
341
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return 'unknown';
|
|
342
|
+
if (processHost !== currentHost) return 'unknown';
|
|
343
|
+
|
|
344
|
+
if (platform === 'linux') {
|
|
345
|
+
const hasInjectedNamespace = Object.prototype.hasOwnProperty.call(
|
|
346
|
+
options,
|
|
347
|
+
'currentPidNamespaceIdentity',
|
|
348
|
+
);
|
|
349
|
+
currentPidNamespaceIdentity = hasInjectedNamespace
|
|
350
|
+
? options.currentPidNamespaceIdentity
|
|
351
|
+
: pidNamespaceIdentity(platform);
|
|
352
|
+
if (
|
|
353
|
+
typeof expectedPidNamespaceIdentity !== 'string' ||
|
|
354
|
+
!/^pid:\[\d+\]$/u.test(expectedPidNamespaceIdentity) ||
|
|
355
|
+
typeof currentPidNamespaceIdentity !== 'string' ||
|
|
356
|
+
!/^pid:\[\d+\]$/u.test(currentPidNamespaceIdentity) ||
|
|
357
|
+
currentPidNamespaceIdentity !== expectedPidNamespaceIdentity
|
|
358
|
+
) {
|
|
359
|
+
return 'unknown';
|
|
360
|
+
}
|
|
361
|
+
if (
|
|
362
|
+
typeof expectedStartIdentity === 'string' &&
|
|
363
|
+
expectedStartIdentity.startsWith('linux:') &&
|
|
364
|
+
!/^linux:\d+$/u.test(expectedStartIdentity)
|
|
365
|
+
) {
|
|
366
|
+
return 'unknown';
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
(options.killProcess ?? ((targetPid) => process.kill(targetPid, 0)))(pid);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error?.code === 'ESRCH') return 'dead';
|
|
374
|
+
if (error?.code !== 'EPERM') return 'unknown';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (typeof expectedStartIdentity !== 'string' || expectedStartIdentity.length === 0) {
|
|
378
|
+
return 'live';
|
|
379
|
+
}
|
|
380
|
+
const actualStartIdentity = options.getProcessStartIdentity
|
|
381
|
+
? options.getProcessStartIdentity(pid)
|
|
382
|
+
: platform === 'linux' &&
|
|
383
|
+
!expectedStartIdentity.startsWith('linux:')
|
|
384
|
+
? legacyLinuxProcessStartIdentity(pid, {
|
|
385
|
+
...options,
|
|
386
|
+
currentPidNamespaceIdentity,
|
|
387
|
+
})
|
|
388
|
+
: processStartIdentity(pid);
|
|
389
|
+
if (actualStartIdentity === undefined) {
|
|
390
|
+
try {
|
|
391
|
+
(options.killProcess ?? ((targetPid) => process.kill(targetPid, 0)))(pid);
|
|
392
|
+
return 'live';
|
|
393
|
+
} catch (error) {
|
|
394
|
+
return error?.code === 'ESRCH' ? 'dead' : 'live';
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return actualStartIdentity === expectedStartIdentity ? 'live' : 'dead';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function ownerDirectoryName(ownerId) {
|
|
401
|
+
return `${LOCK_OWNER_PREFIX}${ownerId}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function parseOwnerDirectoryName(name) {
|
|
405
|
+
const match = /^\.lock-owner-([a-f0-9]{32})$/u.exec(name);
|
|
406
|
+
return match?.[1];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function recoveryDirectoryName(ownerId, reaper) {
|
|
410
|
+
const hostHex = Buffer.from(reaper.hostname).toString('hex');
|
|
411
|
+
const startHex = reaper.processStartIdentity
|
|
412
|
+
? Buffer.from(reaper.processStartIdentity).toString('hex')
|
|
413
|
+
: '0';
|
|
414
|
+
const pidNamespaceHex = reaper.pidNamespaceIdentity
|
|
415
|
+
? Buffer.from(reaper.pidNamespaceIdentity).toString('hex')
|
|
416
|
+
: '0';
|
|
417
|
+
const nonce = randomBytes(16).toString('hex');
|
|
418
|
+
return `${LOCK_RECOVERY_PREFIX}${ownerId}-${hostHex}-${reaper.pid}-${startHex}-${pidNamespaceHex}-${nonce}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function parseRecoveryDirectoryName(name, ownerId) {
|
|
422
|
+
const prefix = `${LOCK_RECOVERY_PREFIX}${ownerId}-`;
|
|
423
|
+
if (!name.startsWith(prefix)) return undefined;
|
|
424
|
+
const encoded = name.slice(prefix.length);
|
|
425
|
+
const currentFormat =
|
|
426
|
+
/^([a-f0-9]+)-(\d+)-([a-f0-9]+)-([a-f0-9]+)-([a-f0-9]{32})$/u.exec(encoded);
|
|
427
|
+
const previousFormat =
|
|
428
|
+
/^([a-f0-9]+)-(\d+)-([a-f0-9]+)-([a-f0-9]{32})$/u.exec(encoded);
|
|
429
|
+
const match = currentFormat ?? previousFormat;
|
|
430
|
+
if (!match) return undefined;
|
|
431
|
+
|
|
432
|
+
let processHost;
|
|
433
|
+
let processStart;
|
|
434
|
+
let processPidNamespace;
|
|
435
|
+
try {
|
|
436
|
+
processHost = Buffer.from(match[1], 'hex').toString('utf8');
|
|
437
|
+
processStart =
|
|
438
|
+
match[3] === '0'
|
|
439
|
+
? undefined
|
|
440
|
+
: Buffer.from(match[3], 'hex').toString('utf8');
|
|
441
|
+
const namespaceHex = currentFormat ? match[4] : undefined;
|
|
442
|
+
processPidNamespace =
|
|
443
|
+
namespaceHex === undefined || namespaceHex === '0'
|
|
444
|
+
? undefined
|
|
445
|
+
: Buffer.from(namespaceHex, 'hex').toString('utf8');
|
|
446
|
+
} catch {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
if (
|
|
450
|
+
processPidNamespace !== undefined &&
|
|
451
|
+
!/^pid:\[\d+\]$/u.test(processPidNamespace)
|
|
452
|
+
) {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
pid: Number(match[2]),
|
|
457
|
+
hostname: processHost,
|
|
458
|
+
processStartIdentity: processStart,
|
|
459
|
+
pidNamespaceIdentity: processPidNamespace,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function readLockTarget(cacheDir, lockPath) {
|
|
464
|
+
let lockStat;
|
|
465
|
+
try {
|
|
466
|
+
lockStat = lstatSync(lockPath);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (error?.code === 'ENOENT') return { state: 'free' };
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
if (!lockStat.isSymbolicLink()) {
|
|
472
|
+
return {
|
|
473
|
+
state: 'unknown',
|
|
474
|
+
reason: 'the lock is in the ownerless format used by an older helper',
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let target;
|
|
479
|
+
try {
|
|
480
|
+
target = readlinkSync(lockPath);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (error?.code === 'ENOENT') return { state: 'free' };
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
const targetPath = resolve(dirname(lockPath), target);
|
|
486
|
+
if (dirname(targetPath) !== resolve(cacheDir)) {
|
|
487
|
+
return { state: 'unknown', reason: 'the lock target is outside the cache directory' };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const ownerId = parseOwnerDirectoryName(basename(targetPath));
|
|
491
|
+
if (!ownerId) {
|
|
492
|
+
return { state: 'unknown', reason: 'the lock owner identity is invalid' };
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
state: 'owned',
|
|
496
|
+
ownerId,
|
|
497
|
+
ownerDirectoryName: basename(targetPath),
|
|
498
|
+
ownerDirectoryPath: targetPath,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function readOwner(ownerDirectoryPath, expectedId) {
|
|
503
|
+
let owner;
|
|
504
|
+
try {
|
|
505
|
+
owner = JSON.parse(readFileSync(join(ownerDirectoryPath, 'owner.json'), 'utf8'));
|
|
506
|
+
} catch {
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
if (
|
|
510
|
+
![1, 2].includes(owner?.version) ||
|
|
511
|
+
owner.id !== expectedId ||
|
|
512
|
+
!Number.isSafeInteger(owner.pid) ||
|
|
513
|
+
typeof owner.hostname !== 'string' ||
|
|
514
|
+
(owner.processStartIdentity !== null &&
|
|
515
|
+
typeof owner.processStartIdentity !== 'string') ||
|
|
516
|
+
(owner.version === 2 &&
|
|
517
|
+
(owner.pidNamespaceIdentity !== null &&
|
|
518
|
+
(typeof owner.pidNamespaceIdentity !== 'string' ||
|
|
519
|
+
!/^pid:\[\d+\]$/u.test(owner.pidNamespaceIdentity))))
|
|
520
|
+
) {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
return owner;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function recoveryCandidates(cacheDir, ownerId) {
|
|
527
|
+
return readdirSync(cacheDir)
|
|
528
|
+
.map((name) => ({
|
|
529
|
+
name,
|
|
530
|
+
process: parseRecoveryDirectoryName(name, ownerId),
|
|
531
|
+
}))
|
|
532
|
+
.filter((candidate) => candidate.process !== undefined);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function inspectExistingLock(cacheDir, lockPath, currentPidNamespaceIdentity) {
|
|
536
|
+
const target = readLockTarget(cacheDir, lockPath);
|
|
537
|
+
if (target.state !== 'owned') return target;
|
|
538
|
+
|
|
539
|
+
const owner = readOwner(target.ownerDirectoryPath, target.ownerId);
|
|
540
|
+
if (owner) {
|
|
541
|
+
if (
|
|
542
|
+
process.platform === 'linux' &&
|
|
543
|
+
(typeof currentPidNamespaceIdentity !== 'string' ||
|
|
544
|
+
typeof owner.pidNamespaceIdentity !== 'string' ||
|
|
545
|
+
owner.pidNamespaceIdentity !== currentPidNamespaceIdentity)
|
|
546
|
+
) {
|
|
547
|
+
return {
|
|
548
|
+
...target,
|
|
549
|
+
state: 'unknown',
|
|
550
|
+
reason: 'the lock PID namespace is missing, inaccessible, or different',
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
const state = processState(
|
|
554
|
+
owner.pid,
|
|
555
|
+
owner.hostname,
|
|
556
|
+
owner.processStartIdentity,
|
|
557
|
+
owner.pidNamespaceIdentity,
|
|
558
|
+
{ currentPidNamespaceIdentity },
|
|
559
|
+
);
|
|
560
|
+
if (state === 'dead') return { ...target, state: 'stale-owner' };
|
|
561
|
+
if (state === 'live') return { ...target, state: 'live' };
|
|
562
|
+
return { ...target, state: 'unknown', reason: 'the lock owner cannot be verified' };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const candidates = recoveryCandidates(cacheDir, target.ownerId);
|
|
566
|
+
if (candidates.length === 0) {
|
|
567
|
+
// Another reaper can rename the owner directory, unlink this symlink, and
|
|
568
|
+
// remove its recovery directory between our owner read and directory scan.
|
|
569
|
+
// Re-read the symlink before deciding this is a static ownerless lock.
|
|
570
|
+
const currentTarget = readLockTarget(cacheDir, lockPath);
|
|
571
|
+
if (
|
|
572
|
+
currentTarget.state !== 'owned' ||
|
|
573
|
+
currentTarget.ownerId !== target.ownerId
|
|
574
|
+
) {
|
|
575
|
+
return {
|
|
576
|
+
state: 'retry',
|
|
577
|
+
reason: 'the lock target changed while its owner record was read',
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
...target,
|
|
582
|
+
state: 'retry',
|
|
583
|
+
retryKind: 'missing-owner-record',
|
|
584
|
+
reason: 'the lock owner record is missing',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
let staleRecovery;
|
|
589
|
+
for (const candidate of candidates) {
|
|
590
|
+
const state = processState(
|
|
591
|
+
candidate.process.pid,
|
|
592
|
+
candidate.process.hostname,
|
|
593
|
+
candidate.process.processStartIdentity,
|
|
594
|
+
candidate.process.pidNamespaceIdentity,
|
|
595
|
+
{ currentPidNamespaceIdentity },
|
|
596
|
+
);
|
|
597
|
+
if (state === 'live') return { ...target, state: 'live' };
|
|
598
|
+
if (state === 'unknown') {
|
|
599
|
+
return {
|
|
600
|
+
...target,
|
|
601
|
+
state: 'unknown',
|
|
602
|
+
reason: 'the interrupted lock recovery owner cannot be verified',
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
staleRecovery = candidate;
|
|
606
|
+
}
|
|
607
|
+
return { ...target, state: 'stale-recovery', recovery: staleRecovery };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function createOwnedLock(cacheDir, lockPath, currentPidNamespaceIdentity) {
|
|
611
|
+
const recordedPidNamespaceIdentity = requirePidNamespaceIdentity(
|
|
612
|
+
currentPidNamespaceIdentity,
|
|
613
|
+
);
|
|
614
|
+
const id = randomBytes(16).toString('hex');
|
|
615
|
+
const ownerName = ownerDirectoryName(id);
|
|
616
|
+
const ownerPath = resolve(cacheDir, ownerName);
|
|
617
|
+
const owner = {
|
|
618
|
+
version: 2,
|
|
619
|
+
id,
|
|
620
|
+
pid: process.pid,
|
|
621
|
+
hostname: hostname(),
|
|
622
|
+
processStartIdentity: processStartIdentity(process.pid) ?? null,
|
|
623
|
+
pidNamespaceIdentity: recordedPidNamespaceIdentity,
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
mkdirSync(ownerPath, { mode: 0o700 });
|
|
627
|
+
let published = false;
|
|
628
|
+
try {
|
|
629
|
+
writeFileSync(join(ownerPath, 'owner.json'), `${JSON.stringify(owner)}\n`, {
|
|
630
|
+
flag: 'wx',
|
|
631
|
+
mode: 0o600,
|
|
632
|
+
});
|
|
633
|
+
const linkTarget =
|
|
634
|
+
process.platform === 'win32' ? ownerPath : ownerName;
|
|
635
|
+
symlinkSync(linkTarget, lockPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
636
|
+
published = true;
|
|
637
|
+
return { id, ownerName, ownerPath, lockPath };
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
640
|
+
return undefined;
|
|
641
|
+
} finally {
|
|
642
|
+
if (!published) rmSync(ownerPath, { recursive: true, force: true });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function lockLinkPointsTo(lockPath, ownerDirectoryPath) {
|
|
647
|
+
try {
|
|
648
|
+
return resolve(dirname(lockPath), readlinkSync(lockPath)) === ownerDirectoryPath;
|
|
649
|
+
} catch {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function claimRecoveryDirectory(
|
|
655
|
+
cacheDir,
|
|
656
|
+
ownerId,
|
|
657
|
+
sourcePath,
|
|
658
|
+
currentPidNamespaceIdentity,
|
|
659
|
+
) {
|
|
660
|
+
const reaper = {
|
|
661
|
+
pid: process.pid,
|
|
662
|
+
hostname: hostname(),
|
|
663
|
+
processStartIdentity: processStartIdentity(process.pid),
|
|
664
|
+
pidNamespaceIdentity: currentPidNamespaceIdentity,
|
|
665
|
+
};
|
|
666
|
+
const recoveryPath = join(
|
|
667
|
+
cacheDir,
|
|
668
|
+
recoveryDirectoryName(ownerId, reaper),
|
|
669
|
+
);
|
|
670
|
+
try {
|
|
671
|
+
renameSync(sourcePath, recoveryPath);
|
|
672
|
+
return recoveryPath;
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (error?.code === 'ENOENT' || error?.code === 'EEXIST') return undefined;
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function reclaimStaleLock(
|
|
680
|
+
cacheDir,
|
|
681
|
+
lockPath,
|
|
682
|
+
lock,
|
|
683
|
+
currentPidNamespaceIdentity,
|
|
684
|
+
) {
|
|
685
|
+
const sourcePath =
|
|
686
|
+
lock.state === 'stale-owner'
|
|
687
|
+
? lock.ownerDirectoryPath
|
|
688
|
+
: join(cacheDir, lock.recovery.name);
|
|
689
|
+
const recoveryPath = claimRecoveryDirectory(
|
|
690
|
+
cacheDir,
|
|
691
|
+
lock.ownerId,
|
|
692
|
+
sourcePath,
|
|
693
|
+
currentPidNamespaceIdentity,
|
|
694
|
+
);
|
|
695
|
+
if (!recoveryPath) return false;
|
|
696
|
+
|
|
697
|
+
if (lockLinkPointsTo(lockPath, lock.ownerDirectoryPath)) {
|
|
698
|
+
try {
|
|
699
|
+
unlinkSync(lockPath);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
rmSync(recoveryPath, { recursive: true, force: true });
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function releaseLock(lock) {
|
|
709
|
+
if (lockLinkPointsTo(lock.lockPath, lock.ownerPath)) {
|
|
710
|
+
try {
|
|
711
|
+
unlinkSync(lock.lockPath);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
rmSync(lock.ownerPath, { recursive: true, force: true });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function withLock(cacheDir, paths, ttlMs, command, action) {
|
|
720
|
+
const lockPath = join(cacheDir, LOCK_DIR_NAME);
|
|
721
|
+
const currentPidNamespaceIdentity = pidNamespaceIdentity();
|
|
722
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
723
|
+
let missingOwnerTarget;
|
|
724
|
+
let missingOwnerRetries = 0;
|
|
725
|
+
let lock;
|
|
726
|
+
while (true) {
|
|
727
|
+
try {
|
|
728
|
+
lock = createOwnedLock(
|
|
729
|
+
cacheDir,
|
|
730
|
+
lockPath,
|
|
731
|
+
currentPidNamespaceIdentity,
|
|
732
|
+
);
|
|
733
|
+
if (lock) break;
|
|
734
|
+
} catch (error) {
|
|
735
|
+
throw error;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (action !== 'clear') {
|
|
739
|
+
const cachedToken = readValidToken(paths, ttlMs);
|
|
740
|
+
if (cachedToken !== undefined) return cachedToken;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const existingLock = inspectExistingLock(
|
|
744
|
+
cacheDir,
|
|
745
|
+
lockPath,
|
|
746
|
+
currentPidNamespaceIdentity,
|
|
747
|
+
);
|
|
748
|
+
if (existingLock.state === 'stale-owner' || existingLock.state === 'stale-recovery') {
|
|
749
|
+
missingOwnerTarget = undefined;
|
|
750
|
+
missingOwnerRetries = 0;
|
|
751
|
+
if (
|
|
752
|
+
reclaimStaleLock(
|
|
753
|
+
cacheDir,
|
|
754
|
+
lockPath,
|
|
755
|
+
existingLock,
|
|
756
|
+
currentPidNamespaceIdentity,
|
|
757
|
+
)
|
|
758
|
+
) continue;
|
|
759
|
+
} else if (existingLock.state === 'retry') {
|
|
760
|
+
if (existingLock.retryKind === 'missing-owner-record') {
|
|
761
|
+
const currentTarget = readLockTarget(cacheDir, lockPath);
|
|
762
|
+
if (
|
|
763
|
+
currentTarget.state !== 'owned' ||
|
|
764
|
+
currentTarget.ownerId !== existingLock.ownerId
|
|
765
|
+
) {
|
|
766
|
+
missingOwnerTarget = undefined;
|
|
767
|
+
missingOwnerRetries = 0;
|
|
768
|
+
} else if (currentTarget.ownerId === missingOwnerTarget) {
|
|
769
|
+
missingOwnerRetries += 1;
|
|
770
|
+
} else {
|
|
771
|
+
missingOwnerTarget = currentTarget.ownerId;
|
|
772
|
+
missingOwnerRetries = 1;
|
|
773
|
+
}
|
|
774
|
+
if (missingOwnerRetries >= LOCK_MISSING_OWNER_RETRIES) {
|
|
775
|
+
throw new Error(
|
|
776
|
+
`cache lock owner cannot be verified (${existingLock.reason}); refusing to remove it`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
missingOwnerTarget = undefined;
|
|
781
|
+
missingOwnerRetries = 0;
|
|
782
|
+
}
|
|
783
|
+
} else if (existingLock.state === 'unknown') {
|
|
784
|
+
throw new Error(
|
|
785
|
+
`cache lock owner cannot be verified (${existingLock.reason}); refusing to remove it`,
|
|
786
|
+
);
|
|
787
|
+
} else if (existingLock.state === 'free') {
|
|
788
|
+
missingOwnerTarget = undefined;
|
|
789
|
+
missingOwnerRetries = 0;
|
|
790
|
+
continue;
|
|
791
|
+
} else {
|
|
792
|
+
missingOwnerTarget = undefined;
|
|
793
|
+
missingOwnerRetries = 0;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (Date.now() >= deadline) {
|
|
797
|
+
throw new Error('could not acquire cache lock');
|
|
798
|
+
}
|
|
799
|
+
Atomics.wait(SLEEP_CELL, 0, 0, LOCK_POLL_MS);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
if (action === 'clear') {
|
|
804
|
+
rmSync(paths.token, { force: true });
|
|
805
|
+
rmSync(paths.meta, { force: true });
|
|
806
|
+
return '';
|
|
807
|
+
}
|
|
808
|
+
return refresh(paths, ttlMs, command);
|
|
809
|
+
} finally {
|
|
810
|
+
releaseLock(lock);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function main() {
|
|
815
|
+
let options;
|
|
816
|
+
try {
|
|
817
|
+
options = parseOptions(process.argv.slice(2), process.env);
|
|
818
|
+
} catch (error) {
|
|
819
|
+
if (error instanceof UsageError) {
|
|
820
|
+
process.stderr.write(`cached-auth.mjs: ${error.message}\n${usage()}`);
|
|
821
|
+
process.exitCode = 2;
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (options.help) {
|
|
828
|
+
process.stdout.write(usage());
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
try {
|
|
833
|
+
ensureCacheDir(options.cacheDir);
|
|
834
|
+
const paths = cachePaths(options.cacheDir, options.cacheKey);
|
|
835
|
+
if (options.clear) {
|
|
836
|
+
withLock(
|
|
837
|
+
options.cacheDir,
|
|
838
|
+
paths,
|
|
839
|
+
options.ttlMs,
|
|
840
|
+
options.command,
|
|
841
|
+
'clear',
|
|
842
|
+
);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const cachedToken = readValidToken(paths, options.ttlMs);
|
|
847
|
+
const token =
|
|
848
|
+
cachedToken ??
|
|
849
|
+
withLock(
|
|
850
|
+
options.cacheDir,
|
|
851
|
+
paths,
|
|
852
|
+
options.ttlMs,
|
|
853
|
+
options.command,
|
|
854
|
+
'refresh',
|
|
855
|
+
);
|
|
856
|
+
process.stdout.write(token);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
process.stderr.write(`cached-auth.mjs: ${error.message}\n`);
|
|
859
|
+
process.exitCode = 1;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function isDirectInvocation() {
|
|
864
|
+
const entryPath = process.argv[1];
|
|
865
|
+
if (!entryPath) return false;
|
|
866
|
+
try {
|
|
867
|
+
return realpathSync(entryPath) === fileURLToPath(import.meta.url);
|
|
868
|
+
} catch {
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (isDirectInvocation()) main();
|