@prohost/cli 0.16.4 → 0.16.5
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/CHANGELOG.md +43 -0
- package/README.md +17 -3
- package/dist/agent/daemon.js +12 -1
- package/dist/agent/run.js +12 -4
- package/dist/codehost/hooks.d.ts +2 -0
- package/dist/codehost/hooks.js +15 -1
- package/dist/codehost/host.d.ts +24 -5
- package/dist/codehost/host.js +85 -3
- package/dist/codehost/ledger.d.ts +16 -2
- package/dist/codehost/ledger.js +43 -6
- package/dist/codehost/ready.d.ts +8 -9
- package/dist/codehost/ready.js +41 -9
- package/dist/codehost/session.d.ts +25 -0
- package/dist/codehost/session.js +61 -0
- package/dist/codehost/terminals.d.ts +119 -1
- package/dist/codehost/terminals.js +369 -5
- package/dist/codehost/tmux.d.ts +6 -0
- package/dist/codehost/tmux.js +9 -0
- package/dist/host/command.js +334 -9
- package/dist/host/computer.d.ts +31 -0
- package/dist/host/computer.js +36 -0
- package/dist/host/install.d.ts +64 -0
- package/dist/host/install.js +317 -7
- package/dist/host/migrate.js +5 -1
- package/dist/host/pair.d.ts +1 -1
- package/dist/host/pair.js +2 -0
- package/dist/host/service.js +11 -4
- package/dist/index.js +5 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/host/command.js
CHANGED
|
@@ -24,12 +24,12 @@ import { claudeQuotaProvider, codexQuotaProvider } from '../agent/runtime_status
|
|
|
24
24
|
import { readPipedKey } from '../usage/key-input.js';
|
|
25
25
|
import { CLI_VERSION } from '../version.js';
|
|
26
26
|
import { recommendCapacity } from './capacity.js';
|
|
27
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
28
28
|
import { addAccount, isValidLabel, loadAccounts, parseAccountRuntime, accountEnv } from '../agent/accounts.js';
|
|
29
29
|
import { revokeComputer } from './api.js';
|
|
30
|
-
import { readSharedObject, acquireHostLock, clearComputer, computerOs, controlPath, hostLockPath, readObject, statePath, tryLoadComputer, writePrivateFile, } from './computer.js';
|
|
30
|
+
import { readSharedObject, acquireHostLock, clearComputer, computerOs, controlPath, handoffNotice, handoffOf, hostLockPath, SERVICE_LABEL, readHandoff, readObject, statePath, tryLoadComputer, writePrivateFile, } from './computer.js';
|
|
31
31
|
import { journalPath, readEvents } from './events.js';
|
|
32
|
-
import { LAUNCHD_HOST_LABEL, defaultLabel, defaultServiceRunner, installService, windowsTaskOf, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
|
|
32
|
+
import { LAUNCHD_HOST_LABEL, defaultLabel, defaultServiceRunner, installService, installedDedicatedService, publishedComputer, publishedComputerId, readServiceCredential, rewriteHomeCredential, windowsServiceCredentialPath, windowsHomesDir, windowsTaskOf, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
|
|
33
33
|
import { LegacyHarnesses } from './legacy.js';
|
|
34
34
|
import { detectLogins } from './logins.js';
|
|
35
35
|
import { adoptComputer, loadAdopted, runMigrate } from './migrate.js';
|
|
@@ -97,6 +97,7 @@ export function hardwareFacts() {
|
|
|
97
97
|
*/
|
|
98
98
|
export function statusJson(env, now = Date.now(), options = {}) {
|
|
99
99
|
const computer = tryLoadComputer(env);
|
|
100
|
+
const handoff = options.label ? undefined : readHandoff(env);
|
|
100
101
|
// A dedicated service is reported only when asked for by its `--label`, and then only from what
|
|
101
102
|
// it publishes: unlabelled, status is about this home's own service, whatever else runs on the
|
|
102
103
|
// computer; labelled, nothing of this home's (its pause, its pairing, its lock) is mixed in.
|
|
@@ -118,13 +119,18 @@ export function statusJson(env, now = Date.now(), options = {}) {
|
|
|
118
119
|
const events = readEvents(labelDir ? path.join(labelDir, 'events.json') : journalPath(env), now);
|
|
119
120
|
return {
|
|
120
121
|
// Reporting the dedicated service: its own computer, never this home's (they can differ).
|
|
121
|
-
|
|
122
|
+
// A home that handed its computer to a dedicated service is still that computer's (served
|
|
123
|
+
// elsewhere, `handed_to`): paired, offline here — never read as "not added", which would invite
|
|
124
|
+
// a second pairing beside the service that holds the key.
|
|
125
|
+
paired: labelDir ? sharedComputer !== undefined : computer !== null || handoff !== undefined,
|
|
122
126
|
computer: labelDir
|
|
123
127
|
? (sharedComputer ?? null)
|
|
124
128
|
: computer
|
|
125
129
|
? { id: computer.computer_id, name: (fresh && state?.name) || computer.name, account: computer.account }
|
|
126
|
-
:
|
|
127
|
-
|
|
130
|
+
: handoff
|
|
131
|
+
? { id: handoff.computer_id, name: handoff.name, account: handoff.account }
|
|
132
|
+
: null,
|
|
133
|
+
online: Boolean(!handoff && fresh && state?.online),
|
|
128
134
|
paused,
|
|
129
135
|
slots: fresh && state ? state.slots : slotsFor(capacity, 0, paused ? 'paused' : null),
|
|
130
136
|
running: fresh && state ? state.running.map(localRunning) : [],
|
|
@@ -141,11 +147,19 @@ export function statusJson(env, now = Date.now(), options = {}) {
|
|
|
141
147
|
...(labelDir ? { activity_file: path.join(labelDir, 'activity.json') } : {}),
|
|
142
148
|
},
|
|
143
149
|
remote_terminal: Boolean(fresh && state?.remote_terminal),
|
|
150
|
+
// This home handed its computer to a dedicated service: where to look instead.
|
|
151
|
+
...(handoff ? { handed_to: { label: handoff.handed_to.label, service_user: handoff.handed_to.service_user, service_home: handoff.handed_to.service_home } } : {}),
|
|
144
152
|
version: CLI_VERSION,
|
|
145
153
|
};
|
|
146
154
|
}
|
|
147
155
|
function printStatus(json, out) {
|
|
148
156
|
const computer = json.computer;
|
|
157
|
+
const handedTo = json.handed_to;
|
|
158
|
+
if (handedTo) {
|
|
159
|
+
out(`This computer runs as the dedicated service ${handedTo.label}${handedTo.service_user ? ` (user ${handedTo.service_user})` : ''}. ` +
|
|
160
|
+
`See it with: prohost host status --label ${handedTo.label}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
149
163
|
if (!computer) {
|
|
150
164
|
out('This computer is not added to a workspace yet. Run: prohost host pair');
|
|
151
165
|
return;
|
|
@@ -285,6 +299,207 @@ async function addLogin(flags, env, out, err, io) {
|
|
|
285
299
|
out(`✓ Added "${label}". It shows in prohost host logins.`);
|
|
286
300
|
return 0;
|
|
287
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* `sudo prohost host unpair --label <label>` (or `--dedicated`, the label read from this home's
|
|
304
|
+
* hand-off note): remove a dedicated service's computer. As root / an administrator it reads the
|
|
305
|
+
* key from the service's own home — the one its installed definition names (root's file), never a
|
|
306
|
+
* path from the note, which the home's owner can edit — removes the computer from its workspace,
|
|
307
|
+
* uninstalls the service (stopped and its definition removed: without a key it could only fail),
|
|
308
|
+
* deletes the key there and clears the note in the home it was handed from. Nothing is changed
|
|
309
|
+
* when the removal is refused. Exits 0 once the computer is removed, even if some local cleanup
|
|
310
|
+
* could not be done (each such step is reported).
|
|
311
|
+
*/
|
|
312
|
+
async function unpairDedicated(flags, env, out, err, io, platform) {
|
|
313
|
+
const serviceIo = io.serviceIo ?? {};
|
|
314
|
+
const run = serviceIo.run ?? defaultServiceRunner;
|
|
315
|
+
const admin = serviceIo.isAdmin ??
|
|
316
|
+
(platform === 'win32' ? run('net', ['session']).status === 0 : (serviceIo.uid ?? process.getuid?.() ?? 1) === 0);
|
|
317
|
+
if (!admin) {
|
|
318
|
+
err(platform === 'win32'
|
|
319
|
+
? '✗ A dedicated service is removed from an administrator prompt.'
|
|
320
|
+
: '✗ A dedicated service is removed as root: run it with sudo.');
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
const fromHome = handedFromHome(flags, env, platform);
|
|
324
|
+
const note = readHandoff({ ...env, PROHOST_HOME: fromHome });
|
|
325
|
+
const explicit = str(flags, 'label');
|
|
326
|
+
// The note is only a hint for the label: everything else comes from what root installed.
|
|
327
|
+
const label = explicit ?? note?.handed_to.label;
|
|
328
|
+
if (!label) {
|
|
329
|
+
err('✗ Which dedicated service? Pass --label <label> (prohost host status --label <label> shows it).');
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
if (!SERVICE_LABEL.test(label) || !label.startsWith(LAUNCHD_HOST_LABEL)) {
|
|
333
|
+
err(`✗ "${label}" is not a label this CLI installs services under.`);
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
const installed = installedDedicatedService(platform, label, serviceIo);
|
|
337
|
+
if (!installed) {
|
|
338
|
+
err(`✗ No dedicated service ${label} is installed here (no service definition for it): remove the computer on its page in ProhostAI.`);
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
const flagUser = str(flags, 'service-user');
|
|
342
|
+
if (flagUser && installed.user && flagUser !== installed.user) {
|
|
343
|
+
err(`✗ ${label} runs as ${installed.user}, not ${flagUser}: nothing was changed.`);
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
const serviceUser = installed.user ?? flagUser;
|
|
347
|
+
const serviceHome = installed.home;
|
|
348
|
+
const noted = note?.handed_to.label === label ? note : undefined;
|
|
349
|
+
if (noted && path.resolve(noted.handed_to.service_home) !== path.resolve(serviceHome)) {
|
|
350
|
+
err(`✗ The note in ${fromHome} says ${label} lives in ${noted.handed_to.service_home}, but it is installed with ${serviceHome}: nothing was changed.`);
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
// The home the definition names must be the service account's own folder, not a link to elsewhere.
|
|
354
|
+
const homeProblem = serviceHomeProblem(platform, serviceHome, serviceUser, serviceIo);
|
|
355
|
+
if (homeProblem) {
|
|
356
|
+
err(`✗ ${homeProblem}: nothing was changed.`);
|
|
357
|
+
return 1;
|
|
358
|
+
}
|
|
359
|
+
const computer = serviceCredential(platform, serviceHome, serviceUser, serviceIo, label);
|
|
360
|
+
// Named only by the note: only the service that holds that very computer.
|
|
361
|
+
if (!explicit && note && computer && computer.computer_id !== note.computer_id) {
|
|
362
|
+
err(`✗ ${label} runs a different computer than the note in ${fromHome} names: nothing was changed. Pass --label to name the service.`);
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
365
|
+
const uninstall = () => {
|
|
366
|
+
const outcome = uninstallService({
|
|
367
|
+
platform,
|
|
368
|
+
dedicated: true,
|
|
369
|
+
...(serviceUser ? { serviceUser } : {}),
|
|
370
|
+
nodePath: process.execPath,
|
|
371
|
+
scriptPath: scriptPath(),
|
|
372
|
+
home: serviceHome,
|
|
373
|
+
pathEnv: env.PATH ?? '',
|
|
374
|
+
userHome: env.HOME || os.homedir(),
|
|
375
|
+
label,
|
|
376
|
+
}, serviceIo);
|
|
377
|
+
(outcome.ok ? out : err)(` ${outcome.message}`);
|
|
378
|
+
return outcome.ok;
|
|
379
|
+
};
|
|
380
|
+
if (computer && platform === 'win32' && publishedComputerId({ platform, label }, serviceIo) !== computer.computer_id) {
|
|
381
|
+
// The copy is an install-time snapshot: the service now runs another computer (it re-paired),
|
|
382
|
+
// or publishes none. Root never revokes the old one, nor uninstalls the new one, on a guess.
|
|
383
|
+
err(`✗ ${label} no longer publishes the computer its key on record is for: nothing was changed. ` +
|
|
384
|
+
'Sign the computer out on its page in ProhostAI (AI Employees › Computers).');
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
if (!computer && platform === 'win32') {
|
|
388
|
+
// No administrators' copy of the key (installed by a CLI before 0.16.5, or never paired): the
|
|
389
|
+
// key in the account's home is never read by an administrator, so nothing is changed here.
|
|
390
|
+
err(`✗ ${label} has no administrators' copy of its key (${windowsServiceCredentialPath({ label })}): nothing was changed. ` +
|
|
391
|
+
`Remove the computer on its page in ProhostAI, then: prohost host uninstall --label ${label} --dedicated`);
|
|
392
|
+
return 1;
|
|
393
|
+
}
|
|
394
|
+
if (!computer) {
|
|
395
|
+
// Nothing to remove it with: the service can't run either. Only a service named on purpose is
|
|
396
|
+
// uninstalled (never one picked by the note alone); its note goes with it.
|
|
397
|
+
err(`✗ ${serviceHome} holds no computer key for ${label}: remove the computer on its page in ProhostAI.`);
|
|
398
|
+
if (explicit) {
|
|
399
|
+
uninstall();
|
|
400
|
+
if (noted)
|
|
401
|
+
clearHandedHome(platform, fromHome, 'the note', serviceIo, err);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
err(` To uninstall it anyway: sudo prohost host unpair --label ${label}`);
|
|
405
|
+
}
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
408
|
+
const revoked = await revokeComputer({ baseUrl: computer.base_url, apiKey: computer.api_key, fetchImpl: io.fetchImpl }, computer.computer_id);
|
|
409
|
+
if (revoked.result === 'failed') {
|
|
410
|
+
err(`✗ ProhostAI did not remove this computer (${revoked.status === 0 ? revoked.error ?? 'no answer' : `HTTP ${revoked.status}`}). ` +
|
|
411
|
+
'Nothing was changed; try again, or remove it on its page in ProhostAI.');
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
414
|
+
if (platform === 'win32' && revoked.status === 401) {
|
|
415
|
+
// The administrators' copy is an install-time snapshot: a key refused may only mean it was
|
|
416
|
+
// re-paired since, and the service's live key is never read here. Never cleaned up on a guess.
|
|
417
|
+
err(`✗ ProhostAI no longer accepts the key on record for ${label} (re-paired since it was installed?): nothing was changed. ` +
|
|
418
|
+
`Remove the computer on its page in ProhostAI, then: prohost host uninstall --label ${label} --dedicated`);
|
|
419
|
+
return 1;
|
|
420
|
+
}
|
|
421
|
+
out(revoked.result === 'revoked' ? `✓ Removed "${computer.name}" from its workspace.` : `✓ "${computer.name}" was already removed from its workspace.`);
|
|
422
|
+
let clean = uninstall();
|
|
423
|
+
clean = clearHandedHome(platform, serviceHome, 'its key', serviceIo, err) && clean;
|
|
424
|
+
if (platform === 'win32') {
|
|
425
|
+
try {
|
|
426
|
+
(serviceIo.removeFile ?? ((f) => rmSync(f, { force: true })))(windowsServiceCredentialPath({ label }));
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
clean = false;
|
|
430
|
+
err(` ✗ Could not delete ${windowsServiceCredentialPath({ label })} (${e instanceof Error ? e.message : String(e)}): delete it yourself.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// The home it was handed from: its note, or — installed by a CLI before 0.16.5 — its own copy of this key.
|
|
434
|
+
const fromCopy = tryLoadComputer({ ...env, PROHOST_HOME: fromHome });
|
|
435
|
+
if ((noted || fromCopy?.computer_id === computer.computer_id) && path.resolve(fromHome) !== path.resolve(serviceHome)) {
|
|
436
|
+
clean = clearHandedHome(platform, fromHome, noted ? 'the note' : 'the old copy of its key', serviceIo, err) && clean;
|
|
437
|
+
}
|
|
438
|
+
out(clean ? ` ${label} is uninstalled and this computer's key is gone.` : ` The computer is removed; finish the steps above by hand.`);
|
|
439
|
+
return 0;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* What is wrong with the service home an installed definition names, if anything: on macOS / Linux
|
|
443
|
+
* it must be a real folder (no link) owned by the service account; on Windows, the administrators'
|
|
444
|
+
* folder for it (`windowsHomesDir`), no junction.
|
|
445
|
+
*/
|
|
446
|
+
function serviceHomeProblem(platform, home, user, serviceIo) {
|
|
447
|
+
if (platform === 'win32') {
|
|
448
|
+
const parent = path.win32.resolve(path.win32.dirname(home));
|
|
449
|
+
if (parent !== path.win32.resolve(windowsHomesDir()))
|
|
450
|
+
return `${home} is not a dedicated service home (${windowsHomesDir()})`;
|
|
451
|
+
const isSymlink = serviceIo.isSymlink ?? ((f) => {
|
|
452
|
+
try {
|
|
453
|
+
return lstatSync(f).isSymbolicLink();
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
return isSymlink(home) ? `${home} is a link or junction` : undefined;
|
|
460
|
+
}
|
|
461
|
+
const owner = serviceIo.ownerOf?.(home) ?? (() => {
|
|
462
|
+
try {
|
|
463
|
+
const stat = lstatSync(home);
|
|
464
|
+
return { uid: stat.uid, link: stat.isSymbolicLink() };
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
})();
|
|
470
|
+
if (!owner)
|
|
471
|
+
return `${home} is not there`;
|
|
472
|
+
if (owner.link)
|
|
473
|
+
return `${home} is a link`;
|
|
474
|
+
if (!user)
|
|
475
|
+
return `${home}: the service's account is unknown`;
|
|
476
|
+
const id = (serviceIo.run ?? defaultServiceRunner)('id', ['-u', user]);
|
|
477
|
+
if (id.status !== 0 || Number.parseInt(id.stdout.trim(), 10) !== owner.uid)
|
|
478
|
+
return `${home} is not owned by ${user}`;
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
/** A dedicated service home's key, read as `readServiceCredential` reads it (never through the account's links). */
|
|
482
|
+
function serviceCredential(platform, home, user, serviceIo, label) {
|
|
483
|
+
const text = readServiceCredential(platform, home, user, serviceIo, label);
|
|
484
|
+
if (!text)
|
|
485
|
+
return null;
|
|
486
|
+
try {
|
|
487
|
+
return JSON.parse(text);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** The home a dedicated install took its computer from: `--from-home`, else (under sudo `$PROHOST_HOME` is root's) `$SUDO_USER`'s. */
|
|
494
|
+
function handedFromHome(flags, env, platform) {
|
|
495
|
+
return str(flags, 'from-home') ?? (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : prohostHome(env));
|
|
496
|
+
}
|
|
497
|
+
function clearHandedHome(platform, home, what, serviceIo, err) {
|
|
498
|
+
const problem = rewriteHomeCredential(platform, home, undefined, serviceIo);
|
|
499
|
+
if (problem)
|
|
500
|
+
err(` ✗ Could not delete ${what} in ${home} (${problem}): delete ${path.join(home, 'host', 'computer.json')} yourself.`);
|
|
501
|
+
return !problem;
|
|
502
|
+
}
|
|
288
503
|
export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
289
504
|
const env = io.env ?? process.env;
|
|
290
505
|
const out = io.out ?? ((line) => process.stdout.write(`${line}\n`));
|
|
@@ -292,6 +507,13 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
292
507
|
const platform = io.platform ?? process.platform;
|
|
293
508
|
switch (subcommand) {
|
|
294
509
|
case 'pair': {
|
|
510
|
+
const handedOff = readHandoff(env);
|
|
511
|
+
if (handedOff) {
|
|
512
|
+
const result = { version: 1, outcome: 'handed_off', computer_id: handedOff.computer_id, account: handedOff.account, message: handoffNotice(handedOff) };
|
|
513
|
+
writeHostPairResult(result, env, err);
|
|
514
|
+
err(`✗ ${result.message}`);
|
|
515
|
+
return 1;
|
|
516
|
+
}
|
|
295
517
|
const baseUrl = str(flags, 'base-url') ?? env.PROHOST_BASE_URL ?? DEFAULT_API_BASE_URL;
|
|
296
518
|
const problem = baseUrlProblem(baseUrl);
|
|
297
519
|
if (problem) {
|
|
@@ -341,6 +563,12 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
341
563
|
}
|
|
342
564
|
}
|
|
343
565
|
case 'run': {
|
|
566
|
+
const handedOff = readHandoff(env);
|
|
567
|
+
if (handedOff) {
|
|
568
|
+
// The dedicated service runs this computer (and any homes adopted here are its business now).
|
|
569
|
+
err(`✗ ${handoffNotice(handedOff)}`);
|
|
570
|
+
return 1;
|
|
571
|
+
}
|
|
344
572
|
const computer = tryLoadComputer(env);
|
|
345
573
|
const adopted = loadAdopted(env);
|
|
346
574
|
if (!computer && !adopted?.homes.length) {
|
|
@@ -444,17 +672,65 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
444
672
|
// A dedicated service is labelled after its own home: without --label,
|
|
445
673
|
// every one this computer publishes state for is looked at too.
|
|
446
674
|
const labels = new Set([spec.label]);
|
|
447
|
-
|
|
675
|
+
// Elevated, whatever the flags say (on Windows an administrator prompt, as unpair checks it).
|
|
676
|
+
const admin = io.serviceIo?.isAdmin ??
|
|
677
|
+
(platform === 'win32'
|
|
678
|
+
? dedicated || (io.serviceIo?.run ?? defaultServiceRunner)('net', ['session']).status === 0
|
|
679
|
+
: (io.serviceIo?.uid ?? process.getuid?.() ?? 1) === 0);
|
|
448
680
|
if (!str(flags, 'label') && (admin || dedicated)) {
|
|
449
681
|
for (const dir of sharedDirs(io.sharedBase ?? sharedBase(platform)))
|
|
450
682
|
labels.add(path.basename(dir));
|
|
451
683
|
}
|
|
452
|
-
|
|
684
|
+
// A home that handed its computer to one of these services gets the key back once that
|
|
685
|
+
// service is gone (the mirror of install): `prohost host install` runs it there again.
|
|
686
|
+
const fromHome = admin ? handedFromHome(flags, env, platform) : undefined;
|
|
687
|
+
const note = fromHome ? readHandoff({ ...env, PROHOST_HOME: fromHome }) : undefined;
|
|
688
|
+
const handedTo = note && labels.has(note.handed_to.label) ? installedDedicatedService(platform, note.handed_to.label, io.serviceIo) : undefined;
|
|
689
|
+
// Read before the service goes (on Windows its administrators' copy goes with it), and only
|
|
690
|
+
// from the home the definition names when it is the service account's own folder.
|
|
691
|
+
const handedKey = note && handedTo && !serviceHomeProblem(platform, handedTo.home, handedTo.user, io.serviceIo ?? {})
|
|
692
|
+
? serviceCredential(platform, handedTo.home, handedTo.user, io.serviceIo ?? {}, note.handed_to.label)
|
|
693
|
+
: null;
|
|
694
|
+
// Windows reads the administrators' copy (an install-time snapshot): it is given back while the
|
|
695
|
+
// service publishes that same computer, or publishes none yet (never started, failed early)
|
|
696
|
+
// and the copy is for the computer the note names. Another computer published: never.
|
|
697
|
+
// A state that can't be read is not "none published": then nothing is given back.
|
|
698
|
+
const published = platform === 'win32' && note ? publishedComputer({ platform, label: note.handed_to.label }, io.serviceIo) : undefined;
|
|
699
|
+
const handedKeyLive = handedKey &&
|
|
700
|
+
(platform !== 'win32' ||
|
|
701
|
+
(published?.kind === 'none' ? handedKey.computer_id === note?.computer_id : published?.kind === 'published' && published.id === handedKey.computer_id))
|
|
702
|
+
? handedKey
|
|
703
|
+
: null;
|
|
704
|
+
const outcomes = [...labels].map((label) => ({ label, ...uninstallService({ ...spec, label }, io.serviceIo) }));
|
|
453
705
|
const removed = outcomes.filter((o) => !o.message.startsWith('No host service'));
|
|
454
706
|
const shown = removed.length > 0 ? removed : outcomes.slice(0, 1);
|
|
455
707
|
for (const o of shown)
|
|
456
708
|
(o.ok ? out : err)(o.message);
|
|
457
|
-
|
|
709
|
+
let ok = shown.every((o) => o.ok);
|
|
710
|
+
if (note && fromHome && outcomes.some((o) => o.label === note.handed_to.label && o.ok)) {
|
|
711
|
+
const key = handedKeyLive;
|
|
712
|
+
if (key && key.computer_id === note.computer_id && handedTo) {
|
|
713
|
+
const problem = rewriteHomeCredential(platform, fromHome, `${JSON.stringify(key, null, 2)}\n`, io.serviceIo);
|
|
714
|
+
if (problem) {
|
|
715
|
+
ok = false;
|
|
716
|
+
err(` ✗ Could not give the computer's key back to ${fromHome} (${problem}); it stays in ${handedTo.home}.`);
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
// On Windows the (administrators') service home keeps its copy: a later dedicated
|
|
720
|
+
// install finds that home and writes nothing into it, so it must still hold the key.
|
|
721
|
+
if (platform !== 'win32')
|
|
722
|
+
clearHandedHome(platform, handedTo.home, 'its key', io.serviceIo ?? {}, err);
|
|
723
|
+
out(` The computer's key is back in ${fromHome}: prohost host install runs it there (prohost host unpair removes it).`);
|
|
724
|
+
if (platform === 'win32')
|
|
725
|
+
out(` To install it as a dedicated service again, first remove the folder ${handedTo.home}.`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err);
|
|
730
|
+
out(` ${fromHome} no longer points at ${note.handed_to.label}. Its key was not found: add the computer again with prohost host pair, or remove it on its page in ProhostAI.`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return ok ? 0 : 1;
|
|
458
734
|
}
|
|
459
735
|
// 0.16.0 put a Windows dedicated service's home in the account's profile, under another
|
|
460
736
|
// label: replacing it here would leave two services on one key. Its owner removes it first.
|
|
@@ -469,12 +745,21 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
469
745
|
return 1;
|
|
470
746
|
}
|
|
471
747
|
}
|
|
748
|
+
// A home whose computer went to a dedicated service has no key to run a service of its own on.
|
|
749
|
+
const ownHandoff = dedicated ? undefined : readHandoff(env);
|
|
750
|
+
if (ownHandoff) {
|
|
751
|
+
err(`✗ ${handoffNotice(ownHandoff)}`);
|
|
752
|
+
return 1;
|
|
753
|
+
}
|
|
472
754
|
// The invoking user's home: under sudo, $PROHOST_HOME and $HOME are root's.
|
|
473
755
|
const adoptFrom = str(flags, 'from-home') ??
|
|
474
756
|
(env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
|
|
757
|
+
// The key this install takes over, read before it runs: only a copy it really made is retired.
|
|
758
|
+
const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
|
|
475
759
|
const outcome = installService(spec, io.serviceIo, {
|
|
476
760
|
...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
|
|
477
761
|
...(dedicated ? { adoptFrom } : {}),
|
|
762
|
+
...(dedicated && readHandoff({ ...env, PROHOST_HOME: adoptFrom })?.handed_to.label === spec.label ? { handedOffHere: true } : {}),
|
|
478
763
|
});
|
|
479
764
|
(outcome.ok ? out : err)(outcome.message);
|
|
480
765
|
if (outcome.ok && !tryLoadComputer({ ...env, PROHOST_HOME: home })) {
|
|
@@ -482,9 +767,41 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
482
767
|
? ` ! ${home} has no computer yet (none in ${adoptFrom}): pair as yourself, then run this again — or pass --from-home <your PROHOST_HOME>.`
|
|
483
768
|
: ` This home (${home}) has no computer yet: run prohost host pair.`);
|
|
484
769
|
}
|
|
770
|
+
if (outcome.ok && dedicated && handedFrom) {
|
|
771
|
+
// One key, one holder: the home it came from keeps a note of where the computer went, never
|
|
772
|
+
// the key — so an `unpair`, `pair` or personal `install` there can't revoke or run it twice.
|
|
773
|
+
const serviceHas = serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label);
|
|
774
|
+
if (serviceHas && serviceHas.computer_id === handedFrom.computer_id && serviceHas.api_key !== handedFrom.api_key) {
|
|
775
|
+
// Re-paired since the service got its copy: it holds an older key (no longer valid).
|
|
776
|
+
// This home's key is the live one, so it stays here.
|
|
777
|
+
err(` ! The service in ${home} holds an older key for this computer than ${adoptFrom} (re-paired since?). ` +
|
|
778
|
+
`This home keeps its key. To move it over: sudo prohost host uninstall --label ${spec.label}, ` +
|
|
779
|
+
(platform === 'win32'
|
|
780
|
+
? // An existing administrators' home is never written into: the whole folder goes.
|
|
781
|
+
`remove the folder ${home}, then install again.`
|
|
782
|
+
: `delete ${path.join(home, 'host', 'computer.json')}, then install again.`));
|
|
783
|
+
}
|
|
784
|
+
else if (serviceHas?.computer_id === handedFrom.computer_id) {
|
|
785
|
+
const note = handoffOf(handedFrom, {
|
|
786
|
+
label: spec.label,
|
|
787
|
+
service_user: serviceUser ?? null,
|
|
788
|
+
service_home: home,
|
|
789
|
+
at: new Date((io.now ?? Date.now)()).toISOString(),
|
|
790
|
+
});
|
|
791
|
+
const problem = rewriteHomeCredential(platform, adoptFrom, `${JSON.stringify(note, null, 2)}\n`, io.serviceIo);
|
|
792
|
+
if (problem) {
|
|
793
|
+
err(` ✗ The service has the computer, but its key is still in ${adoptFrom} too (${problem}). ` +
|
|
794
|
+
`Delete ${path.join(adoptFrom, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from that home: it would remove the computer.`);
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
485
801
|
if (outcome.ok && dedicated) {
|
|
486
802
|
out(` Its status, for anyone on this computer: ${sharedDirFor(spec)}`);
|
|
487
803
|
out(` Remove it with: sudo prohost host uninstall --label ${spec.label}`);
|
|
804
|
+
out(` Remove the computer from its workspace too: sudo prohost host unpair --label ${spec.label}`);
|
|
488
805
|
// The personal service of the home it took the computer from runs on the same key.
|
|
489
806
|
const personal = personalServiceOf(platform, adoptFrom, env.SUDO_USER);
|
|
490
807
|
if (personal && (io.serviceIo?.exists ?? existsSync)(personal.file)) {
|
|
@@ -495,6 +812,14 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
495
812
|
return outcome.ok ? 0 : 1;
|
|
496
813
|
}
|
|
497
814
|
case 'unpair': {
|
|
815
|
+
const labelFlag = str(flags, 'label');
|
|
816
|
+
if (labelFlag || flags.dedicated === true)
|
|
817
|
+
return unpairDedicated(flags, env, out, err, io, platform);
|
|
818
|
+
const handedOff = readHandoff(env);
|
|
819
|
+
if (handedOff) {
|
|
820
|
+
err(`✗ ${handoffNotice(handedOff)}`);
|
|
821
|
+
return 1;
|
|
822
|
+
}
|
|
498
823
|
const computer = tryLoadComputer(env);
|
|
499
824
|
if (!computer) {
|
|
500
825
|
out('This computer is not added to a workspace. Nothing to remove.');
|
package/dist/host/computer.d.ts
CHANGED
|
@@ -78,6 +78,37 @@ export interface ComputerCredentials {
|
|
|
78
78
|
}
|
|
79
79
|
export declare class ComputerMissingError extends Error {
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* What a home keeps once a dedicated install has taken its computer over (`host install
|
|
83
|
+
* --dedicated`): no key — the dedicated service holds the only one — just where it went, so
|
|
84
|
+
* `host status` / `unpair` / `pair` / `install` here explain instead of acting on a copy of it.
|
|
85
|
+
* `code_host_home` stays for the desktop app, which reads it from this file.
|
|
86
|
+
*/
|
|
87
|
+
export interface ComputerHandoff {
|
|
88
|
+
version: 1;
|
|
89
|
+
handed_to: {
|
|
90
|
+
label: string;
|
|
91
|
+
service_user: string | null;
|
|
92
|
+
service_home: string;
|
|
93
|
+
at: string;
|
|
94
|
+
};
|
|
95
|
+
computer_id: string;
|
|
96
|
+
name: string;
|
|
97
|
+
account: ProhostAccount;
|
|
98
|
+
base_url?: string;
|
|
99
|
+
code_host_home?: string;
|
|
100
|
+
}
|
|
101
|
+
export declare function handoffOf(credentials: ComputerCredentials, to: ComputerHandoff['handed_to']): ComputerHandoff;
|
|
102
|
+
/** A service label (launchd label, systemd unit, Windows task): the one rule, never a path. */
|
|
103
|
+
export declare const SERVICE_LABEL: RegExp;
|
|
104
|
+
/**
|
|
105
|
+
* This home's hand-off note, when its computer went to a dedicated service. The note is the home
|
|
106
|
+
* owner's file: its label is checked like any label, and nothing run as an administrator takes a
|
|
107
|
+
* path from it.
|
|
108
|
+
*/
|
|
109
|
+
export declare function readHandoff(env?: NodeJS.ProcessEnv): ComputerHandoff | undefined;
|
|
110
|
+
/** How to reach the dedicated service a home handed its computer to — the one line every refusal here says. */
|
|
111
|
+
export declare function handoffNotice(handoff: ComputerHandoff): string;
|
|
81
112
|
export declare function saveComputer(credentials: ComputerCredentials, env?: NodeJS.ProcessEnv): string;
|
|
82
113
|
export declare function loadComputer(env?: NodeJS.ProcessEnv): ComputerCredentials;
|
|
83
114
|
/** The `claude setup-token` token a dedicated install put in this home (`host/claude-oauth-token`). */
|
package/dist/host/computer.js
CHANGED
|
@@ -209,6 +209,39 @@ export function readObject(file) {
|
|
|
209
209
|
}
|
|
210
210
|
export class ComputerMissingError extends Error {
|
|
211
211
|
}
|
|
212
|
+
export function handoffOf(credentials, to) {
|
|
213
|
+
return {
|
|
214
|
+
version: 1,
|
|
215
|
+
handed_to: to,
|
|
216
|
+
computer_id: credentials.computer_id,
|
|
217
|
+
name: credentials.name,
|
|
218
|
+
account: credentials.account,
|
|
219
|
+
base_url: credentials.base_url,
|
|
220
|
+
...(credentials.code_host_home ? { code_host_home: credentials.code_host_home } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** A service label (launchd label, systemd unit, Windows task): the one rule, never a path. */
|
|
224
|
+
export const SERVICE_LABEL = /^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/;
|
|
225
|
+
/**
|
|
226
|
+
* This home's hand-off note, when its computer went to a dedicated service. The note is the home
|
|
227
|
+
* owner's file: its label is checked like any label, and nothing run as an administrator takes a
|
|
228
|
+
* path from it.
|
|
229
|
+
*/
|
|
230
|
+
export function readHandoff(env = process.env) {
|
|
231
|
+
const raw = readObject(computerPath(env));
|
|
232
|
+
const to = raw?.handed_to;
|
|
233
|
+
if (!raw || typeof raw.api_key === 'string' || !to || typeof to.label !== 'string' || typeof to.service_home !== 'string')
|
|
234
|
+
return undefined;
|
|
235
|
+
if (!SERVICE_LABEL.test(to.label))
|
|
236
|
+
return undefined;
|
|
237
|
+
return raw;
|
|
238
|
+
}
|
|
239
|
+
/** How to reach the dedicated service a home handed its computer to — the one line every refusal here says. */
|
|
240
|
+
export function handoffNotice(handoff) {
|
|
241
|
+
const { label, service_user: user } = handoff.handed_to;
|
|
242
|
+
return (`This computer ("${handoff.name}") runs as the dedicated service ${label}${user ? ` (user ${user})` : ''}; this home no longer holds its key. ` +
|
|
243
|
+
`See it with: prohost host status --label ${label} — remove it as an administrator with: sudo prohost host unpair --label ${label}`);
|
|
244
|
+
}
|
|
212
245
|
export function saveComputer(credentials, env = process.env) {
|
|
213
246
|
const file = computerPath(env);
|
|
214
247
|
writePrivateFile(file, credentials);
|
|
@@ -219,6 +252,9 @@ export function loadComputer(env = process.env) {
|
|
|
219
252
|
const raw = readObject(file);
|
|
220
253
|
if (!raw)
|
|
221
254
|
throw new ComputerMissingError(`This computer isn't added yet (no ${file}). Run: prohost host pair`);
|
|
255
|
+
const handoff = readHandoff(env);
|
|
256
|
+
if (handoff)
|
|
257
|
+
throw new ComputerMissingError(handoffNotice(handoff));
|
|
222
258
|
if (typeof raw.api_key !== 'string' || !raw.api_key || typeof raw.computer_id !== 'string' || !raw.computer_id) {
|
|
223
259
|
throw new ComputerMissingError(`${file} has no computer key. Add the computer again: prohost host pair`);
|
|
224
260
|
}
|
package/dist/host/install.d.ts
CHANGED
|
@@ -100,6 +100,13 @@ export declare function buildWindowsWrapper(spec: ServiceSpec): string;
|
|
|
100
100
|
export declare function windowsWrapperPath(spec: ServiceSpec): string;
|
|
101
101
|
/** Where a Windows dedicated install keeps its task definition and wrapper: administrators write, the account reads. */
|
|
102
102
|
export declare function windowsServiceDir(spec: Pick<ServiceSpec, 'label'>): string;
|
|
103
|
+
/**
|
|
104
|
+
* The administrators' copy of a Windows dedicated service's key (`services\<label>`: the account
|
|
105
|
+
* may read it, never change it) — what an administrator's `unpair` / `uninstall` reads. Written
|
|
106
|
+
* when an install creates the service's home; a home installed before 0.16.5 has none, and is
|
|
107
|
+
* then never handed a note nor unpaired by label (its computer is removed on its page).
|
|
108
|
+
*/
|
|
109
|
+
export declare function windowsServiceCredentialPath(spec: Pick<ServiceSpec, 'label'>): string;
|
|
103
110
|
/** Task Scheduler XML: at logon (personal) or at boot as the service user via S4U (dedicated). */
|
|
104
111
|
export declare function buildWindowsTaskXml(spec: ServiceSpec, currentUser: string): string;
|
|
105
112
|
export interface InstallOutcome {
|
|
@@ -126,6 +133,19 @@ export interface InstallIo {
|
|
|
126
133
|
link: boolean;
|
|
127
134
|
} | undefined;
|
|
128
135
|
removeFile?: (file: string) => void;
|
|
136
|
+
/** Rename `from` over `to` (Windows hand-off note: a fresh file, then renamed into place). */
|
|
137
|
+
rename?: (from: string, to: string) => void;
|
|
138
|
+
/** The real path of a folder, links resolved (Windows hand-off note: checked before and after). */
|
|
139
|
+
realpath?: (file: string) => string;
|
|
140
|
+
/**
|
|
141
|
+
* Open a file once and read it through that handle, with what `fstat` says about the very file
|
|
142
|
+
* opened (Windows service key: no re-open between the check and the read). Undefined: not there.
|
|
143
|
+
*/
|
|
144
|
+
readHandle?: (file: string) => {
|
|
145
|
+
isFile: boolean;
|
|
146
|
+
nlink: number;
|
|
147
|
+
text: string;
|
|
148
|
+
} | undefined;
|
|
129
149
|
exists?: (file: string) => boolean;
|
|
130
150
|
mkdir?: (dir: string, mode: number) => void;
|
|
131
151
|
/** Seams for waiting on a re-install's previous service to stop (macOS). */
|
|
@@ -138,6 +158,11 @@ export interface DedicatedSetup {
|
|
|
138
158
|
claudeTokenFile?: string;
|
|
139
159
|
/** The invoking user's `PROHOST_HOME`: its computer credential is copied into the service home if that has none. */
|
|
140
160
|
adoptFrom?: string;
|
|
161
|
+
/**
|
|
162
|
+
* That home already handed its computer to this very service (its note names this label): a
|
|
163
|
+
* reinstall, whose administrators' key copy (Windows) is kept although no credential is handed.
|
|
164
|
+
*/
|
|
165
|
+
handedOffHere?: boolean;
|
|
141
166
|
}
|
|
142
167
|
/** Validate a spec before anything is written. */
|
|
143
168
|
export declare function specProblem(spec: ServiceSpec, io?: InstallIo, setup?: DedicatedSetup): string | undefined;
|
|
@@ -145,6 +170,45 @@ export declare function specProblem(spec: ServiceSpec, io?: InstallIo, setup?: D
|
|
|
145
170
|
export declare function windowsHomesDir(): string;
|
|
146
171
|
/** Where a Windows dedicated install keeps the Claude token: an administrators' folder the account may only read. */
|
|
147
172
|
export declare function windowsTokenPath(spec: Pick<ServiceSpec, 'label'>): string;
|
|
173
|
+
/**
|
|
174
|
+
* The home (and account) an installed dedicated service runs with, read from its definition —
|
|
175
|
+
* root's / the administrators' own file (`/Library/LaunchDaemons`, `/etc/systemd/system`,
|
|
176
|
+
* `ProgramData\\Prohost\\services`) — or undefined when none is installed under `label`. What
|
|
177
|
+
* an administrator's `unpair` / `uninstall` trusts, never a note in an account's home.
|
|
178
|
+
*/
|
|
179
|
+
export declare function installedDedicatedService(platform: NodeJS.Platform, label: string, io?: InstallIo): {
|
|
180
|
+
home: string;
|
|
181
|
+
user?: string;
|
|
182
|
+
} | undefined;
|
|
183
|
+
/**
|
|
184
|
+
* A dedicated service home's `computer.json` text, when it holds a key, read the safe way for an
|
|
185
|
+
* administrator: on macOS / Linux as the service's account (`sudo -u` / `runuser … cat`), so a link
|
|
186
|
+
* the account planted at `host` or `computer.json` reaches only what that account may read itself;
|
|
187
|
+
* on Windows from the service's administrators-only copy, never from the account's home.
|
|
188
|
+
*/
|
|
189
|
+
export declare function readServiceCredential(platform: NodeJS.Platform, home: string, user: string | undefined, io?: InstallIo, label?: string): string | undefined;
|
|
190
|
+
/**
|
|
191
|
+
* What a dedicated service last published about its computer (`hosts\\<label>\\state.json`): its id,
|
|
192
|
+
* `none` when it has published nothing yet (no file, or the `{}` an install starts it with), or
|
|
193
|
+
* `unreadable` (a read error or JSON that does not parse, even after a retry) — never taken as none.
|
|
194
|
+
*/
|
|
195
|
+
export declare function publishedComputer(spec: Pick<ServiceSpec, 'platform' | 'label'>, io?: InstallIo): {
|
|
196
|
+
kind: 'published';
|
|
197
|
+
id: string;
|
|
198
|
+
} | {
|
|
199
|
+
kind: 'none';
|
|
200
|
+
} | {
|
|
201
|
+
kind: 'unreadable';
|
|
202
|
+
};
|
|
203
|
+
/** The computer id a dedicated service last published, if it did (see `publishedComputer`). */
|
|
204
|
+
export declare function publishedComputerId(spec: Pick<ServiceSpec, 'platform' | 'label'>, io?: InstallIo): string | undefined;
|
|
205
|
+
/**
|
|
206
|
+
* Write (`content`) or remove (`undefined`) `<home>/host/computer.json` in a home another user
|
|
207
|
+
* owns — the one a dedicated install took its computer from. As root on macOS / Linux it is done
|
|
208
|
+
* as the folder's owner (root never writes through a path an account controls); anyone else, and
|
|
209
|
+
* an administrator on Windows, writes it directly. Returns what went wrong, if anything.
|
|
210
|
+
*/
|
|
211
|
+
export declare function rewriteHomeCredential(platform: NodeJS.Platform, home: string, content: string | undefined, io?: InstallIo): string | undefined;
|
|
148
212
|
/**
|
|
149
213
|
* Create or take over the ProgramData folders a Windows dedicated install
|
|
150
214
|
* uses, top-down: each one is checked for a junction, owned by
|