@prohost/cli 0.16.4 → 0.16.6
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 +52 -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.d.ts +3 -1
- package/dist/host/command.js +422 -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, systemdUnitOf, 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,246 @@ 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
|
+
/** Whether the service manager still has `spec`'s dedicated service (loaded / active / a task). */
|
|
494
|
+
export function serviceStillLoaded(spec, serviceIo, pidAlive = processAlive) {
|
|
495
|
+
const run = serviceIo.run ?? defaultServiceRunner;
|
|
496
|
+
try {
|
|
497
|
+
if (spec.platform === 'darwin')
|
|
498
|
+
return run('launchctl', ['print', `system/${spec.label}`]).status === 0;
|
|
499
|
+
if (spec.platform === 'win32') {
|
|
500
|
+
if (run('schtasks', ['/Query', '/TN', windowsTaskOf(spec)]).status === 0)
|
|
501
|
+
return true;
|
|
502
|
+
// Deleting a task does not stop what it started: the host's own lock names a live process.
|
|
503
|
+
// Only "no such file" means no lock; any other read error is an unknown, so still there (fail closed).
|
|
504
|
+
const lock = (serviceIo.readFile ?? ((f) => {
|
|
505
|
+
try {
|
|
506
|
+
return readFileSync(f, 'utf8');
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
if (e.code === 'ENOENT')
|
|
510
|
+
return undefined;
|
|
511
|
+
throw e;
|
|
512
|
+
}
|
|
513
|
+
}))(path.win32.join(spec.home, 'host', 'service.lock'));
|
|
514
|
+
const pid = lock ? JSON.parse(lock).pid : undefined;
|
|
515
|
+
return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pidAlive(pid);
|
|
516
|
+
}
|
|
517
|
+
return run('systemctl', ['is-active', '--quiet', systemdUnitOf(spec)]).status === 0;
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function processAlive(pid) {
|
|
524
|
+
try {
|
|
525
|
+
process.kill(pid, 0);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
catch (e) {
|
|
529
|
+
return e.code === 'EPERM';
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/** The home a dedicated install took its computer from: `--from-home`, else (under sudo `$PROHOST_HOME` is root's) `$SUDO_USER`'s. */
|
|
533
|
+
function handedFromHome(flags, env, platform) {
|
|
534
|
+
return str(flags, 'from-home') ?? (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : prohostHome(env));
|
|
535
|
+
}
|
|
536
|
+
function clearHandedHome(platform, home, what, serviceIo, err) {
|
|
537
|
+
const problem = rewriteHomeCredential(platform, home, undefined, serviceIo);
|
|
538
|
+
if (problem)
|
|
539
|
+
err(` ✗ Could not delete ${what} in ${home} (${problem}): delete ${path.join(home, 'host', 'computer.json')} yourself.`);
|
|
540
|
+
return !problem;
|
|
541
|
+
}
|
|
288
542
|
export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
289
543
|
const env = io.env ?? process.env;
|
|
290
544
|
const out = io.out ?? ((line) => process.stdout.write(`${line}\n`));
|
|
@@ -292,6 +546,13 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
292
546
|
const platform = io.platform ?? process.platform;
|
|
293
547
|
switch (subcommand) {
|
|
294
548
|
case 'pair': {
|
|
549
|
+
const handedOff = readHandoff(env);
|
|
550
|
+
if (handedOff) {
|
|
551
|
+
const result = { version: 1, outcome: 'handed_off', computer_id: handedOff.computer_id, account: handedOff.account, message: handoffNotice(handedOff) };
|
|
552
|
+
writeHostPairResult(result, env, err);
|
|
553
|
+
err(`✗ ${result.message}`);
|
|
554
|
+
return 1;
|
|
555
|
+
}
|
|
295
556
|
const baseUrl = str(flags, 'base-url') ?? env.PROHOST_BASE_URL ?? DEFAULT_API_BASE_URL;
|
|
296
557
|
const problem = baseUrlProblem(baseUrl);
|
|
297
558
|
if (problem) {
|
|
@@ -341,6 +602,12 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
341
602
|
}
|
|
342
603
|
}
|
|
343
604
|
case 'run': {
|
|
605
|
+
const handedOff = readHandoff(env);
|
|
606
|
+
if (handedOff) {
|
|
607
|
+
// The dedicated service runs this computer (and any homes adopted here are its business now).
|
|
608
|
+
err(`✗ ${handoffNotice(handedOff)}`);
|
|
609
|
+
return 1;
|
|
610
|
+
}
|
|
344
611
|
const computer = tryLoadComputer(env);
|
|
345
612
|
const adopted = loadAdopted(env);
|
|
346
613
|
if (!computer && !adopted?.homes.length) {
|
|
@@ -444,17 +711,65 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
444
711
|
// A dedicated service is labelled after its own home: without --label,
|
|
445
712
|
// every one this computer publishes state for is looked at too.
|
|
446
713
|
const labels = new Set([spec.label]);
|
|
447
|
-
|
|
714
|
+
// Elevated, whatever the flags say (on Windows an administrator prompt, as unpair checks it).
|
|
715
|
+
const admin = io.serviceIo?.isAdmin ??
|
|
716
|
+
(platform === 'win32'
|
|
717
|
+
? dedicated || (io.serviceIo?.run ?? defaultServiceRunner)('net', ['session']).status === 0
|
|
718
|
+
: (io.serviceIo?.uid ?? process.getuid?.() ?? 1) === 0);
|
|
448
719
|
if (!str(flags, 'label') && (admin || dedicated)) {
|
|
449
720
|
for (const dir of sharedDirs(io.sharedBase ?? sharedBase(platform)))
|
|
450
721
|
labels.add(path.basename(dir));
|
|
451
722
|
}
|
|
452
|
-
|
|
723
|
+
// A home that handed its computer to one of these services gets the key back once that
|
|
724
|
+
// service is gone (the mirror of install): `prohost host install` runs it there again.
|
|
725
|
+
const fromHome = admin ? handedFromHome(flags, env, platform) : undefined;
|
|
726
|
+
const note = fromHome ? readHandoff({ ...env, PROHOST_HOME: fromHome }) : undefined;
|
|
727
|
+
const handedTo = note && labels.has(note.handed_to.label) ? installedDedicatedService(platform, note.handed_to.label, io.serviceIo) : undefined;
|
|
728
|
+
// Read before the service goes (on Windows its administrators' copy goes with it), and only
|
|
729
|
+
// from the home the definition names when it is the service account's own folder.
|
|
730
|
+
const handedKey = note && handedTo && !serviceHomeProblem(platform, handedTo.home, handedTo.user, io.serviceIo ?? {})
|
|
731
|
+
? serviceCredential(platform, handedTo.home, handedTo.user, io.serviceIo ?? {}, note.handed_to.label)
|
|
732
|
+
: null;
|
|
733
|
+
// Windows reads the administrators' copy (an install-time snapshot): it is given back while the
|
|
734
|
+
// service publishes that same computer, or publishes none yet (never started, failed early)
|
|
735
|
+
// and the copy is for the computer the note names. Another computer published: never.
|
|
736
|
+
// A state that can't be read is not "none published": then nothing is given back.
|
|
737
|
+
const published = platform === 'win32' && note ? publishedComputer({ platform, label: note.handed_to.label }, io.serviceIo) : undefined;
|
|
738
|
+
const handedKeyLive = handedKey &&
|
|
739
|
+
(platform !== 'win32' ||
|
|
740
|
+
(published?.kind === 'none' ? handedKey.computer_id === note?.computer_id : published?.kind === 'published' && published.id === handedKey.computer_id))
|
|
741
|
+
? handedKey
|
|
742
|
+
: null;
|
|
743
|
+
const outcomes = [...labels].map((label) => ({ label, ...uninstallService({ ...spec, label }, io.serviceIo) }));
|
|
453
744
|
const removed = outcomes.filter((o) => !o.message.startsWith('No host service'));
|
|
454
745
|
const shown = removed.length > 0 ? removed : outcomes.slice(0, 1);
|
|
455
746
|
for (const o of shown)
|
|
456
747
|
(o.ok ? out : err)(o.message);
|
|
457
|
-
|
|
748
|
+
let ok = shown.every((o) => o.ok);
|
|
749
|
+
if (note && fromHome && outcomes.some((o) => o.label === note.handed_to.label && o.ok)) {
|
|
750
|
+
const key = handedKeyLive;
|
|
751
|
+
if (key && key.computer_id === note.computer_id && handedTo) {
|
|
752
|
+
const problem = rewriteHomeCredential(platform, fromHome, `${JSON.stringify(key, null, 2)}\n`, io.serviceIo);
|
|
753
|
+
if (problem) {
|
|
754
|
+
ok = false;
|
|
755
|
+
err(` ✗ Could not give the computer's key back to ${fromHome} (${problem}); it stays in ${handedTo.home}.`);
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
// On Windows the (administrators') service home keeps its copy: a later dedicated
|
|
759
|
+
// install finds that home and writes nothing into it, so it must still hold the key.
|
|
760
|
+
if (platform !== 'win32')
|
|
761
|
+
clearHandedHome(platform, handedTo.home, 'its key', io.serviceIo ?? {}, err);
|
|
762
|
+
out(` The computer's key is back in ${fromHome}: prohost host install runs it there (prohost host unpair removes it).`);
|
|
763
|
+
if (platform === 'win32')
|
|
764
|
+
out(` To install it as a dedicated service again, first remove the folder ${handedTo.home}.`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err);
|
|
769
|
+
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.`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return ok ? 0 : 1;
|
|
458
773
|
}
|
|
459
774
|
// 0.16.0 put a Windows dedicated service's home in the account's profile, under another
|
|
460
775
|
// label: replacing it here would leave two services on one key. Its owner removes it first.
|
|
@@ -469,12 +784,23 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
469
784
|
return 1;
|
|
470
785
|
}
|
|
471
786
|
}
|
|
787
|
+
// A home whose computer went to a dedicated service has no key to run a service of its own on.
|
|
788
|
+
const ownHandoff = dedicated ? undefined : readHandoff(env);
|
|
789
|
+
if (ownHandoff) {
|
|
790
|
+
err(`✗ ${handoffNotice(ownHandoff)}`);
|
|
791
|
+
return 1;
|
|
792
|
+
}
|
|
472
793
|
// The invoking user's home: under sudo, $PROHOST_HOME and $HOME are root's.
|
|
473
794
|
const adoptFrom = str(flags, 'from-home') ??
|
|
474
795
|
(env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
|
|
796
|
+
// The key this install takes over, read before it runs: only a copy it really made is retired.
|
|
797
|
+
const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
|
|
798
|
+
// Whether the service home already held a key before this install (then it copies none).
|
|
799
|
+
const serviceHadKey = handedFrom ? serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label) !== null : false;
|
|
475
800
|
const outcome = installService(spec, io.serviceIo, {
|
|
476
801
|
...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
|
|
477
802
|
...(dedicated ? { adoptFrom } : {}),
|
|
803
|
+
...(dedicated && readHandoff({ ...env, PROHOST_HOME: adoptFrom })?.handed_to.label === spec.label ? { handedOffHere: true } : {}),
|
|
478
804
|
});
|
|
479
805
|
(outcome.ok ? out : err)(outcome.message);
|
|
480
806
|
if (outcome.ok && !tryLoadComputer({ ...env, PROHOST_HOME: home })) {
|
|
@@ -482,9 +808,88 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
482
808
|
? ` ! ${home} has no computer yet (none in ${adoptFrom}): pair as yourself, then run this again — or pass --from-home <your PROHOST_HOME>.`
|
|
483
809
|
: ` This home (${home}) has no computer yet: run prohost host pair.`);
|
|
484
810
|
}
|
|
811
|
+
if (outcome.ok && dedicated && handedFrom) {
|
|
812
|
+
// One key, one holder: the home it came from keeps a note of where the computer went, never
|
|
813
|
+
// the key — so an `unpair`, `pair` or personal `install` there can't revoke or run it twice.
|
|
814
|
+
const serviceHas = serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label);
|
|
815
|
+
if (serviceHas && serviceHas.computer_id === handedFrom.computer_id && serviceHas.api_key !== handedFrom.api_key) {
|
|
816
|
+
// Re-paired since the service got its copy: it holds an older key (no longer valid).
|
|
817
|
+
// This home's key is the live one, so it stays here.
|
|
818
|
+
err(` ! The service in ${home} holds an older key for this computer than ${adoptFrom} (re-paired since?). ` +
|
|
819
|
+
`This home keeps its key. To move it over: sudo prohost host uninstall --label ${spec.label}, ` +
|
|
820
|
+
(platform === 'win32'
|
|
821
|
+
? // An existing administrators' home is never written into: the whole folder goes.
|
|
822
|
+
`remove the folder ${home}, then install again.`
|
|
823
|
+
: `delete ${path.join(home, 'host', 'computer.json')}, then install again.`));
|
|
824
|
+
}
|
|
825
|
+
else if (serviceHas?.computer_id === handedFrom.computer_id) {
|
|
826
|
+
const note = handoffOf(handedFrom, {
|
|
827
|
+
label: spec.label,
|
|
828
|
+
service_user: serviceUser ?? null,
|
|
829
|
+
service_home: home,
|
|
830
|
+
at: new Date((io.now ?? Date.now)()).toISOString(),
|
|
831
|
+
});
|
|
832
|
+
const problem = rewriteHomeCredential(platform, adoptFrom, `${JSON.stringify(note, null, 2)}\n`, io.serviceIo);
|
|
833
|
+
if (problem) {
|
|
834
|
+
// The note is part of the takeover: without it the key would live in both homes, and an
|
|
835
|
+
// `unpair` here would remove the computer under the service. What this install did is undone.
|
|
836
|
+
err(` ✗ Could not leave the hand-off note in ${adoptFrom} (${problem}).`);
|
|
837
|
+
// What the original home holds now decides the undo (on Windows a rename can land before a
|
|
838
|
+
// later check fails): its key still there → undo; the note there after all → handed over;
|
|
839
|
+
// neither → the service keeps the only key.
|
|
840
|
+
const sourceKey = tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom });
|
|
841
|
+
const sourceNote = readHandoff({ ...env, PROHOST_HOME: adoptFrom });
|
|
842
|
+
const handedAnyway = !sourceKey && sourceNote?.handed_to.label === spec.label && sourceNote?.computer_id === handedFrom.computer_id;
|
|
843
|
+
if (handedAnyway) {
|
|
844
|
+
err(` ! The note is in place after all: the computer's key is the service's alone. Check ${path.join(adoptFrom, 'host', 'computer.json')}.`);
|
|
845
|
+
}
|
|
846
|
+
else if (sourceKey?.computer_id !== handedFrom.computer_id) {
|
|
847
|
+
err(` ✗ ${adoptFrom} no longer holds this computer's key, so ${spec.label} keeps it: check ${path.join(adoptFrom, 'host', 'computer.json')}, never delete ${home}.`);
|
|
848
|
+
return 1;
|
|
849
|
+
}
|
|
850
|
+
else if (!serviceHadKey) {
|
|
851
|
+
const undone = (() => {
|
|
852
|
+
try {
|
|
853
|
+
return uninstallService(spec, io.serviceIo);
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
857
|
+
}
|
|
858
|
+
})();
|
|
859
|
+
// Removed and no longer loaded, not just "nothing failed": a Windows task that would not
|
|
860
|
+
// stop removes nothing, and a launchd / systemd service whose stop failed may still run.
|
|
861
|
+
const serviceGone = undone.ok && undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
|
|
862
|
+
const keyGone = serviceGone && !rewriteHomeCredential(platform, home, undefined, io.serviceIo);
|
|
863
|
+
if (keyGone && platform === 'win32') {
|
|
864
|
+
try {
|
|
865
|
+
(io.serviceIo?.removeFile ?? ((f) => rmSync(f, { force: true })))(windowsServiceCredentialPath(spec));
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
/* reported below with the rest */
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
err(serviceGone && keyGone
|
|
872
|
+
? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
|
|
873
|
+
(platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
|
|
874
|
+
: ` ✗ Could not fully undo it: remove ${spec.label} (sudo prohost host uninstall --label ${spec.label}) and ${path.join(home, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
|
|
875
|
+
return 1;
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
err(` ✗ ${home} already held this key before this install, so ${adoptFrom} still does too: never run \`prohost host unpair\` from that home. ` +
|
|
879
|
+
`Fix the problem above, then install again.`);
|
|
880
|
+
}
|
|
881
|
+
if (!handedAnyway)
|
|
882
|
+
return 1;
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
485
889
|
if (outcome.ok && dedicated) {
|
|
486
890
|
out(` Its status, for anyone on this computer: ${sharedDirFor(spec)}`);
|
|
487
891
|
out(` Remove it with: sudo prohost host uninstall --label ${spec.label}`);
|
|
892
|
+
out(` Remove the computer from its workspace too: sudo prohost host unpair --label ${spec.label}`);
|
|
488
893
|
// The personal service of the home it took the computer from runs on the same key.
|
|
489
894
|
const personal = personalServiceOf(platform, adoptFrom, env.SUDO_USER);
|
|
490
895
|
if (personal && (io.serviceIo?.exists ?? existsSync)(personal.file)) {
|
|
@@ -495,6 +900,14 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
495
900
|
return outcome.ok ? 0 : 1;
|
|
496
901
|
}
|
|
497
902
|
case 'unpair': {
|
|
903
|
+
const labelFlag = str(flags, 'label');
|
|
904
|
+
if (labelFlag || flags.dedicated === true)
|
|
905
|
+
return unpairDedicated(flags, env, out, err, io, platform);
|
|
906
|
+
const handedOff = readHandoff(env);
|
|
907
|
+
if (handedOff) {
|
|
908
|
+
err(`✗ ${handoffNotice(handedOff)}`);
|
|
909
|
+
return 1;
|
|
910
|
+
}
|
|
498
911
|
const computer = tryLoadComputer(env);
|
|
499
912
|
if (!computer) {
|
|
500
913
|
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
|
}
|