@kortix/agent-tunnel 0.13.40 → 0.13.42
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/src/agent/config.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { chmodSync, existsSync, lstatSync, readFileSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
2
|
+
import { basename, dirname, join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { isTunnelCapability } from '../shared/permissions';
|
|
5
5
|
import type { TunnelCapability } from '../shared/types';
|
|
6
|
+
import { agentTunnelHome, defaultAgentTunnelHome } from './service-paths';
|
|
6
7
|
|
|
7
8
|
export interface TunnelConfig {
|
|
8
9
|
token: string;
|
|
@@ -23,7 +24,7 @@ export interface TunnelConfig {
|
|
|
23
24
|
enabledCapabilities?: TunnelCapability[];
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const CONFIG_DIR =
|
|
27
|
+
const CONFIG_DIR = agentTunnelHome();
|
|
27
28
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
28
29
|
|
|
29
30
|
const DEFAULTS: Partial<TunnelConfig> = {
|
|
@@ -213,6 +214,19 @@ export function buildTunnelWsUrl(
|
|
|
213
214
|
return `${base}${wsPath}?${params.toString()}`;
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Directories no RPC may read or write, whatever config.json says. The agent
|
|
219
|
+
* home holds the credential, access.json (the owner's answer to the access
|
|
220
|
+
* prompt) and desktop-app.json (what the agent launches). A file grant must
|
|
221
|
+
* never rewrite them into a permanent grant or a command to run. A desktop
|
|
222
|
+
* home `<userData>/agent-tunnel/<sha8>` also protects its sibling homes.
|
|
223
|
+
*/
|
|
224
|
+
export function protectedAgentPaths(home: string = CONFIG_DIR): string[] {
|
|
225
|
+
const paths = new Set([home, defaultAgentTunnelHome()]);
|
|
226
|
+
if (basename(dirname(home)) === 'agent-tunnel') paths.add(dirname(home));
|
|
227
|
+
return [...paths];
|
|
228
|
+
}
|
|
229
|
+
|
|
216
230
|
export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig {
|
|
217
231
|
let fileConfig: Partial<TunnelConfig> = {};
|
|
218
232
|
if (existsSync(CONFIG_FILE)) {
|
|
@@ -250,6 +264,7 @@ export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig
|
|
|
250
264
|
merged.apiUrl = normalizeApiUrl(merged.apiUrl);
|
|
251
265
|
merged.wsPath = absoluteWsPath(merged.wsPath);
|
|
252
266
|
validateConfigValues(merged);
|
|
267
|
+
merged.blockedPaths = [...merged.blockedPaths, ...protectedAgentPaths()];
|
|
253
268
|
|
|
254
269
|
return merged;
|
|
255
270
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import '../node-ws-polyfill';
|
|
2
|
-
import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION,
|
|
2
|
+
import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION, isCredentialRejection } from './agent';
|
|
3
3
|
import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -78,8 +78,8 @@ export function probeCredentials(
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
socket.addEventListener('close', (event) => {
|
|
81
|
-
const { code } = event as CloseEvent;
|
|
82
|
-
if (
|
|
81
|
+
const { code, reason } = event as CloseEvent;
|
|
82
|
+
if (isCredentialRejection(code, reason)) return settle('rejected');
|
|
83
83
|
// The relay only replaces a socket it registered, and it only registers
|
|
84
84
|
// one that authenticated. Being replaced proves the credential is good.
|
|
85
85
|
if (code === AGENT_REPLACED_CLOSE_CODE) return settle('valid');
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
|
|
2
|
-
import { homedir } from 'os';
|
|
3
2
|
import { join } from 'path';
|
|
3
|
+
import { agentTunnelHome } from './service-paths';
|
|
4
4
|
|
|
5
|
-
export const CONFIG_DIR =
|
|
5
|
+
export const CONFIG_DIR = agentTunnelHome();
|
|
6
6
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
7
|
|
|
8
8
|
/** Fields that identify a pairing. Everything else in the file is user settings. */
|
|
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import {
|
|
3
3
|
InvalidDeviceAuthResponseError,
|
|
4
4
|
awaitDeviceAuthorization,
|
|
5
|
+
machineDisplayName,
|
|
5
6
|
parseDeviceAuthChallenge,
|
|
6
7
|
parseDeviceAuthStatus,
|
|
8
|
+
requestDeviceAuthorization,
|
|
7
9
|
} from './device-auth';
|
|
8
10
|
|
|
9
11
|
const VALID_TUNNEL_ID = '00000000-0000-4000-8000-000000000042';
|
|
@@ -130,3 +132,58 @@ describe('polling', () => {
|
|
|
130
132
|
expect(outcome).toEqual({ status: 'expired' });
|
|
131
133
|
});
|
|
132
134
|
});
|
|
135
|
+
|
|
136
|
+
describe('machine display name', () => {
|
|
137
|
+
const fails = () => {
|
|
138
|
+
throw new Error('scutil: not found');
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
test('macOS uses the ComputerName the person set', () => {
|
|
142
|
+
const name = machineDisplayName({
|
|
143
|
+
os: 'darwin',
|
|
144
|
+
host: 'MacBook-Pro-9.local',
|
|
145
|
+
env: {},
|
|
146
|
+
run: (command, args) => {
|
|
147
|
+
expect([command, ...args]).toEqual(['scutil', '--get', 'ComputerName']);
|
|
148
|
+
return 'Ada’s MacBook Pro\n';
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
expect(name).toBe('Ada’s MacBook Pro');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('macOS falls back to the hostname without .local when scutil fails or is empty', () => {
|
|
155
|
+
expect(machineDisplayName({ os: 'darwin', host: 'MacBook-Pro-9.local', env: {}, run: fails })).toBe(
|
|
156
|
+
'MacBook-Pro-9',
|
|
157
|
+
);
|
|
158
|
+
expect(machineDisplayName({ os: 'darwin', host: 'MacBook-Pro-9.local', env: {}, run: () => ' ' })).toBe(
|
|
159
|
+
'MacBook-Pro-9',
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('Windows uses COMPUTERNAME', () => {
|
|
164
|
+
expect(
|
|
165
|
+
machineDisplayName({ os: 'win32', host: 'desktop-1', env: { COMPUTERNAME: 'STUDIO-PC' }, run: fails }),
|
|
166
|
+
).toBe('STUDIO-PC');
|
|
167
|
+
expect(machineDisplayName({ os: 'win32', host: 'desktop-1', env: {}, run: fails })).toBe('desktop-1');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('Linux uses the hostname and never runs a command', () => {
|
|
171
|
+
expect(machineDisplayName({ os: 'linux', host: 'build-box.local', env: {}, run: fails })).toBe('build-box');
|
|
172
|
+
expect(machineDisplayName({ os: 'linux', host: 'build-box', env: {}, run: fails })).toBe('build-box');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('pairing proposes the display name as the machine name', async () => {
|
|
176
|
+
const original = globalThis.fetch;
|
|
177
|
+
let body: Record<string, unknown> = {};
|
|
178
|
+
globalThis.fetch = (async (_url: string, init: RequestInit) => {
|
|
179
|
+
body = JSON.parse(String(init.body));
|
|
180
|
+
return new Response(JSON.stringify(challenge()), { status: 200 });
|
|
181
|
+
}) as unknown as typeof fetch;
|
|
182
|
+
try {
|
|
183
|
+
await requestDeviceAuthorization('https://api.kortix.com/v1/tunnel');
|
|
184
|
+
} finally {
|
|
185
|
+
globalThis.fetch = original;
|
|
186
|
+
}
|
|
187
|
+
expect(body.machineHostname).toBe(machineDisplayName());
|
|
188
|
+
});
|
|
189
|
+
});
|
package/src/agent/device-auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
1
|
+
import { execFileSync, spawn } from 'child_process';
|
|
2
2
|
import { hostname, platform } from 'os';
|
|
3
3
|
import { isTunnelCapability } from '../shared/permissions';
|
|
4
4
|
import type { TunnelCapability } from '../shared/types';
|
|
@@ -10,6 +10,8 @@ import type { TunnelCapability } from '../shared/types';
|
|
|
10
10
|
* rules below can be read — and tested — without a CLI around them.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
export const UUID_PATTERN =
|
|
14
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
15
|
const TUNNEL_ID_PATTERN =
|
|
14
16
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
15
17
|
const SETUP_TOKEN_PATTERN = /^kortix_tnl_[A-Za-z0-9_-]{32,64}$/;
|
|
@@ -138,11 +140,51 @@ export function parseDeviceAuthStatus(value: unknown): DeviceAuthOutcome | null
|
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
|
|
143
|
+
/**
|
|
144
|
+
* The name a person gave this machine, e.g. "Ada's MacBook Pro" instead of
|
|
145
|
+
* `MacBook-Pro-9.local`. macOS: System Settings' Computer Name; Windows:
|
|
146
|
+
* COMPUTERNAME; elsewhere, and whenever those are unavailable, the hostname
|
|
147
|
+
* without a trailing `.local`.
|
|
148
|
+
*/
|
|
149
|
+
export function machineDisplayName({
|
|
150
|
+
os = platform(),
|
|
151
|
+
host = hostname(),
|
|
152
|
+
env = process.env,
|
|
153
|
+
run = (command: string, args: string[]) =>
|
|
154
|
+
execFileSync(command, args, { encoding: 'utf8', timeout: 1_000, stdio: ['ignore', 'pipe', 'ignore'] }),
|
|
155
|
+
}: {
|
|
156
|
+
os?: string;
|
|
157
|
+
host?: string;
|
|
158
|
+
env?: Record<string, string | undefined>;
|
|
159
|
+
run?: (command: string, args: string[]) => string;
|
|
160
|
+
} = {}): string {
|
|
161
|
+
const fallback = host.replace(/\.local$/i, '') || host;
|
|
162
|
+
try {
|
|
163
|
+
const name =
|
|
164
|
+
os === 'darwin' ? run('scutil', ['--get', 'ComputerName']) : os === 'win32' ? env.COMPUTERNAME : '';
|
|
165
|
+
return name?.trim() || fallback;
|
|
166
|
+
} catch {
|
|
167
|
+
return fallback;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Starts pairing. `projectId` names the project the machine is being connected
|
|
173
|
+
* to, so the approval page can skip its project picker. A relay that predates
|
|
174
|
+
* the field ignores it.
|
|
175
|
+
*/
|
|
176
|
+
export async function requestDeviceAuthorization(
|
|
177
|
+
apiUrl: string,
|
|
178
|
+
options: { projectId?: string } = {},
|
|
179
|
+
): Promise<DeviceAuthChallenge> {
|
|
142
180
|
const response = await fetch(`${apiUrl}/device-auth`, {
|
|
143
181
|
method: 'POST',
|
|
144
182
|
headers: { 'Content-Type': 'application/json' },
|
|
145
|
-
body: JSON.stringify({
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
// The default machine name on the approval page.
|
|
185
|
+
machineHostname: machineDisplayName(),
|
|
186
|
+
...(options.projectId ? { project_id: options.projectId } : {}),
|
|
187
|
+
}),
|
|
146
188
|
});
|
|
147
189
|
if (!response.ok) {
|
|
148
190
|
const body = await response.text().catch(() => '');
|
|
@@ -56,7 +56,9 @@ function assertAllowedResolvedPath(
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
if (!withinAllowed) {
|
|
59
|
-
throw new Error(
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Access denied: path "${originalPath}" is outside allowed directories (${allowedPaths.join(', ')})`,
|
|
61
|
+
);
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
}
|
|
@@ -2,7 +2,9 @@ import {
|
|
|
2
2
|
type ServiceStatus,
|
|
3
3
|
getServiceStatus,
|
|
4
4
|
installService,
|
|
5
|
+
pauseService,
|
|
5
6
|
restartService,
|
|
7
|
+
resumeService,
|
|
6
8
|
startService,
|
|
7
9
|
stopService,
|
|
8
10
|
uninstallService,
|
|
@@ -39,8 +41,8 @@ export function acquireTunnelLease(): TunnelLease {
|
|
|
39
41
|
|
|
40
42
|
/** Service verbs that map one-to-one onto a supervisor operation. */
|
|
41
43
|
export const SERVICE_ACTIONS = {
|
|
42
|
-
start: { run:
|
|
43
|
-
stop: { run:
|
|
44
|
+
start: { run: resumeService, label: 'started' },
|
|
45
|
+
stop: { run: pauseService, label: 'paused until `agent-tunnel start`' },
|
|
44
46
|
restart: { run: restartService, label: 'restarted' },
|
|
45
47
|
uninstall: { run: uninstallService, label: 'removed' },
|
|
46
48
|
install: { run: installService, label: 'installed' },
|
|
@@ -50,6 +52,7 @@ export type ServiceAction = keyof typeof SERVICE_ACTIONS;
|
|
|
50
52
|
|
|
51
53
|
export function describeService(status: ServiceStatus): string {
|
|
52
54
|
if (!status.installed) return `${glyph.off} not installed`;
|
|
55
|
+
if (status.enabled === false) return `${c.yellow}○${c.reset} paused ${c.dim}· stays stopped until \`agent-tunnel start\`${c.reset}`;
|
|
53
56
|
if (status.active) return `${glyph.on} running ${c.dim}· starts at login${c.reset}`;
|
|
54
57
|
return `${c.yellow}○${c.reset} installed ${c.dim}· stopped${c.reset}`;
|
|
55
58
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { pause, renderWindowsTaskXml, serviceDriverFor, supervisor } from './service-drivers';
|
|
8
|
+
import { getServicePaths } from './service-paths';
|
|
9
|
+
|
|
10
|
+
const realRun = supervisor.run;
|
|
11
|
+
const realPause = pause.ms;
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
supervisor.run = realRun;
|
|
14
|
+
pause.ms = realPause;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Records every supervisor command; `answers` fakes the ones a test cares about. */
|
|
18
|
+
function recordCommands(answers: Record<string, { ok: boolean; detail?: string }> = {}) {
|
|
19
|
+
const calls: string[] = [];
|
|
20
|
+
supervisor.run = (command, args) => {
|
|
21
|
+
const line = [command, ...args].join(' ');
|
|
22
|
+
calls.push(line);
|
|
23
|
+
const hit = Object.entries(answers).find(([prefix]) => line.startsWith(prefix));
|
|
24
|
+
return { ok: hit ? hit[1].ok : true, detail: hit?.[1].detail ?? '' };
|
|
25
|
+
};
|
|
26
|
+
return calls;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const paths = getServicePaths('/tmp/agent-tunnel-drivers-test');
|
|
30
|
+
|
|
31
|
+
describe('durable pause (R3)', () => {
|
|
32
|
+
test('launchd: pause disables the job before unloading it, so login does not start it again', () => {
|
|
33
|
+
const calls = recordCommands();
|
|
34
|
+
serviceDriverFor('darwin')!.pause(paths, true);
|
|
35
|
+
const uid = process.getuid!();
|
|
36
|
+
expect(calls).toEqual([
|
|
37
|
+
`launchctl disable gui/${uid}/${paths.label}`,
|
|
38
|
+
`launchctl bootout gui/${uid} ${paths.launchdPlist}`,
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('launchd: resume enables, loads and starts the job', () => {
|
|
43
|
+
const calls = recordCommands();
|
|
44
|
+
const outcome = serviceDriverFor('darwin')!.resume(paths, true);
|
|
45
|
+
const uid = process.getuid!();
|
|
46
|
+
expect(calls).toEqual([
|
|
47
|
+
`launchctl enable gui/${uid}/${paths.label}`,
|
|
48
|
+
`launchctl bootstrap gui/${uid} ${paths.launchdPlist}`,
|
|
49
|
+
`launchctl kickstart gui/${uid}/${paths.label}`,
|
|
50
|
+
]);
|
|
51
|
+
expect(outcome.active).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('launchd: status reports a disabled job as paused', () => {
|
|
55
|
+
const uid = process.getuid!();
|
|
56
|
+
recordCommands({
|
|
57
|
+
'launchctl print-disabled': { ok: true, detail: `disabled services = {\n\t"${paths.label}" => disabled\n}` },
|
|
58
|
+
'launchctl print ': { ok: false },
|
|
59
|
+
});
|
|
60
|
+
expect(serviceDriverFor('darwin')!.status(paths, false)).toMatchObject({ active: false, enabled: false });
|
|
61
|
+
recordCommands({
|
|
62
|
+
'launchctl print-disabled': { ok: true, detail: `disabled services = {\n\t"${paths.label}" => enabled\n}` },
|
|
63
|
+
});
|
|
64
|
+
expect(serviceDriverFor('darwin')!.status(paths, false)).toMatchObject({ active: true, enabled: true });
|
|
65
|
+
expect(uid).toBeGreaterThanOrEqual(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('systemd: pause is disable --now; resume is enable --now', () => {
|
|
69
|
+
const calls = recordCommands();
|
|
70
|
+
serviceDriverFor('linux')!.pause(paths, true);
|
|
71
|
+
serviceDriverFor('linux')!.resume(paths, true);
|
|
72
|
+
expect(calls).toEqual([
|
|
73
|
+
`systemctl --user disable --now ${paths.label}.service`,
|
|
74
|
+
`systemctl --user enable --now ${paths.label}.service`,
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('windows: pause disables and ends the task; resume enables and runs it', () => {
|
|
79
|
+
const calls = recordCommands();
|
|
80
|
+
serviceDriverFor('win32')!.pause(paths, true);
|
|
81
|
+
serviceDriverFor('win32')!.resume(paths, true);
|
|
82
|
+
expect(calls).toEqual([
|
|
83
|
+
`schtasks.exe /Change /TN ${paths.label} /DISABLE`,
|
|
84
|
+
`schtasks.exe /End /TN ${paths.label}`,
|
|
85
|
+
`schtasks.exe /Change /TN ${paths.label} /ENABLE`,
|
|
86
|
+
`schtasks.exe /Run /TN ${paths.label}`,
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('install really starts the service', () => {
|
|
92
|
+
const runner = { command: '/usr/bin/node', args: ['/opt/kortix/agent-cli.js', 'run', '--service'] };
|
|
93
|
+
function tempPaths() {
|
|
94
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-tunnel-install-'));
|
|
95
|
+
const base = getServicePaths(dir);
|
|
96
|
+
return {
|
|
97
|
+
dir,
|
|
98
|
+
paths: {
|
|
99
|
+
...base,
|
|
100
|
+
launchdPlist: join(dir, 'job.plist'),
|
|
101
|
+
systemdUnit: join(dir, 'job.service'),
|
|
102
|
+
windowsScript: join(dir, 'job.ps1'),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test('launchd: a bootstrap launchd did not load is retried, and never reported active', () => {
|
|
108
|
+
const { dir, paths } = tempPaths();
|
|
109
|
+
pause.ms = () => {};
|
|
110
|
+
try {
|
|
111
|
+
const calls = recordCommands({ 'launchctl print': { ok: false }, 'launchctl bootstrap': { ok: false, detail: 'Bootstrap failed: 5: Input/output error' } });
|
|
112
|
+
const outcome = serviceDriverFor('darwin')!.install(paths, runner);
|
|
113
|
+
expect(outcome.active).toBeNull();
|
|
114
|
+
expect(calls.filter((c) => c.startsWith('launchctl bootstrap'))).toHaveLength(3);
|
|
115
|
+
|
|
116
|
+
recordCommands();
|
|
117
|
+
expect(serviceDriverFor('darwin')!.install(paths, runner).active).toBe(true);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('systemd: install restarts a running unit so an update rolls the agent, and keeps it past logout', () => {
|
|
124
|
+
const { dir, paths } = tempPaths();
|
|
125
|
+
try {
|
|
126
|
+
const calls = recordCommands({ 'loginctl show-user': { ok: true, detail: 'Linger=no' } });
|
|
127
|
+
const outcome = serviceDriverFor('linux')!.install(paths, runner);
|
|
128
|
+
expect(calls).toContain(`systemctl --user restart ${paths.label}.service`);
|
|
129
|
+
expect(calls.some((c) => c.startsWith('loginctl enable-linger'))).toBe(true);
|
|
130
|
+
expect(outcome.active).toBe(true);
|
|
131
|
+
|
|
132
|
+
recordCommands({ 'loginctl': { ok: false, detail: 'Linger=no' } });
|
|
133
|
+
expect(serviceDriverFor('linux')!.install(paths, runner).detail).toContain('stops when you log out');
|
|
134
|
+
} finally {
|
|
135
|
+
rmSync(dir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('windows: the task is registered from XML without battery or 72 h limits, hidden, and restarted on install', () => {
|
|
140
|
+
const { dir, paths } = tempPaths();
|
|
141
|
+
try {
|
|
142
|
+
const calls = recordCommands();
|
|
143
|
+
serviceDriverFor('win32')!.install(paths, runner);
|
|
144
|
+
expect(calls[0]).toBe(`schtasks.exe /Create /TN ${paths.label} /XML ${join(dir, 'job.xml')} /F`);
|
|
145
|
+
expect(calls.slice(1)).toEqual([`schtasks.exe /End /TN ${paths.label}`, `schtasks.exe /Run /TN ${paths.label}`]);
|
|
146
|
+
const xml = readFileSync(join(dir, 'job.xml')).toString('utf16le');
|
|
147
|
+
expect(xml.charCodeAt(0)).toBe(0xfeff);
|
|
148
|
+
expect(xml).toContain('<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>');
|
|
149
|
+
expect(xml).toContain('<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>');
|
|
150
|
+
expect(xml).toContain('<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>');
|
|
151
|
+
expect(xml).toContain('-WindowStyle Hidden');
|
|
152
|
+
expect(renderWindowsTaskXml(paths, 'DOMAIN\\a&b')).toContain('<UserId>DOMAIN\\a&b</UserId>');
|
|
153
|
+
} finally {
|
|
154
|
+
rmSync(dir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('windows: status reads the non-localized task state', () => {
|
|
159
|
+
recordCommands({ 'powershell.exe': { ok: true, detail: 'Running\r\n' } });
|
|
160
|
+
expect(serviceDriverFor('win32')!.status(paths, false)).toMatchObject({ active: true, enabled: true });
|
|
161
|
+
recordCommands({ 'powershell.exe': { ok: true, detail: 'Disabled' } });
|
|
162
|
+
expect(serviceDriverFor('win32')!.status(paths, false)).toMatchObject({ active: false, enabled: false });
|
|
163
|
+
});
|
|
164
|
+
});
|