@kortix/agent-tunnel 0.13.41 → 0.13.43
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 +120 -11
- package/dist/agent-cli.js +1144 -435
- package/dist/client-cli.js +1 -3
- package/package.json +1 -1
- package/src/agent/access.test.ts +164 -0
- package/src/agent/access.ts +176 -0
- package/src/agent/agent.test.ts +641 -0
- package/src/agent/agent.ts +333 -10
- package/src/agent/capabilities/security.test.ts +20 -1
- package/src/agent/cli-device-auth.test.ts +114 -0
- package/src/agent/cli-reauth.test.ts +108 -8
- package/src/agent/cli.ts +201 -57
- package/src/agent/config.test.ts +5 -0
- package/src/agent/config.ts +17 -2
- package/src/agent/credential-probe.ts +3 -3
- package/src/agent/credential-store.ts +2 -2
- package/src/agent/device-auth.test.ts +57 -0
- package/src/agent/device-auth.ts +45 -3
- package/src/agent/security/path-validator.ts +3 -1
- package/src/agent/service-control.ts +5 -2
- package/src/agent/service-drivers.test.ts +164 -0
- package/src/agent/service-drivers.ts +205 -56
- package/src/agent/service-lifecycle.test.ts +32 -18
- package/src/agent/service-paths.ts +42 -10
- package/src/agent/service.test.ts +126 -5
- package/src/agent/service.ts +99 -12
- package/src/agent/state-file.test.ts +32 -0
- package/src/agent/state-file.ts +58 -0
- package/src/client/tunnel-client.ts +6 -3
- package/src/server/ws-handler.test.ts +31 -1
- package/src/server/ws-handler.ts +3 -3
- package/src/shared/types.ts +12 -0
package/dist/client-cli.js
CHANGED
|
@@ -123,9 +123,7 @@ class TunnelClient {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
this.cachedTunnelId = null;
|
|
126
|
-
throw new TunnelClientError(-1,
|
|
127
|
-
` + `1. Create a tunnel connection
|
|
128
|
-
` + "2. Connect the local machine from the Kortix desktop app or run the tunnel connect command");
|
|
126
|
+
throw new TunnelClientError(-1, "No tunnel connection found. Connect this computer from the Kortix desktop app, " + "or run `npx @kortix/agent-tunnel connect`.");
|
|
129
127
|
}
|
|
130
128
|
}
|
|
131
129
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ACCESS_HOLD_MS,
|
|
8
|
+
accessFilePath,
|
|
9
|
+
accessRequestPath,
|
|
10
|
+
decideAccess,
|
|
11
|
+
keepAwakeCommand,
|
|
12
|
+
readAccess,
|
|
13
|
+
wakeDesktopApp,
|
|
14
|
+
writeAccess,
|
|
15
|
+
writeAccessRequest,
|
|
16
|
+
} from './access';
|
|
17
|
+
|
|
18
|
+
const homes: string[] = [];
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
function tempHome(): string {
|
|
23
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-access-'));
|
|
24
|
+
homes.push(home);
|
|
25
|
+
return home;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('access.json', () => {
|
|
29
|
+
test('a machine without the file keeps working: it reads as always allowed', () => {
|
|
30
|
+
expect(readAccess(tempHome())).toEqual({ mode: 'always', grantedUntil: null, deniedUntil: null, keepAwake: false });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a damaged file fails closed to ask, never to always', () => {
|
|
34
|
+
const home = tempHome();
|
|
35
|
+
writeFileSync(accessFilePath(home), '{not json');
|
|
36
|
+
expect(readAccess(home).mode).toBe('ask');
|
|
37
|
+
writeFileSync(accessFilePath(home), JSON.stringify({ mode: 'sometimes' }));
|
|
38
|
+
expect(readAccess(home).mode).toBe('ask');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('writes atomically and privately, and reads back', () => {
|
|
42
|
+
const home = join(tempHome(), 'nested');
|
|
43
|
+
const state = { mode: 'ask' as const, grantedUntil: '2030-01-01T00:00:00.000Z', deniedUntil: null, keepAwake: true };
|
|
44
|
+
writeAccess(state, home);
|
|
45
|
+
expect(readAccess(home)).toEqual(state);
|
|
46
|
+
expect(statSync(accessFilePath(home)).mode & 0o077).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('decideAccess', () => {
|
|
51
|
+
const now = Date.parse('2030-01-01T12:00:00.000Z');
|
|
52
|
+
const base = { grantedUntil: null, deniedUntil: null, keepAwake: false };
|
|
53
|
+
|
|
54
|
+
test('always runs, off refuses', () => {
|
|
55
|
+
expect(decideAccess({ ...base, mode: 'always' }, now)).toBe('run');
|
|
56
|
+
expect(decideAccess({ ...base, mode: 'off' }, now)).toBe('off');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('ask runs inside a grant, fails fast while denied, otherwise asks', () => {
|
|
60
|
+
expect(decideAccess({ ...base, mode: 'ask', grantedUntil: '2030-01-01T12:30:00.000Z' }, now)).toBe('run');
|
|
61
|
+
expect(decideAccess({ ...base, mode: 'ask', grantedUntil: '2030-01-01T11:59:59.000Z' }, now)).toBe('ask');
|
|
62
|
+
expect(decideAccess({ ...base, mode: 'ask', deniedUntil: '2030-01-01T12:05:00.000Z' }, now)).toBe('denied');
|
|
63
|
+
expect(decideAccess({ ...base, mode: 'ask', deniedUntil: '2030-01-01T11:00:00.000Z' }, now)).toBe('ask');
|
|
64
|
+
expect(decideAccess({ ...base, mode: 'ask' }, now)).toBe('ask');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a grant that reaches more than 24 hours ahead is not honoured', () => {
|
|
68
|
+
const tooLong = new Date(now + 48 * 3_600_000).toISOString();
|
|
69
|
+
expect(decideAccess({ ...base, mode: 'ask', grantedUntil: tooLong }, now)).toBe('ask');
|
|
70
|
+
const day = new Date(now + 24 * 3_600_000).toISOString();
|
|
71
|
+
expect(decideAccess({ ...base, mode: 'ask', grantedUntil: day }, now)).toBe('run');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the hold is 20 seconds', () => {
|
|
75
|
+
expect(ACCESS_HOLD_MS).toBe(20_000);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('access request and desktop wake', () => {
|
|
80
|
+
test('the request file names the capability and method', () => {
|
|
81
|
+
const home = tempHome();
|
|
82
|
+
const request = writeAccessRequest({ capability: 'filesystem', method: 'fs.list' }, home);
|
|
83
|
+
const saved = JSON.parse(readFileSync(accessRequestPath(home), 'utf8'));
|
|
84
|
+
expect(saved).toEqual(request);
|
|
85
|
+
expect(saved).toMatchObject({ capability: 'filesystem', method: 'fs.list' });
|
|
86
|
+
expect(typeof saved.id).toBe('string');
|
|
87
|
+
expect(Date.parse(saved.requestedAt)).toBeGreaterThan(Date.now() - 5_000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('launches the recorded desktop command when the app is not running, as a GUI app, not as Node', async () => {
|
|
91
|
+
const home = tempHome();
|
|
92
|
+
const marker = join(home, 'launched.json');
|
|
93
|
+
const script = join(home, 'fake-app.js');
|
|
94
|
+
writeFileSync(
|
|
95
|
+
script,
|
|
96
|
+
`require('fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ args: process.argv.slice(2), runAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, home: process.env.AGENT_TUNNEL_HOME ?? null, url: process.env.KORTIX_DESKTOP_URL ?? null }));`,
|
|
97
|
+
);
|
|
98
|
+
writeFileSync(
|
|
99
|
+
join(home, 'desktop-app.json'),
|
|
100
|
+
JSON.stringify({ command: process.execPath, args: [script, '--from-agent'], pid: 2 ** 22 + 4321, env: { KORTIX_DESKTOP_URL: 'http://localhost:3000' } }),
|
|
101
|
+
);
|
|
102
|
+
const previous = { run: process.env.ELECTRON_RUN_AS_NODE, home: process.env.AGENT_TUNNEL_HOME };
|
|
103
|
+
process.env.ELECTRON_RUN_AS_NODE = '1';
|
|
104
|
+
process.env.AGENT_TUNNEL_HOME = home;
|
|
105
|
+
try {
|
|
106
|
+
expect(wakeDesktopApp(home)).toBe('launched');
|
|
107
|
+
} finally {
|
|
108
|
+
if (previous.run === undefined) delete process.env.ELECTRON_RUN_AS_NODE;
|
|
109
|
+
else process.env.ELECTRON_RUN_AS_NODE = previous.run;
|
|
110
|
+
if (previous.home === undefined) delete process.env.AGENT_TUNNEL_HOME;
|
|
111
|
+
else process.env.AGENT_TUNNEL_HOME = previous.home;
|
|
112
|
+
}
|
|
113
|
+
const deadline = Date.now() + 5_000;
|
|
114
|
+
while (!existsSync(marker) && Date.now() < deadline) await Bun.sleep(20);
|
|
115
|
+
expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({
|
|
116
|
+
args: ['--from-agent'],
|
|
117
|
+
runAsNode: null,
|
|
118
|
+
home: null,
|
|
119
|
+
url: 'http://localhost:3000',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('does not launch a second copy of a running app, and ignores a missing or relative command', () => {
|
|
124
|
+
const home = tempHome();
|
|
125
|
+
expect(wakeDesktopApp(home)).toBe('no-app');
|
|
126
|
+
writeFileSync(join(home, 'desktop-app.json'), JSON.stringify({ command: process.execPath, args: [], pid: process.pid }));
|
|
127
|
+
expect(wakeDesktopApp(home)).toBe('running');
|
|
128
|
+
writeFileSync(join(home, 'desktop-app.json'), JSON.stringify({ command: 'kortix', args: [], pid: 0 }));
|
|
129
|
+
expect(wakeDesktopApp(home)).toBe('no-app');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a recorded app that no longer exists is no-app, and a spawn failure never crashes the agent', async () => {
|
|
133
|
+
const home = tempHome();
|
|
134
|
+
writeFileSync(join(home, 'desktop-app.json'), JSON.stringify({ command: '/tmp/.mount_gone/kortix', args: [], pid: 0 }));
|
|
135
|
+
expect(wakeDesktopApp(home)).toBe('no-app');
|
|
136
|
+
// Exists but cannot execute: ENOENT/EACCES arrives later as an 'error' event.
|
|
137
|
+
const notExecutable = join(home, 'Kortix');
|
|
138
|
+
writeFileSync(notExecutable, 'not a program', { mode: 0o600 });
|
|
139
|
+
writeFileSync(join(home, 'desktop-app.json'), JSON.stringify({ command: notExecutable, args: [], pid: 0 }));
|
|
140
|
+
expect(wakeDesktopApp(home)).toBe('launched');
|
|
141
|
+
await Bun.sleep(200);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a live pid with a stale heartbeat is not the app (pid reuse after a reboot)', () => {
|
|
145
|
+
const home = tempHome();
|
|
146
|
+
const file = join(home, 'desktop-app.json');
|
|
147
|
+
writeFileSync(file, JSON.stringify({ command: process.execPath, args: ['-e', '0'], pid: process.pid }));
|
|
148
|
+
expect(wakeDesktopApp(home)).toBe('running');
|
|
149
|
+
const old = new Date(Date.now() - 120_000);
|
|
150
|
+
utimesSync(file, old, old);
|
|
151
|
+
expect(wakeDesktopApp(home)).toBe('launched');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('keep awake', () => {
|
|
156
|
+
test('macOS uses caffeinate tied to the agent pid; Linux uses systemd-inhibit; Windows is unsupported', () => {
|
|
157
|
+
expect(keepAwakeCommand('darwin', 42)).toEqual({ command: 'caffeinate', args: ['-s', '-w', '42'] });
|
|
158
|
+
expect(keepAwakeCommand('linux', 42)).toEqual({
|
|
159
|
+
command: 'systemd-inhibit',
|
|
160
|
+
args: ['--what=sleep', '--who=Kortix', '--why=Keep this computer reachable', '--mode=block', 'tail', '--pid=42', '-f', '/dev/null'],
|
|
161
|
+
});
|
|
162
|
+
expect(keepAwakeCommand('win32', 42)).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
4
|
+
import { isAbsolute, join } from 'path';
|
|
5
|
+
|
|
6
|
+
import { agentTunnelHome } from './service-paths';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AnyDesk-style access control, enforced on this machine (contract v2 §A).
|
|
10
|
+
*
|
|
11
|
+
* `<home>/access.json` is the owner's standing answer. The desktop app writes
|
|
12
|
+
* it (grant, deny, mode); the agent reads it before every call. A machine
|
|
13
|
+
* without the file predates access control and stays `always`.
|
|
14
|
+
*/
|
|
15
|
+
export type AccessMode = 'ask' | 'always' | 'off';
|
|
16
|
+
|
|
17
|
+
export interface AccessState {
|
|
18
|
+
mode: AccessMode;
|
|
19
|
+
grantedUntil: string | null;
|
|
20
|
+
deniedUntil: string | null;
|
|
21
|
+
keepAwake: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AccessRequest {
|
|
25
|
+
id: string;
|
|
26
|
+
requestedAt: string;
|
|
27
|
+
capability: string;
|
|
28
|
+
method: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How long a call waits for the owner to answer the prompt. */
|
|
32
|
+
export const ACCESS_HOLD_MS = 20_000;
|
|
33
|
+
/** The desktop app touches desktop-app.json this often; older means it is gone (pid reuse). */
|
|
34
|
+
export const DESKTOP_APP_HEARTBEAT_MS = 30_000;
|
|
35
|
+
/** A grant never lasts longer than this, whatever the file says. */
|
|
36
|
+
export const MAX_GRANT_MS = 24 * 3_600_000;
|
|
37
|
+
|
|
38
|
+
const MODES: readonly AccessMode[] = ['ask', 'always', 'off'];
|
|
39
|
+
const COMPAT: AccessState = { mode: 'always', grantedUntil: null, deniedUntil: null, keepAwake: false };
|
|
40
|
+
|
|
41
|
+
export const accessFilePath = (home = agentTunnelHome()) => join(home, 'access.json');
|
|
42
|
+
export const accessRequestPath = (home = agentTunnelHome()) => join(home, 'access-request.json');
|
|
43
|
+
export const desktopAppPath = (home = agentTunnelHome()) => join(home, 'desktop-app.json');
|
|
44
|
+
|
|
45
|
+
const isoOrNull = (value: unknown) => (typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? value : null);
|
|
46
|
+
|
|
47
|
+
/** Missing file = `always` (compat). A damaged file fails closed to `ask`. */
|
|
48
|
+
export function readAccess(home = agentTunnelHome()): AccessState {
|
|
49
|
+
let raw: string;
|
|
50
|
+
try {
|
|
51
|
+
raw = readFileSync(accessFilePath(home), 'utf8');
|
|
52
|
+
} catch {
|
|
53
|
+
return { ...COMPAT };
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
57
|
+
return {
|
|
58
|
+
mode: MODES.includes(parsed.mode as AccessMode) ? (parsed.mode as AccessMode) : 'ask',
|
|
59
|
+
grantedUntil: isoOrNull(parsed.grantedUntil),
|
|
60
|
+
deniedUntil: isoOrNull(parsed.deniedUntil),
|
|
61
|
+
keepAwake: parsed.keepAwake === true,
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return { mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writePrivateJson(file: string, home: string, value: unknown): void {
|
|
69
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
70
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
71
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
72
|
+
try { chmodSync(tmp, 0o600); } catch {}
|
|
73
|
+
renameSync(tmp, file);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function writeAccess(state: AccessState, home = agentTunnelHome()): void {
|
|
77
|
+
writePrivateJson(accessFilePath(home), home, state);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type AccessDecision = 'run' | 'off' | 'denied' | 'ask';
|
|
81
|
+
|
|
82
|
+
export function decideAccess(state: AccessState, now = Date.now()): AccessDecision {
|
|
83
|
+
if (state.mode === 'always') return 'run';
|
|
84
|
+
if (state.mode === 'off') return 'off';
|
|
85
|
+
const denied = state.deniedUntil ? Date.parse(state.deniedUntil) : 0;
|
|
86
|
+
if (denied > now) return 'denied';
|
|
87
|
+
const granted = state.grantedUntil ? Date.parse(state.grantedUntil) : 0;
|
|
88
|
+
// ponytail: the cap is measured from now, not from when the grant was given;
|
|
89
|
+
// a hand-edited far-future grant still expires within 24 h of any check.
|
|
90
|
+
return granted > now && granted - now <= MAX_GRANT_MS ? 'run' : 'ask';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function writeAccessRequest(
|
|
94
|
+
input: { capability: string; method: string },
|
|
95
|
+
home = agentTunnelHome(),
|
|
96
|
+
): AccessRequest {
|
|
97
|
+
const request: AccessRequest = { id: randomUUID(), requestedAt: new Date().toISOString(), ...input };
|
|
98
|
+
writePrivateJson(accessRequestPath(home), home, request);
|
|
99
|
+
return request;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readAccessRequest(home = agentTunnelHome()): AccessRequest | null {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(readFileSync(accessRequestPath(home), 'utf8')) as AccessRequest;
|
|
105
|
+
return typeof parsed?.id === 'string' ? parsed : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function clearAccessRequest(id: string, home = agentTunnelHome()): void {
|
|
112
|
+
if (readAccessRequest(home)?.id === id) rmSync(accessRequestPath(home), { force: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isAlive(pid: unknown): boolean {
|
|
116
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
117
|
+
try {
|
|
118
|
+
process.kill(pid, 0);
|
|
119
|
+
return true;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Starts the desktop app so it can show the access prompt. The app records how
|
|
127
|
+
* to launch it in `desktop-app.json` at startup and touches the file while it
|
|
128
|
+
* runs; a running app sees the request file itself, so nothing is launched
|
|
129
|
+
* twice. A live pid alone is not proof: after a reboot another process may
|
|
130
|
+
* hold it.
|
|
131
|
+
*/
|
|
132
|
+
export function wakeDesktopApp(home = agentTunnelHome(), now = Date.now()): 'running' | 'launched' | 'no-app' {
|
|
133
|
+
let app: { command?: unknown; args?: unknown; pid?: unknown; env?: unknown };
|
|
134
|
+
let touchedAt = 0;
|
|
135
|
+
try {
|
|
136
|
+
app = JSON.parse(readFileSync(desktopAppPath(home), 'utf8'));
|
|
137
|
+
touchedAt = statSync(desktopAppPath(home)).mtimeMs;
|
|
138
|
+
} catch {
|
|
139
|
+
return 'no-app';
|
|
140
|
+
}
|
|
141
|
+
if (isAlive(app.pid) && now - touchedAt < DESKTOP_APP_HEARTBEAT_MS) return 'running';
|
|
142
|
+
// A moved or uninstalled app, or a Linux AppImage mount that is gone.
|
|
143
|
+
if (typeof app.command !== 'string' || !isAbsolute(app.command) || !existsSync(app.command)) return 'no-app';
|
|
144
|
+
const args = Array.isArray(app.args) ? app.args.filter((arg): arg is string => typeof arg === 'string') : [];
|
|
145
|
+
const recorded = app.env && typeof app.env === 'object' ? (app.env as Record<string, unknown>) : {};
|
|
146
|
+
// The service runs the app binary as Node; the app itself must start as an app.
|
|
147
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
148
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
149
|
+
delete env.AGENT_TUNNEL_HOME;
|
|
150
|
+
for (const [key, value] of Object.entries(recorded)) if (typeof value === 'string') env[key] = value;
|
|
151
|
+
try {
|
|
152
|
+
// Detached: its own process group, so a service restart never takes the app down.
|
|
153
|
+
const child = spawn(app.command, args, { detached: true, stdio: 'ignore', env });
|
|
154
|
+
// spawn reports ENOENT/EACCES as an async 'error' event; unheard, it kills the agent.
|
|
155
|
+
child.on('error', () => {});
|
|
156
|
+
child.unref();
|
|
157
|
+
return 'launched';
|
|
158
|
+
} catch {
|
|
159
|
+
return 'no-app';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The sleep blocker for `keepAwake`, tied to the agent's lifetime. Windows: unsupported. */
|
|
164
|
+
export function keepAwakeCommand(
|
|
165
|
+
platform: NodeJS.Platform,
|
|
166
|
+
pid: number,
|
|
167
|
+
): { command: string; args: string[] } | null {
|
|
168
|
+
if (platform === 'darwin') return { command: 'caffeinate', args: ['-s', '-w', String(pid)] };
|
|
169
|
+
if (platform === 'linux') {
|
|
170
|
+
return {
|
|
171
|
+
command: 'systemd-inhibit',
|
|
172
|
+
args: ['--what=sleep', '--who=Kortix', '--why=Keep this computer reachable', '--mode=block', 'tail', `--pid=${pid}`, '-f', '/dev/null'],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|