Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,15 +324,18 @@ outside it moves to its own issue and pull request.
and stim-server stops it when the build is cancelled, the client loses
`build`, or the machine's available memory runs low. Gradle stops a daemon
whose Gradle home is deleted. `stim-server service` installs,
reports and removes the worker's per-user service, a LaunchAgent on macOS
or a systemd user unit on Linux, and, only when asked, a tailnet-only serve
route; it never enables Funnel, never runs sudo and never touches pairings,
reports and removes the worker's per-user service, a LaunchAgent on macOS,
a systemd user unit on Linux or a scheduled task on Windows, whose action is
Stim's supervisor because Task Scheduler does not restart an exited process,
and, only when asked, a tailnet-only serve
route; it never enables Funnel, never runs sudo or elevates, and never touches pairings,
`$STIM_HOME/server` or settings. `stim-server service update` and
`rollback` install a server version beside the running one under
`~/Library/Application Support/Stim/services/<label>/versions/` on macOS or
`~/.local/share/stim/services/<label>/versions/` on Linux, keeping the
`~/.local/share/stim/services/<label>/versions/` on Linux or
`%LOCALAPPDATA%\Stim\services\<label>\versions\` on Windows, keeping the
running and the previous version, and repoint only the service's server
script; a failed health check switches back. A Linux worker takes Android
script; a failed health check switches back. A Linux or Windows worker takes Android
builds only; setup there approves `build` and refuses `device-host`, and off macOS
stim-server refuses device-host requests and grants. A client approved for `build` or
`device-host` can run the same update on this machine over the tailnet, and no
Expand Down
60 changes: 54 additions & 6 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ If the server is not installed globally, use `npx --yes --package @stim-cli/serv
in place of `stim-server`, with the exact version from the client machine.
At least one capability is required. The expiry must be in the future and at
most two hours ahead. The defaults are port 7787 and label `dev.stim.server`.
Argument and preflight refusals change nothing. Preflight requires macOS or
Linux, Node 22.12+, running Tailscale, the client node on this tailnet, and
Argument and preflight refusals change nothing. Preflight requires macOS,
Linux or Windows, Node 22.12+, running Tailscale, the client node on this tailnet, and
this user's GUI login session on macOS or systemd user manager on Linux. On
Linux, setup takes `--build` only and refuses `--device-host`, because hosting
needs a Mac; see [On Linux](#on-linux). Off macOS, stim-server also refuses a
Linux and Windows, setup takes `--build` only and refuses `--device-host`, because hosting
needs a Mac; see [On Linux](#on-linux) and [On Windows](#on-windows). Off macOS, stim-server also refuses a
device-host request at hello and `stim-server devices grant <id> --device-host`.

Running setup on the worker is the worker-side approval. It pre-approves at
Expand All @@ -141,7 +141,7 @@ any change. Requests themselves lapse after 15 minutes.
Setup installs its exact server release into the service's `versions/`
directory (`~/Library/Application Support/Stim/services/<label>/versions/` on
macOS), installs Stim Host on macOS, installs or reuses the managed
LaunchAgent or systemd user service, and creates or reuses a
LaunchAgent, systemd user service or scheduled task, and creates or reuses a
tailnet-only HTTPS route on 7443 or the next free port. Older managed services
use the service update busy-wait and rollback behavior. Newer versions stay
installed; tools report a Stim build difference. A reused server must be at
Expand Down Expand Up @@ -1024,7 +1024,8 @@ before replacement. Automatic placement remains in
## Run as a service

`stim-server service install` runs stim-server as a per-user LaunchAgent on
macOS, or a systemd user service on Linux ([On Linux](#on-linux)), so a remote
macOS, a systemd user service on Linux ([On Linux](#on-linux)), or a scheduled
task on Windows ([On Windows](#on-windows)), so a remote
machine or a phone-serving Mac keeps it running without a terminal. Other
systems refuse; start stim-server from your own service manager there:

Expand Down Expand Up @@ -1226,6 +1227,53 @@ installs Stim Host and has no permission grants.
`--device-host`, needs no GUI session, and skips Stim Host, the permission
steps and the Xcode, iOS runtime and CocoaPods checks.

### On Windows

On Windows, `service` manages a per-user scheduled task with the same commands,
flags and checks, and never needs elevation. A Windows machine takes offloaded
Android builds only, so it never installs Stim Host and has no permission
grants.

- `install` writes the service file
`%LOCALAPPDATA%\Stim\services\<label>.json` (the server's `node`, script,
arguments and environment, `STIM_SERVICE_LABEL=<label>`, the serve route and
the previous server script), copies the supervisor
`stim-service-runner.mjs` into `%LOCALAPPDATA%\Stim\services\<label>\`, and
registers the task `\Stim\<label>` with `schtasks /Create`, then starts it.
It logs to `%LOCALAPPDATA%\Stim\Logs\<label>.log`.
- The task starts at your logon and runs whether or not you are logged on
(Task Scheduler's S4U logon type, which an ordinary user can register), so it
keeps running after you sign out; after a reboot it starts at your next
logon. Task Scheduler restarts a task only when it fails to start, not when
its process exits, so the task runs the supervisor, which restarts the
server 30 seconds after it exits and reads the service file again each time.
The supervisor records its pid, the server's, the number of runs and the last
exit code in `supervisor.json`, which `status` reports, and its own errors,
such as an unreadable service file, in `supervisor.log` beside it. The task
runs at normal priority, so builds are not slowed by Task Scheduler's
below-normal default.
- Stopping the service ends the supervisor's process tree with
`taskkill /T /F`; a remote update the server starts runs detached from that
tree, so restarting the service does not stop it.
- `update` and `rollback` keep versions under
`%LOCALAPPDATA%\Stim\services\<label>\versions\` and rewrite only the
service file's arguments and previous script. `uninstall` stops the task,
deletes it and removes the service file.
- The task's environment is your user environment, without a login shell.
Builds start tools such as Expo's config loader with the first `node.exe` on
`PATH`, and a version manager's `node.exe` shim, such as pnpm's, breaks their
Node IPC, so builds fail with `fingerprint-mismatch`. Stim keeps `PATH` as
you set it; `install` names a first `node.exe` that is not the Node running
the server, and `--path-prepend` puts the real Node first:

```powershell
stim-server service install --serve --path-prepend "C:\Program Files\nodejs" `
--env JAVA_HOME="C:\Program Files\Eclipse Adoptium\jdk-17" `
--env ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk"
```

- `stim-server setup` works on Windows for `--build`, as on Linux.

## Remote update

A machine this one approved for builds (`--build`) or device hosting
Expand Down
102 changes: 102 additions & 0 deletions packages/server/__tests__/service-task.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ServiceSpec } from '../src/service-plist.ts';
import { netstatListeners } from '../src/service.ts';
import { editTask, parseTask, renderTask, renderTaskXml, windowsArgument } from '../src/service-task.ts';

const SPEC: ServiceSpec = {
label: 'dev.stim.server',
host: null,
node: 'C:\\Program Files\\nodejs\\node.exe',
script: 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@stim-cli\\server\\dist\\stim-server.mjs',
port: 7787,
env: ['JAVA_HOME=C:\\Program Files\\Eclipse Adoptium\\jdk-17', 'TOKEN=a"b'],
pathPrepend: ['C:\\Program Files\\nodejs'],
environment: { STIM_HOME: 'C:\\Users\\me\\.stim' },
logPath: 'C:\\Users\\me\\AppData\\Local\\Stim\\Logs\\dev.stim.server.log',
workingDirectory: 'C:\\Users\\me',
serve: { port: 7443, created: true },
};

describe('Windows service file', () => {
it('reads back what it wrote, with the env values and the serve record', () => {
expect(parseTask(renderTask(SPEC))).toEqual({
label: 'dev.stim.server',
host: null,
node: SPEC.node,
script: SPEC.script,
port: 7787,
env: SPEC.env,
pathPrepend: SPEC.pathPrepend,
stimHome: 'C:\\Users\\me\\.stim',
logPath: SPEC.logPath,
managed: true,
serve: { port: 7443, created: true },
previousScript: null,
programArguments: expect.arrayContaining([SPEC.node, SPEC.script, '--port', '7787']),
});
});

it('switches the server script and records the previous one, or replaces only the serve record', () => {
const installed = parseTask(renderTask(SPEC));
const switched = parseTask(
editTask(renderTask(SPEC), {
programArguments: [SPEC.node, 'C:\\new\\stim-server.mjs', ...(installed?.programArguments.slice(2) ?? [])],
previousScript: SPEC.script,
}),
);
expect(switched).toMatchObject({ script: 'C:\\new\\stim-server.mjs', previousScript: SPEC.script, port: 7787 });
expect(switched?.env).toEqual(SPEC.env);
expect(parseTask(editTask(renderTask(SPEC), { serve: { port: 8443, created: false } }))).toMatchObject({
serve: { port: 8443, created: false },
script: SPEC.script,
});
});

it('is not a service install wrote unless it says so', () => {
expect(parseTask(renderTask(SPEC).replace('"managed": true', '"managed": false'))?.managed).toBe(false);
expect(parseTask('not json')).toBeNull();
});
});

describe('Windows scheduled task', () => {
it('quotes words the way the C runtime splits them', () => {
expect(windowsArgument('C:\\Program Files\\x.mjs')).toBe('"C:\\Program Files\\x.mjs"');
expect(windowsArgument('a"b')).toBe('"a\\"b"');
expect(windowsArgument('C:\\dir\\')).toBe('"C:\\dir\\\\"');
});

it('runs the supervisor at the user logon without elevation, with XML-escaped paths', () => {
const xml = renderTaskXml({
label: 'dev.stim.server',
user: 'S-1-5-21-1-2-3-1001',
node: 'C:\\R&D\\node.exe',
runner: 'C:\\Users\\me\\AppData\\Local\\Stim\\services\\dev.stim.server\\stim-service-runner.mjs',
file: 'C:\\Users\\me\\AppData\\Local\\Stim\\services\\dev.stim.server.json',
state: 'C:\\Users\\me\\AppData\\Local\\Stim\\services\\dev.stim.server\\supervisor.json',
workingDirectory: 'C:\\Users\\me',
});
expect(xml).toContain('<LogonTrigger><Enabled>true</Enabled><UserId>S-1-5-21-1-2-3-1001</UserId></LogonTrigger>');
expect(xml).toContain('<LogonType>S4U</LogonType><RunLevel>LeastPrivilege</RunLevel>');
expect(xml).toContain('<Priority>4</Priority>');
expect(xml).toContain('<Command>C:\\R&#38;D\\node.exe</Command>');
expect(xml).toMatch(
/<Arguments>&#34;C:\\Users.*stim-service-runner\.mjs&#34; .*supervisor\.json&#34; &#34;30&#34;<\/Arguments>/,
);
});
});

test('netstat listeners are the pids listening on exactly that port, whatever the locale calls the state', () => {
const output = [
'',
'Active Connections',
'',
' Proto Local Address Foreign Address State PID',
' TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1012',
' TCP 127.0.0.1:7787 0.0.0.0:0 LISTENING 4242',
' TCP 127.0.0.1:7787 127.0.0.1:50000 ESTABLISHED 4242',
' TCP 100.100.38.114:7787 0.0.0.0:0 LISTENING 4242',
' TCP 127.0.0.1:17787 0.0.0.0:0 LISTENING 99',
' TCP [::]:7787 [::]:0 ABH\u00d6REN 4243',
' UDP 0.0.0.0:7787 *:* 77',
].join('\r\n');
expect(netstatListeners(output, 7787)).toEqual(['4242', '4243']);
});
100 changes: 53 additions & 47 deletions packages/server/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,62 +474,68 @@ test('a new worker installs the exact release and records route ownership only a
expect(JSON.parse(f.stdout[0]!).route.port).toBe(7449);
});

test('on Linux, setup approves builds under a systemd user service without Stim Host or Apple tools', async () => {
const f = fixture({
platform: 'linux',
installed: async () => ({
label: 'dev.stim.server',
host: null,
node: '/node',
script: '/server.mjs',
port: 7787,
env: [],
pathPrepend: [],
stimHome: home,
logPath: '/log',
managed: true,
serve: { port: 7447, created: false },
previousScript: null,
programArguments: [],
}),
installJob: async (_opts, setup) => {
expect(setup?.host).toBeNull();
f.actions.push('service');
return [];
},
toolchain: async () => ({
stimBuild: 'digest',
arch: 'x64',
xcode: null,
simulatorSdk: null,
macosSdk: null,
cocoapods: null,
runtimes: [],
jdk: '17',
androidSdk: { ndk: ['27'], buildTools: ['36'], platforms: ['36'] },
}),
});
expect(await runSetup([...args, '--yes', '--json'], '1.16.0', f.deps)).toBe(0);
expect(f.actions).toEqual(['service']);
expect(f.grants).toEqual([{ id: 'build-id', capabilities: ['build'] }]);
const output = JSON.parse(f.stdout.join(''));
expect(
output.tools.filter((t: { state: string }) => t.state !== 'present').map((t: { tool: string }) => t.tool),
).toEqual(['Xcode', 'iOS runtime', 'CocoaPods']);
expect(readSetupJournal(options.ticketHash)?.steps.some((s) => s.id === 'host')).toBe(false);
});
test.each(['linux', 'win32'] as const)(
'on %s, setup approves builds under its user service without Stim Host or Apple tools',
async (platform) => {
const f = fixture({
platform,
installed: async () => ({
label: 'dev.stim.server',
host: null,
node: '/node',
script: '/server.mjs',
port: 7787,
env: [],
pathPrepend: [],
stimHome: home,
logPath: '/log',
managed: true,
serve: { port: 7447, created: false },
previousScript: null,
programArguments: [],
}),
installJob: async (_opts, setup) => {
expect(setup?.host).toBeNull();
f.actions.push('service');
return [];
},
toolchain: async () => ({
stimBuild: 'digest',
arch: 'x64',
xcode: null,
simulatorSdk: null,
macosSdk: null,
cocoapods: null,
runtimes: [],
jdk: '17',
androidSdk: { ndk: ['27'], buildTools: ['36'], platforms: ['36'] },
}),
});
expect(await runSetup([...args, '--yes', '--json'], '1.16.0', f.deps)).toBe(0);
expect(f.actions).toEqual(['service']);
expect(f.grants).toEqual([{ id: 'build-id', capabilities: ['build'] }]);
const output = JSON.parse(f.stdout.join(''));
expect(
output.tools.filter((t: { state: string }) => t.state !== 'present').map((t: { tool: string }) => t.tool),
).toEqual(['Xcode', 'iOS runtime', 'CocoaPods']);
expect(readSetupJournal(options.ticketHash)?.steps.some((s) => s.id === 'host')).toBe(false);
},
);

test('on Linux, setup refuses device hosting before preflight', async () => {
test.each([
['linux', 'On Linux'],
['win32', 'On Windows'],
] as const)('on %s, setup refuses device hosting before preflight', async (platform, where) => {
const f = fixture({
platform: 'linux',
platform,
preflight: async () => {
throw new Error('Preflight must not run');
},
});
expect(await runSetup([...args, '--device-host', '--json'], '1.16.0', f.deps)).toBe(1);
expect(f.actions).toEqual([]);
expect(f.grants).toEqual([]);
expect(JSON.parse(f.stdout.join('')).warnings.join('\n')).toContain('Hosting simulators needs a Mac');
expect(JSON.parse(f.stdout.join('')).warnings.join('\n')).toContain(`Hosting simulators needs a Mac. ${where}, run`);
});

test('a newer managed server stays installed and a different Stim build produces partial success', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/bin/stim-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const USAGE = `Usage:
stim-server service install [--port <n>] [--label <name>] [--serve]
[--env KEY=VALUE]... [--path-prepend <dir>]...
run stim-server as a macOS LaunchAgent that starts at login,
or a Linux systemd user service;
a Linux systemd user service, or a Windows scheduled task;
--serve adds a tailnet-only \`tailscale serve\` route
stim-server service status [--label <name>] [--json]
stim-server service update [--label <name>] --release <version>|--from <dir>
Expand Down
Loading
Loading