starmux 0.14.0 → 0.15.0
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 +27 -0
- package/README.md +2 -0
- package/dist/src/cli/instances.js +22 -13
- package/dist/src/cli/starmux-status.js +21 -10
- package/dist/src/cli/starmux.js +34 -12
- package/dist/src/config-schemas.js +1 -1
- package/dist/src/global-core-control.js +106 -53
- package/dist/src/instance-control-protocol.js +27 -0
- package/dist/src/instance-runtime.js +212 -73
- package/dist/src/platform.js +3 -0
- package/package.json +3 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Instance lifecycle
|
|
6
|
+
|
|
7
|
+
- Version and validate the local instance control protocol independently of package releases.
|
|
8
|
+
- Report `unknown` when control records or channels cannot establish the instance's state; do not mistake a missing control channel for a stopped service.
|
|
9
|
+
- Confirm cleanup before `down` reports success. Repeated stops report `Already stopped`; failed or timed-out cleanup returns a nonzero exit code.
|
|
10
|
+
- Use the same stop confirmation for instance archival and protect successor runs during concurrent operations.
|
|
11
|
+
- Separate confirmed active configuration, saved settings and historical snapshots in CLI and JSON output.
|
|
12
|
+
- Prevent `up` from changing permissions in an unknown state, prevent `doctor` from passing unresolved runtime errors, and show unknown states in the macOS App.
|
|
13
|
+
- Add isolated lifecycle and real CLI regression tests, validated on macOS and NVIDIA NX Linux ARM64.
|
|
14
|
+
|
|
15
|
+
### Upgrade
|
|
16
|
+
|
|
17
|
+
Stop each running instance with the currently installed CLI before upgrading, then start it again with the new CLI. This release introduces a versioned instance control contract and does not migrate old runtime directories or support the previous unversioned contract. Replacing installed files does not update an already-running supervisor.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
starmux ls
|
|
21
|
+
starmux down # Default instance
|
|
22
|
+
starmux down -i NAME # Repeat for other running instances
|
|
23
|
+
npm install -g starmux@0.15.0
|
|
24
|
+
starmux up
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Proceed with installation only after stopping succeeds. If the current CLI cannot contact its supervisor, resolve that runtime problem first; a port conflict is not proof of successful shutdown.
|
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ Requires **Node.js 22.12+**.
|
|
|
39
39
|
npm install -g starmux
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
**Upgrading an existing installation?** Stop each running instance with the current CLI (`starmux down`, or `starmux down -i NAME`) before installing the new version, then run `starmux up` again. See the [upgrade instructions](https://github.com/cookyecat/starmux/blob/main/docs/INSTALL.md#upgrade) and [release notes](https://github.com/cookyecat/starmux/blob/main/CHANGELOG.md).
|
|
43
|
+
|
|
42
44
|
### 2. Start in your project directory
|
|
43
45
|
|
|
44
46
|
```bash
|
|
@@ -6,7 +6,7 @@ import { createAccessControl } from '../access-control.js';
|
|
|
6
6
|
import { createWorkspaceGrants } from '../workspace-grants.js';
|
|
7
7
|
import { createDenyRules } from '../explicit-deny-rules.js';
|
|
8
8
|
import { createPrivateDirectory } from '../platform.js';
|
|
9
|
-
import { instanceStatus, instanceRequest } from '../instance-runtime.js';
|
|
9
|
+
import { instanceStatus, instanceRequest, stopInstance } from '../instance-runtime.js';
|
|
10
10
|
import { atomicPrivate, changeRegistry, configJSONSchema, sourceRevision, configTemplate, validateConfig, withLock, createInstance, exists, fingerprint, instancePaths, loadInstanceConfig, parseConfig, patchInstanceConfig, readRegistry, readTrusted, resolveInstance, saveInstanceConfig, suggestPort } from '../instance-store.js';
|
|
11
11
|
const booleans = new Set(['--json', '--all', '--link', '--archived', '--approve-trusted-developer', '--approve-unrestricted', '--effective', '--allow-loopback-http']);
|
|
12
12
|
function options(args) {
|
|
@@ -65,16 +65,17 @@ export async function handle(command, args) {
|
|
|
65
65
|
: 'No instances. Run: starmux up\n');
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
-
const rows =
|
|
69
|
-
const
|
|
70
|
-
const
|
|
68
|
+
const rows = instances.map(s => {
|
|
69
|
+
const cfg = s.phase === 'stopped' ? s.saved?.config : s.active?.config;
|
|
70
|
+
const saved = s.saved?.config;
|
|
71
71
|
const mark = s.instanceId === registry.defaultInstanceId ? '*' : ' ';
|
|
72
72
|
const state = s.archived ? 'archived' : s.configState === 'pending' ? `${s.phase} (pending)` : s.phase;
|
|
73
|
-
const
|
|
73
|
+
const describe = (value) => !value ? 'unavailable' : value.accessRole === 'trusted' ? 'coding' : value.mode;
|
|
74
|
+
const mode = s.phase === 'stopped' ? `${describe(saved)} (saved)` : s.phase === 'unknown' ? `unavailable / ${describe(saved)} (saved)` : s.configState === 'pending' ? `${describe(cfg)} / ${describe(saved)} (saved)` : describe(cfg);
|
|
74
75
|
const port = String(s.port ?? '—');
|
|
75
|
-
const workspace = cfg?.projectRoot ?? s.source.path;
|
|
76
|
+
const workspace = cfg?.projectRoot ?? (saved ? `${saved.projectRoot} (saved)` : s.source.path);
|
|
76
77
|
return { mark, name: s.name, state, mode, port, workspace };
|
|
77
|
-
})
|
|
78
|
+
});
|
|
78
79
|
const wName = Math.max(4, ...rows.map(r => r.name.length));
|
|
79
80
|
const wState = Math.max(6, ...rows.map(r => r.state.length));
|
|
80
81
|
const wMode = Math.max(4, ...rows.map(r => r.mode.length));
|
|
@@ -121,9 +122,9 @@ export async function handle(command, args) {
|
|
|
121
122
|
else if (op === 'default')
|
|
122
123
|
registry.defaultInstanceId = item.id;
|
|
123
124
|
else if (op === 'archive' || op === 'remove') {
|
|
124
|
-
const stopped = await
|
|
125
|
-
if (stopped
|
|
126
|
-
throw Error('
|
|
125
|
+
const stopped = await stopInstance(instance);
|
|
126
|
+
if (stopped.restarted)
|
|
127
|
+
throw Error('INSTANCE_RESTARTED: cannot archive a new running instance');
|
|
127
128
|
item.archived = true;
|
|
128
129
|
if (registry.defaultInstanceId === item.id)
|
|
129
130
|
registry.defaultInstanceId = null;
|
|
@@ -163,11 +164,12 @@ export async function handle(command, args) {
|
|
|
163
164
|
}
|
|
164
165
|
if (op === 'show') {
|
|
165
166
|
if (flags.has('--effective')) {
|
|
166
|
-
const
|
|
167
|
+
const status = await instanceStatus(instance);
|
|
168
|
+
const active = status.active;
|
|
167
169
|
if (json)
|
|
168
170
|
output(active);
|
|
169
171
|
else if (!active)
|
|
170
|
-
process.stdout.write(`Instance ${instance.name} has no active
|
|
172
|
+
process.stdout.write(`Instance ${instance.name} has no confirmed active configuration (state: ${status.phase}).\n`);
|
|
171
173
|
else
|
|
172
174
|
process.stdout.write(`Instance ${instance.name} (effective running config)\n` +
|
|
173
175
|
`Workspace ${active.config.projectRoot}\n` +
|
|
@@ -205,7 +207,7 @@ export async function handle(command, args) {
|
|
|
205
207
|
return;
|
|
206
208
|
}
|
|
207
209
|
if (op === 'recover') {
|
|
208
|
-
const snapshot = (await instanceStatus(instance)).
|
|
210
|
+
const snapshot = (await instanceStatus(instance)).lastActive;
|
|
209
211
|
if (!snapshot)
|
|
210
212
|
throw Error('NO_SUCCESSFUL_CONFIG_SNAPSHOT');
|
|
211
213
|
await validateConfig(snapshot.config);
|
|
@@ -291,7 +293,14 @@ export async function handle(command, args) {
|
|
|
291
293
|
return;
|
|
292
294
|
}
|
|
293
295
|
if (op === 'apply') {
|
|
296
|
+
const before = await instanceStatus(instance);
|
|
297
|
+
if (before.phase !== 'running' && before.phase !== 'stopped')
|
|
298
|
+
throw Error(`INSTANCE_NOT_READY: ${before.runtimeProblem ?? before.phase}`);
|
|
294
299
|
const state = await instanceRequest(instance, 'apply', flags.get('--revision') ?? loaded.revision);
|
|
300
|
+
if (state && state.phase !== 'running')
|
|
301
|
+
throw Error('INSTANCE_APPLY_UNVERIFIED');
|
|
302
|
+
if (!state && before.phase === 'running')
|
|
303
|
+
throw Error('INSTANCE_CHANGED_DURING_APPLY');
|
|
295
304
|
if (json)
|
|
296
305
|
output({ result: state ? 'APPLIED' : 'VALID_ON_NEXT_START', instanceId: instance.id, runtime: state });
|
|
297
306
|
else
|
|
@@ -23,9 +23,8 @@ async function authentication(instance, status) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
export async function formatStarmuxStatus(instance, status, config, options = {}) {
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
config = status.active?.config;
|
|
26
|
+
// Only the live supervisor can confirm active permissions.
|
|
27
|
+
config = status.phase === 'stopped' ? status.saved?.config : status.active?.config;
|
|
29
28
|
const auth = await authentication(instance, status);
|
|
30
29
|
// Omit `-i default` for the default instance to keep everyday single-instance output clean.
|
|
31
30
|
const omitSelector = options.isDefault === true && instance.name === 'default';
|
|
@@ -40,23 +39,35 @@ export async function formatStarmuxStatus(instance, status, config, options = {}
|
|
|
40
39
|
'api-key': `API key (Bearer token; ${auth.activeKeys} active ${auth.activeKeys === 1 ? 'key' : 'keys'})`,
|
|
41
40
|
oauth: 'OAuth (browser sign-in)'
|
|
42
41
|
};
|
|
43
|
-
const saved = status.phase === '
|
|
42
|
+
const saved = status.phase === 'stopped' ? ' (saved for next start)' : ' (saved; live state unavailable)';
|
|
44
43
|
const row = (label, value) => ' ' + label.padEnd(16) + value;
|
|
45
44
|
const codingCmd = command('up --code');
|
|
46
45
|
const globalMode = status.phase === 'running' ? status.runtime?.permissionMode === 'global' : !!config && usesGlobalPermissions(config);
|
|
47
|
-
const
|
|
46
|
+
const describeMode = (value, global) => !value
|
|
48
47
|
? 'Unavailable'
|
|
49
|
-
:
|
|
50
|
-
?
|
|
51
|
-
:
|
|
48
|
+
: value.accessRole === 'trusted'
|
|
49
|
+
? value.writeScope === 'home-only' ? 'Coding (Home sandbox · edits, shell & git)' : 'Coding (OS account · edits, shell & git)'
|
|
50
|
+
: value.mode === 'read-write'
|
|
52
51
|
? 'File writes (restricted)'
|
|
53
|
-
: `Read-only (${
|
|
52
|
+
: `Read-only (${global ? 'global · OS account' : 'project scope'})`;
|
|
53
|
+
const switchHint = config && (status.phase === 'running' || status.phase === 'stopped')
|
|
54
|
+
? config.accessRole === 'trusted' ? ` (run '${command('up --read')}' to return to read-only)` : ` (run '${codingCmd}' to enable edits & shell)`
|
|
55
|
+
: '';
|
|
56
|
+
const mode = describeMode(config, globalMode) + switchHint;
|
|
54
57
|
const title = `${indicator} Starmux is ${status.phase} (instance: ${instance.name})`;
|
|
55
58
|
const lines = ['', title, row('Instance', instance.id)];
|
|
56
59
|
if (ready && status.port) {
|
|
57
60
|
lines.push(row('Listen', `http://127.0.0.1:${status.port}/mcp (this computer only)`));
|
|
58
61
|
}
|
|
59
|
-
lines.push(row('Project', config?.projectRoot ?? 'Unavailable'), row('Mode', mode), row('Auth', auth.mode ? labels[auth.mode] + (ready ? '' : saved) : 'Unavailable'));
|
|
62
|
+
lines.push(row(status.phase === 'stopped' ? 'Saved project' : 'Project', config?.projectRoot ?? 'Unavailable'), row(status.phase === 'stopped' ? 'Saved mode' : status.phase === 'unknown' || status.configState === 'pending' ? 'Active mode' : 'Mode', mode), row('Auth', auth.mode ? labels[auth.mode] + (ready ? '' : saved) : 'Unavailable'));
|
|
63
|
+
if (status.phase !== 'stopped' && (status.configState === 'pending' || !status.active)) {
|
|
64
|
+
const savedConfig = status.saved?.config;
|
|
65
|
+
lines.push(row('Saved mode', describeMode(savedConfig, !!savedConfig && usesGlobalPermissions(savedConfig)) + (status.configState === 'pending' ? ' (pending)' : '')));
|
|
66
|
+
if (!status.active)
|
|
67
|
+
lines.push(row('Saved project', savedConfig?.projectRoot ?? 'Unavailable'));
|
|
68
|
+
}
|
|
69
|
+
if (status.runtime)
|
|
70
|
+
lines.push(row('Runtime', status.runtime.buildVersion));
|
|
60
71
|
if (ready && status.port && auth.mode === 'oauth' && auth.oauth) {
|
|
61
72
|
lines.push(row('Client URL', auth.oauth.resource), row('Issuer', auth.oauth.issuer));
|
|
62
73
|
}
|
package/dist/src/cli/starmux.js
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
9
9
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
10
10
|
import { createAccessControl } from '../access-control.js';
|
|
11
|
-
import { instanceStatus, instanceRequest, runInstance } from '../instance-runtime.js';
|
|
11
|
+
import { instanceStatus, instanceRequest, stopInstance, runInstance } from '../instance-runtime.js';
|
|
12
12
|
import { atomicPrivate, availablePort, changeRegistry, configJSONSchema, createInstance, ensureAccessReady, exists, instanceConfigSchema, instancePaths, loadInstanceConfig, parseConfig, patchInstanceConfig, readRegistry, readTrusted, resolveInstance, saveInstanceConfig, suggestPort, validateConfig, usesGlobalPermissions } from '../instance-store.js';
|
|
13
13
|
import { handle } from './instances.js';
|
|
14
14
|
import { packageRoot, desktopCommanderEntry } from '../runtime-paths.js';
|
|
@@ -125,6 +125,11 @@ async function configure() {
|
|
|
125
125
|
instance = await resolveInstance();
|
|
126
126
|
if (instance?.archived)
|
|
127
127
|
fail('Instance is archived. Use starmux config restore -i ' + instance.name);
|
|
128
|
+
if (command === 'up' && instance) {
|
|
129
|
+
const state = await instanceStatus(instance);
|
|
130
|
+
if (state.phase !== 'running' && state.phase !== 'stopped')
|
|
131
|
+
fail(`Cannot start ${instance.name}: ${state.runtimeProblem ?? state.phase}. Run starmux doctor -i ${instance.id}.`);
|
|
132
|
+
}
|
|
128
133
|
const loaded = instance ? await loadInstanceConfig(instance) : undefined;
|
|
129
134
|
const writing = flags.write || flags.code;
|
|
130
135
|
if (flags.read && writing)
|
|
@@ -160,21 +165,29 @@ async function show(instance) {
|
|
|
160
165
|
print(status);
|
|
161
166
|
return;
|
|
162
167
|
}
|
|
163
|
-
const config = status.phase === '
|
|
168
|
+
const config = status.phase === 'stopped' ? status.saved?.config : status.active?.config;
|
|
164
169
|
process.stdout.write(await formatStarmuxStatus(instance, status, config, { isDefault: await isDefaultInstance(instance) }));
|
|
165
170
|
}
|
|
166
171
|
async function start(instance) {
|
|
167
172
|
const state = await instanceStatus(instance);
|
|
168
173
|
if (state.phase === 'running') {
|
|
169
|
-
if (state.configState === 'pending')
|
|
170
|
-
await instanceRequest(instance, 'apply', (await loadInstanceConfig(instance)).revision);
|
|
174
|
+
if (state.configState === 'pending') {
|
|
175
|
+
const applied = await instanceRequest(instance, 'apply', (await loadInstanceConfig(instance)).revision);
|
|
176
|
+
if (!applied || applied.phase !== 'running')
|
|
177
|
+
fail('Configuration was not applied: instance changed while starting. Run starmux status.');
|
|
178
|
+
}
|
|
179
|
+
const current = await instanceStatus(instance);
|
|
180
|
+
if (current.phase !== 'running' || current.health !== 'ok')
|
|
181
|
+
fail(`Instance is not ready: ${current.runtimeProblem ?? current.health}. Run starmux doctor -i ${instance.id}.`);
|
|
171
182
|
await show(instance);
|
|
172
183
|
return;
|
|
173
184
|
}
|
|
185
|
+
if (state.phase !== 'stopped')
|
|
186
|
+
fail(`Cannot start ${instance.name}: ${state.runtimeProblem ?? state.phase}. Run starmux doctor -i ${instance.id}.`);
|
|
174
187
|
await ensureAccessReady(instance);
|
|
175
188
|
const config = (await loadInstanceConfig(instance)).config;
|
|
176
189
|
if (!await availablePort(config.port))
|
|
177
|
-
fail(`Port ${config.port} is in use.
|
|
190
|
+
fail(`Port ${config.port} is in use. Inspect starmux doctor -i ${instance.id} before changing the port.`);
|
|
178
191
|
if (flags.foreground || string('app-parent-pid')) {
|
|
179
192
|
await runInstance(instance, { json, ...(!json ? { onReady: () => show(instance) } : {}), ...(string('app-parent-pid') ? { appParentPid: Number(string('app-parent-pid')) } : {}) });
|
|
180
193
|
return;
|
|
@@ -645,11 +658,13 @@ async function main() {
|
|
|
645
658
|
if (command === 'status')
|
|
646
659
|
await show(instance);
|
|
647
660
|
else if (command === 'down' || command === 'stop') {
|
|
648
|
-
await
|
|
661
|
+
const result = await stopInstance(instance);
|
|
649
662
|
if (json)
|
|
650
|
-
print(
|
|
663
|
+
print(result);
|
|
664
|
+
else if (result.restarted)
|
|
665
|
+
process.stdout.write(`Stopped the previous run of ${instance.name}; another command has started a new run.\n`);
|
|
651
666
|
else
|
|
652
|
-
process.stdout.write('
|
|
667
|
+
process.stdout.write(`${result.result === 'ALREADY_STOPPED' ? 'Already stopped' : 'Stopped'} ${instance.name}\n`);
|
|
653
668
|
}
|
|
654
669
|
else if (command === 'auth')
|
|
655
670
|
await auth(instance);
|
|
@@ -662,23 +677,30 @@ async function main() {
|
|
|
662
677
|
else if (command === 'doctor') {
|
|
663
678
|
if (Number(process.versions.node.split('.')[0]) < 22 || (process.versions.node.startsWith('22.') && Number(process.versions.node.split('.')[1]) < 12))
|
|
664
679
|
fail('Node 22.12 or newer is required.');
|
|
680
|
+
const state = await instanceStatus(instance);
|
|
681
|
+
if (state.configProblem || state.runtimeProblem || state.runtime?.problem)
|
|
682
|
+
fail(state.configProblem ?? state.runtimeProblem ?? state.runtime.problem);
|
|
683
|
+
if (state.phase !== 'running' && state.phase !== 'stopped')
|
|
684
|
+
fail('Instance state is ' + state.phase + '. Inspect starmux logs -i ' + instance.id);
|
|
685
|
+
if (state.phase === 'running' && state.health !== 'ok')
|
|
686
|
+
fail('Instance health is ' + state.health + '. Inspect starmux logs -i ' + instance.id);
|
|
665
687
|
await stat(desktopCommanderEntry);
|
|
666
688
|
const loaded = await loadInstanceConfig(instance);
|
|
667
689
|
await ensureAccessReady(instance);
|
|
668
|
-
|
|
669
|
-
if (state.phase !== 'running' && !await availablePort(loaded.config.port))
|
|
690
|
+
if (state.phase === 'stopped' && !await availablePort(loaded.config.port))
|
|
670
691
|
fail('Configured port is in use: ' + loaded.config.port);
|
|
671
692
|
if (json)
|
|
672
693
|
print({ result: 'PASS', node: process.version, instanceId: instance.id, configState: state.configState });
|
|
673
694
|
else {
|
|
674
695
|
const isDef = await isDefaultInstance(instance);
|
|
675
696
|
const access = await (await createAccessControl(instancePaths(instance).access, { initialize: false })).snapshot();
|
|
676
|
-
const
|
|
697
|
+
const effective = state.phase === 'running' ? state.active?.config : loaded.config;
|
|
698
|
+
const modeLabel = !effective ? 'Mode unavailable' : effective.accessRole === 'trusted' ? 'Coding mode' : effective.mode === 'read-write' ? 'File writes' : 'Read-only mode';
|
|
677
699
|
const authLabel = access.mode === 'ingress' ? 'None (no token required)' : access.mode === 'api-key' ? `API key (${access.credentials.filter(c => c.status === 'active').length} active)` : 'OAuth';
|
|
678
700
|
const portState = state.phase === 'running' ? `Running on 127.0.0.1:${state.port}` : `Stopped · port ${loaded.config.port} available`;
|
|
679
701
|
process.stdout.write(`✓ Runtime Node ${process.version} & Desktop Commander engine ready\n` +
|
|
680
702
|
`✓ Service ${portState}${isDef ? '' : ` (instance: ${instance.name})`}\n` +
|
|
681
|
-
`✓ Workspace ${
|
|
703
|
+
`✓ Workspace ${effective?.projectRoot ?? 'Unavailable'} (${modeLabel})\n` +
|
|
682
704
|
`✓ Configuration ${instance.source.path} (${state.configState})\n` +
|
|
683
705
|
`✓ Authentication ${authLabel}\n` +
|
|
684
706
|
`PASS · Node, dependencies, workspace, configuration, port and authentication.\n`);
|
|
@@ -2,4 +2,4 @@ import { z } from 'zod';
|
|
|
2
2
|
export const grantsSchema = z.object({ version: z.literal(1), homeRead: z.boolean(), outsideRead: z.boolean(), custom: z.array(z.object({ path: z.string(), mode: z.enum(['read-only', 'read-write']) }).strict()) }).strict();
|
|
3
3
|
export const denySchema = z.object({ version: z.literal(1), deniedTools: z.array(z.string()), deniedDirectories: z.array(z.string()) }).strict();
|
|
4
4
|
export const upstreamDefinitionSchema = z.object({ id: z.string(), transport: z.literal('stdio'), command: z.string(), args: z.array(z.string()), env: z.record(z.string()).optional(), allowTools: z.array(z.string()) }).strict();
|
|
5
|
-
export const ownerSchema = z.object({ port: z.number().int(), pid: z.number().int().min(2), token: z.string().regex(/^[a-f0-9]{48}$/), launcher: z.enum(['cli', 'app']), runId: z.string().regex(/^[a-f0-9]{32}$/) }).passthrough();
|
|
5
|
+
export const ownerSchema = z.object({ version: z.literal(1), port: z.number().int(), pid: z.number().int().min(2), token: z.string().regex(/^[a-f0-9]{48}$/), launcher: z.enum(['cli', 'app']), runId: z.string().regex(/^[a-f0-9]{32}$/) }).passthrough();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ownedByUser, createPrivateDirectory, controlRoot, windows } from "./platform.js";
|
|
2
2
|
import { ownerSchema } from "./config-schemas.js";
|
|
3
3
|
import { GatewayError, errorCode, isSafeInteger, parseJSON, record } from "./validation.js";
|
|
4
|
+
const CONTROL_VERSION = 1;
|
|
4
5
|
// One Core / two equal frontends. Owner-only local supervisor control.
|
|
5
6
|
// The socket is never an MCP/public endpoint, and never signals a discovered PID.
|
|
6
7
|
import crypto from "node:crypto";
|
|
@@ -39,7 +40,10 @@ function readOwner(paths) {
|
|
|
39
40
|
const d = fs.readFileSync(paths.meta);
|
|
40
41
|
if (d.length > 1024)
|
|
41
42
|
deny("CORE_CONTROL_METADATA_INVALID");
|
|
42
|
-
const
|
|
43
|
+
const metadata = record(parseJSON(d.toString("utf8")), "CORE_CONTROL_METADATA_INVALID");
|
|
44
|
+
if (metadata.version !== CONTROL_VERSION)
|
|
45
|
+
deny("CORE_CONTROL_PROTOCOL_UNSUPPORTED");
|
|
46
|
+
const checked = ownerSchema.safeParse(metadata);
|
|
43
47
|
if (!checked.success)
|
|
44
48
|
deny("CORE_CONTROL_METADATA_INVALID");
|
|
45
49
|
const owner = checked.data;
|
|
@@ -71,7 +75,7 @@ async function exchange(paths, owner, method, timeoutMs = 11500) {
|
|
|
71
75
|
resolve(result);
|
|
72
76
|
};
|
|
73
77
|
client.once("connect", () => client.write(JSON.stringify({
|
|
74
|
-
version:
|
|
78
|
+
version: CONTROL_VERSION, ...(windows ? { nonce, proof: proof(owner.token, nonce + ":" + owner.runId + ":" + method) } : { token: owner.token }), runId: owner.runId, method
|
|
75
79
|
}) + "\n"));
|
|
76
80
|
client.on("data", d => {
|
|
77
81
|
body += d.toString("utf8");
|
|
@@ -83,8 +87,10 @@ async function exchange(paths, owner, method, timeoutMs = 11500) {
|
|
|
83
87
|
const reply = record(parseJSON(body.split("\n")[0]));
|
|
84
88
|
if (windows && reply.proof !== proof(owner.token, nonce + ":" + String(reply.result)))
|
|
85
89
|
throw Error("CORE_CONTROL_REPLY_UNAUTHENTICATED");
|
|
86
|
-
if (reply.
|
|
87
|
-
"
|
|
90
|
+
if (reply.version !== CONTROL_VERSION || reply.result === "PROTOCOL_UNSUPPORTED")
|
|
91
|
+
throw Error("CORE_CONTROL_PROTOCOL_UNSUPPORTED");
|
|
92
|
+
if (reply.runId !== owner.runId || !["RUNNING", "STARTING", "STOPPING", "STOPPED",
|
|
93
|
+
"STOP_ACCEPTED", "STOP_FAILED"].includes(String(reply.result)))
|
|
88
94
|
throw Error("CORE_CONTROL_REPLY_INVALID");
|
|
89
95
|
finish(null, { result: String(reply.result), runId: owner.runId, launcher: owner.launcher });
|
|
90
96
|
}
|
|
@@ -153,35 +159,70 @@ function cleanStale(paths) {
|
|
|
153
159
|
catch { }
|
|
154
160
|
}
|
|
155
161
|
}
|
|
156
|
-
export
|
|
162
|
+
export function inspectControlOwner(port, { root } = {}) {
|
|
157
163
|
const paths = controlPaths(port, root);
|
|
158
|
-
let owner;
|
|
159
164
|
try {
|
|
160
|
-
|
|
165
|
+
return readOwner(paths);
|
|
161
166
|
}
|
|
162
167
|
catch (e) {
|
|
163
|
-
if (
|
|
168
|
+
if (errorCode(e) === "ENOENT" && !fs.existsSync(paths.lock))
|
|
164
169
|
return null;
|
|
165
170
|
throw e;
|
|
166
171
|
}
|
|
172
|
+
}
|
|
173
|
+
export async function connectOwner(port, { root } = {}) {
|
|
174
|
+
const paths = controlPaths(port, root);
|
|
175
|
+
const owner = inspectControlOwner(port, { root });
|
|
176
|
+
if (!owner || !alive(owner.pid))
|
|
177
|
+
return null;
|
|
167
178
|
const result = await exchange(paths, owner, "status", 1500);
|
|
168
179
|
return { owner, result };
|
|
169
180
|
}
|
|
170
|
-
export async function globalStop(port, { root } = {}) {
|
|
181
|
+
export async function globalStop(port, { root, timeoutMs = 65000 } = {}) {
|
|
182
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
|
|
183
|
+
deny("CORE_STOP_TIMEOUT_INVALID");
|
|
171
184
|
const paths = controlPaths(port, root);
|
|
172
185
|
let owner;
|
|
173
186
|
try {
|
|
174
187
|
owner = readOwner(paths);
|
|
175
188
|
}
|
|
176
189
|
catch (e) {
|
|
177
|
-
if (errorCode(e) === "ENOENT")
|
|
190
|
+
if (errorCode(e) === "ENOENT" && !fs.existsSync(paths.lock))
|
|
178
191
|
return { result: "NO_CONTROL_OWNER" };
|
|
179
192
|
throw e;
|
|
180
193
|
}
|
|
181
|
-
const
|
|
182
|
-
|
|
194
|
+
const deadline = Date.now() + timeoutMs;
|
|
195
|
+
const result = await exchange(paths, owner, "stop", Math.min(11500, timeoutMs));
|
|
196
|
+
if (result.result !== "STOP_ACCEPTED")
|
|
183
197
|
deny("CORE_STOP_UNVERIFIED");
|
|
184
|
-
|
|
198
|
+
// The acknowledgment only accepts the request. Completion belongs to this
|
|
199
|
+
// lease, not to whatever may bind the same port after it has been released.
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
try {
|
|
202
|
+
const current = readOwner(paths);
|
|
203
|
+
if (current.runId !== owner.runId)
|
|
204
|
+
return { result: "STOPPED", launcher: owner.launcher, ownerPid: owner.pid, restarted: true };
|
|
205
|
+
try {
|
|
206
|
+
const state = await exchange(paths, owner, "status", Math.min(250, Math.max(1, deadline - Date.now())));
|
|
207
|
+
if (state.result === "STOP_FAILED")
|
|
208
|
+
deny("CORE_STOP_FAILED");
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
// Closing the control listener is part of releasing the lease.
|
|
212
|
+
if (!["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(errorCode(e) ?? "") &&
|
|
213
|
+
!["CORE_CONTROL_EOF", "CORE_CONTROL_TIMEOUT"].includes(e instanceof Error ? e.message : ""))
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
if (errorCode(e) !== "ENOENT")
|
|
219
|
+
throw e;
|
|
220
|
+
if (!fs.existsSync(paths.lock))
|
|
221
|
+
return { result: "STOPPED", launcher: owner.launcher, ownerPid: owner.pid, restarted: false };
|
|
222
|
+
}
|
|
223
|
+
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
224
|
+
}
|
|
225
|
+
deny("CORE_STOP_TIMEOUT");
|
|
185
226
|
}
|
|
186
227
|
export async function acquireControl(port, { root, launcher = "cli" } = {}) {
|
|
187
228
|
if (!["cli", "app"].includes(launcher))
|
|
@@ -226,18 +267,21 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
|
|
|
226
267
|
deny("CORE_CONTROL_CLAIM_TIMEOUT");
|
|
227
268
|
}
|
|
228
269
|
secureDir(paths.lock);
|
|
229
|
-
const owner = { version:
|
|
270
|
+
const owner = { version: CONTROL_VERSION, port, pid: process.pid, launcher,
|
|
230
271
|
token: crypto.randomBytes(24).toString("hex"),
|
|
231
272
|
runId: crypto.randomBytes(16).toString("hex") };
|
|
232
273
|
fs.writeFileSync(paths.meta, JSON.stringify(owner), { mode: 0o600, flag: "wx" });
|
|
233
|
-
let onStop = null, phase = "STARTING";
|
|
274
|
+
let onStop = null, phase = "STARTING", stopAccepted = false;
|
|
234
275
|
const clients = new Set();
|
|
235
276
|
const server = net.createServer(socket => {
|
|
236
277
|
clients.add(socket);
|
|
237
278
|
socket.setTimeout(12000, () => socket.destroy());
|
|
279
|
+
socket.on("error", () => { });
|
|
238
280
|
socket.once("close", () => clients.delete(socket));
|
|
239
|
-
let raw = "";
|
|
240
|
-
socket.on("data",
|
|
281
|
+
let raw = "", handled = false;
|
|
282
|
+
socket.on("data", chunk => {
|
|
283
|
+
if (handled)
|
|
284
|
+
return;
|
|
241
285
|
raw += chunk.toString("utf8");
|
|
242
286
|
if (raw.length > 2048) {
|
|
243
287
|
socket.destroy();
|
|
@@ -246,7 +290,7 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
|
|
|
246
290
|
if (!raw.includes("\n"))
|
|
247
291
|
return;
|
|
248
292
|
const line = raw.split("\n")[0];
|
|
249
|
-
|
|
293
|
+
handled = true;
|
|
250
294
|
let request;
|
|
251
295
|
try {
|
|
252
296
|
request = record(parseJSON(line));
|
|
@@ -255,24 +299,30 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
|
|
|
255
299
|
socket.destroy();
|
|
256
300
|
return;
|
|
257
301
|
}
|
|
258
|
-
if (request.
|
|
259
|
-
(windows ? typeof request.nonce !== "string" || request.nonce.length !== 48 || request.proof !== proof(owner.token, request.nonce + ":" + owner.runId + ":" + String(request.method)) : request.token !== owner.token) ||
|
|
302
|
+
if ((windows ? typeof request.nonce !== "string" || request.nonce.length !== 48 || request.proof !== proof(owner.token, request.nonce + ":" + owner.runId + ":" + String(request.method)) : request.token !== owner.token) ||
|
|
260
303
|
request.runId !== owner.runId) {
|
|
261
304
|
socket.destroy();
|
|
262
305
|
return;
|
|
263
306
|
}
|
|
264
|
-
let result = "STOP_FAILED";
|
|
265
|
-
if (request.
|
|
307
|
+
let result = "STOP_FAILED", startStop = false;
|
|
308
|
+
if (request.version !== CONTROL_VERSION)
|
|
309
|
+
result = "PROTOCOL_UNSUPPORTED";
|
|
310
|
+
else if (request.method === "status")
|
|
266
311
|
result = phase;
|
|
267
312
|
else if (request.method === "stop" && onStop) {
|
|
268
|
-
|
|
269
|
-
result =
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
313
|
+
if (phase !== "STOP_FAILED") {
|
|
314
|
+
result = "STOP_ACCEPTED";
|
|
315
|
+
startStop = !stopAccepted;
|
|
316
|
+
stopAccepted = true;
|
|
317
|
+
phase = "STOPPING";
|
|
273
318
|
}
|
|
274
319
|
}
|
|
275
|
-
socket.end(JSON.stringify({ result, runId: owner.runId, launcher: owner.launcher, ...(windows ? { proof: proof(owner.token, String(request.nonce) + ":" + result) } : {}) }) + "\n")
|
|
320
|
+
socket.end(JSON.stringify({ version: CONTROL_VERSION, result, runId: owner.runId, launcher: owner.launcher, ...(windows ? { proof: proof(owner.token, String(request.nonce) + ":" + result) } : {}) }) + "\n", () => {
|
|
321
|
+
// Never await shutdown on its own control connection: release() waits
|
|
322
|
+
// for these sockets to close. Acceptance and completion are distinct.
|
|
323
|
+
if (startStop)
|
|
324
|
+
void Promise.resolve().then(() => onStop()).catch(() => { phase = "STOP_FAILED"; });
|
|
325
|
+
});
|
|
276
326
|
});
|
|
277
327
|
});
|
|
278
328
|
try {
|
|
@@ -301,36 +351,39 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
|
|
|
301
351
|
catch { }
|
|
302
352
|
throw e;
|
|
303
353
|
}
|
|
304
|
-
let
|
|
354
|
+
let releasePromise = null;
|
|
305
355
|
return {
|
|
306
356
|
owner,
|
|
307
357
|
markRunning() { phase = "RUNNING"; },
|
|
308
358
|
markStopped() { phase = "STOPPED"; },
|
|
309
359
|
onStop(fn) { onStop = fn; },
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
360
|
+
release() {
|
|
361
|
+
return releasePromise ??= (async () => {
|
|
362
|
+
// Response sockets close before the lease disappears.
|
|
363
|
+
await new Promise((resolve, reject) => {
|
|
364
|
+
const timer = setTimeout(() => { for (const s of clients)
|
|
365
|
+
s.destroy(); }, 1200);
|
|
366
|
+
timer.unref();
|
|
367
|
+
server.close(error => { clearTimeout(timer); if (error)
|
|
368
|
+
reject(error);
|
|
369
|
+
else
|
|
370
|
+
resolve(); });
|
|
371
|
+
});
|
|
372
|
+
if (readOwner(paths).runId !== owner.runId)
|
|
373
|
+
deny("CORE_CONTROL_LEASE_CHANGED");
|
|
374
|
+
// node:net automatically unlinks a Unix socket on server.close().
|
|
375
|
+
// A second unlink is not required and must not abort lease cleanup.
|
|
376
|
+
try {
|
|
377
|
+
if (!windows)
|
|
378
|
+
fs.unlinkSync(paths.socket);
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
if (errorCode(e) !== "ENOENT")
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
fs.unlinkSync(paths.meta);
|
|
385
|
+
fs.rmdirSync(paths.lock);
|
|
386
|
+
})();
|
|
334
387
|
}
|
|
335
388
|
};
|
|
336
389
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { instanceConfigSchema } from './instance-store.js';
|
|
3
|
+
// Version the local control contract independently of npm releases. Unknown
|
|
4
|
+
// versions fail explicitly; additional descriptive fields are safe to ignore.
|
|
5
|
+
export const INSTANCE_CONTROL_VERSION = 1;
|
|
6
|
+
const runId = z.string().regex(/^[a-f0-9]{32}$/);
|
|
7
|
+
export const instanceOwnerSchema = z.object({
|
|
8
|
+
protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), instanceId: z.string().uuid(), runId,
|
|
9
|
+
pid: z.number().int().min(2), port: z.number().int().min(1024).max(65535),
|
|
10
|
+
token: z.string().regex(/^[a-f0-9]{64}$/), launcher: z.enum(['cli', 'app']), appParentPid: z.number().int().nullable()
|
|
11
|
+
});
|
|
12
|
+
export const activeConfigSchema = z.object({ config: instanceConfigSchema, revision: z.string(), effectiveRevision: z.string(), sourcePath: z.string() });
|
|
13
|
+
export const runtimeStateSchema = z.object({
|
|
14
|
+
protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), instanceId: z.string().uuid(), runId, pid: z.number().int().min(2),
|
|
15
|
+
launcher: z.enum(['cli', 'app']), appParentPid: z.number().int().nullable(), executionMode: z.enum(['foreground', 'background']).optional(),
|
|
16
|
+
permissionMode: z.enum(['global', 'workspace']).optional(), phase: z.enum(['starting', 'running', 'stopping', 'stopped', 'error']),
|
|
17
|
+
port: z.number().int().min(1024).max(65535).nullable(), active: activeConfigSchema.nullable(), problem: z.string().nullable(),
|
|
18
|
+
coreProgram: z.string(), buildVersion: z.string()
|
|
19
|
+
});
|
|
20
|
+
export const controlRequestSchema = z.object({ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), method: z.enum(['status', 'stop', 'apply']),
|
|
21
|
+
revision: z.string().nullable(), runId, nonce: z.string().regex(/^[a-f0-9]{48}$/) });
|
|
22
|
+
export const controlReplySchema = z.object({ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), state: runtimeStateSchema,
|
|
23
|
+
result: z.enum(['STATE', 'STOP_ACCEPTED']), error: z.string().optional() });
|
|
24
|
+
export function checkInstanceProtocol(value) {
|
|
25
|
+
if (!value || typeof value !== 'object' || !('protocolVersion' in value) || value.protocolVersion !== INSTANCE_CONTROL_VERSION)
|
|
26
|
+
throw Error('INSTANCE_CONTROL_PROTOCOL_UNSUPPORTED');
|
|
27
|
+
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { createServer, createConnection } from 'node:net';
|
|
3
3
|
import { fork } from 'node:child_process';
|
|
4
|
-
import { mkdir, rm,
|
|
4
|
+
import { mkdir, rm, open } from 'node:fs/promises';
|
|
5
|
+
import { setTimeout as pause } from 'node:timers/promises';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { homedir } from 'node:os';
|
|
8
|
-
import {
|
|
9
|
+
import { INSTANCE_CONTROL_VERSION, instanceOwnerSchema, activeConfigSchema, runtimeStateSchema, controlRequestSchema, controlReplySchema, checkInstanceProtocol } from './instance-control-protocol.js';
|
|
9
10
|
import { controlRoot, createPrivateDirectory, childEnvKeys, windows } from './platform.js';
|
|
10
|
-
import { acquireControl } from './global-core-control.js';
|
|
11
|
+
import { acquireControl, inspectControlOwner } from './global-core-control.js';
|
|
11
12
|
import { runtimeIdentity } from './runtime-identity.js';
|
|
12
13
|
import { atomicPrivate, changeRegistry, availablePort, ensureAccessReady, exists, instanceEnv, instancePaths, loadInstanceConfig, prepareUpstreams, readTrusted, resolveInstance } from './instance-store.js';
|
|
13
|
-
|
|
14
|
-
function runtimePaths(id) {
|
|
14
|
+
export function runtimePaths(id) {
|
|
15
15
|
const root = path.join(controlRoot(), 'i-' + id);
|
|
16
16
|
const socket = windows ? '\\\\.\\pipe\\gateway-instance-' + createHash('sha256').update(root).digest('hex').slice(0, 28) : path.join(root, 'control.sock');
|
|
17
17
|
return { root, socket, meta: path.join(root, 'owner.json') };
|
|
@@ -29,21 +29,21 @@ async function ownerFor(instance) {
|
|
|
29
29
|
const p = runtimePaths(instance.id);
|
|
30
30
|
if (!await exists(p.root))
|
|
31
31
|
return null;
|
|
32
|
-
const
|
|
32
|
+
const raw = JSON.parse(await readTrusted(p.meta));
|
|
33
|
+
checkInstanceProtocol(raw);
|
|
34
|
+
const owner = instanceOwnerSchema.parse(raw);
|
|
33
35
|
if (owner.instanceId !== instance.id)
|
|
34
36
|
throw Error('INSTANCE_CONTROL_IDENTITY_INVALID');
|
|
35
37
|
return owner;
|
|
36
38
|
}
|
|
37
|
-
|
|
38
|
-
const owner = await ownerFor(instance);
|
|
39
|
-
if (!owner || !alive(owner.pid))
|
|
40
|
-
return null;
|
|
39
|
+
async function exchange(instance, owner, method, revision, timeoutMs = method === 'apply' ? 65000 : 2000) {
|
|
41
40
|
const nonce = randomBytes(24).toString('hex');
|
|
42
|
-
const body = JSON.stringify({ method, revision: revision ?? null, runId: owner.runId, nonce });
|
|
41
|
+
const body = JSON.stringify({ protocolVersion: INSTANCE_CONTROL_VERSION, method, revision: revision ?? null, runId: owner.runId, nonce });
|
|
43
42
|
return new Promise((resolve, reject) => {
|
|
44
43
|
const socket = createConnection(runtimePaths(instance.id).socket);
|
|
45
44
|
let raw = '';
|
|
46
|
-
|
|
45
|
+
const timer = setTimeout(() => socket.destroy(Error('INSTANCE_CONTROL_TIMEOUT')), timeoutMs);
|
|
46
|
+
socket.once('close', () => clearTimeout(timer));
|
|
47
47
|
socket.on('error', reject);
|
|
48
48
|
socket.on('connect', () => socket.end(JSON.stringify({ body, proof: signature(owner.token, body) }) + '\n'));
|
|
49
49
|
socket.on('data', data => { raw += String(data); if (raw.length > 524288)
|
|
@@ -51,14 +51,18 @@ export async function instanceRequest(instance, method, revision) {
|
|
|
51
51
|
socket.on('end', () => {
|
|
52
52
|
try {
|
|
53
53
|
const reply = JSON.parse(raw);
|
|
54
|
-
if (!equal(reply.proof, signature(owner.token, nonce + reply.body)))
|
|
54
|
+
if (typeof reply.body !== 'string' || !equal(reply.proof, signature(owner.token, nonce + reply.body)))
|
|
55
55
|
throw Error('INSTANCE_CONTROL_REPLY_UNTRUSTED');
|
|
56
|
-
const
|
|
56
|
+
const parsed = JSON.parse(reply.body);
|
|
57
|
+
checkInstanceProtocol(parsed);
|
|
58
|
+
const data = controlReplySchema.parse(parsed);
|
|
57
59
|
if (data.error)
|
|
58
60
|
throw Error(data.error);
|
|
59
61
|
if (data.state.instanceId !== instance.id || data.state.runId !== owner.runId)
|
|
60
62
|
throw Error('INSTANCE_CONTROL_IDENTITY_CHANGED');
|
|
61
|
-
|
|
63
|
+
if (data.result !== (method === 'stop' ? 'STOP_ACCEPTED' : 'STATE'))
|
|
64
|
+
throw Error('INSTANCE_CONTROL_REPLY_INVALID');
|
|
65
|
+
resolve(data);
|
|
62
66
|
}
|
|
63
67
|
catch (e) {
|
|
64
68
|
reject(e);
|
|
@@ -66,64 +70,139 @@ export async function instanceRequest(instance, method, revision) {
|
|
|
66
70
|
});
|
|
67
71
|
});
|
|
68
72
|
}
|
|
73
|
+
export async function instanceRequest(instance, method, revision) {
|
|
74
|
+
const owner = await ownerFor(instance);
|
|
75
|
+
if (!owner || !alive(owner.pid))
|
|
76
|
+
return null;
|
|
77
|
+
return (await exchange(instance, owner, method, revision)).state;
|
|
78
|
+
}
|
|
79
|
+
async function snapshot(file, schema) {
|
|
80
|
+
try {
|
|
81
|
+
return schema.parse(JSON.parse(await readTrusted(file)));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
69
87
|
export async function instanceStatus(instance, home = homedir()) {
|
|
70
88
|
let runtime = null;
|
|
71
89
|
let runtimeProblem = null;
|
|
90
|
+
let owner = null;
|
|
72
91
|
try {
|
|
73
|
-
|
|
92
|
+
owner = await ownerFor(instance);
|
|
93
|
+
if (owner && alive(owner.pid))
|
|
94
|
+
runtime = (await exchange(instance, owner, 'status')).state;
|
|
74
95
|
}
|
|
75
96
|
catch (e) {
|
|
76
97
|
runtimeProblem = e.message;
|
|
77
98
|
}
|
|
78
|
-
let
|
|
99
|
+
let loaded = null;
|
|
79
100
|
let configProblem = null;
|
|
80
101
|
try {
|
|
81
|
-
|
|
102
|
+
loaded = await loadInstanceConfig(instance);
|
|
82
103
|
}
|
|
83
104
|
catch (e) {
|
|
84
105
|
configProblem = e.message;
|
|
85
106
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
catch { }
|
|
92
|
-
if (!runtime && lastRuntime?.problem)
|
|
93
|
-
runtimeProblem ??= lastRuntime.problem;
|
|
107
|
+
const saved = loaded ? { config: loaded.config, revision: loaded.revision, effectiveRevision: loaded.effectiveRevision, sourcePath: loaded.source.path } : null;
|
|
108
|
+
const snapshots = instancePaths(instance, home).snapshots;
|
|
109
|
+
const lastRun = await snapshot(path.join(snapshots, 'last-runtime.json'), runtimeStateSchema);
|
|
110
|
+
const lastActive = await snapshot(path.join(snapshots, 'active.json'), activeConfigSchema);
|
|
94
111
|
let health = 'not-running';
|
|
95
112
|
let accessMode = null;
|
|
96
113
|
let lastToolCallAt = null;
|
|
114
|
+
if (!runtime && !runtimeProblem) {
|
|
115
|
+
// Historical ports are hints for detecting a lost control channel, never
|
|
116
|
+
// authority to stop a process or claim a configuration is currently active.
|
|
117
|
+
const ports = new Set([owner?.port, saved?.config.port, lastActive?.config.port].filter((p) => p !== undefined));
|
|
118
|
+
for (const port of ports) {
|
|
119
|
+
if (!await availablePort(port)) {
|
|
120
|
+
runtimeProblem = 'INSTANCE_CONTROL_UNAVAILABLE: port ' + port + ' is in use; ownership is unconfirmed';
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const lease = inspectControlOwner(port);
|
|
125
|
+
if (lease && alive(lease.pid)) {
|
|
126
|
+
runtimeProblem = 'INSTANCE_CONTROL_UNAVAILABLE: live port lease ' + port;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
runtimeProblem = e.message;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
97
136
|
if (runtime?.phase === 'running' && runtime.port) {
|
|
98
137
|
try {
|
|
99
138
|
const response = await fetch(`http://127.0.0.1:${runtime.port}/healthz`, { signal: AbortSignal.timeout(1500) });
|
|
100
139
|
const data = await response.json();
|
|
101
140
|
health = response.ok && data.status === 'ok' && data.instanceId === instance.id ? 'ok' : 'unrecognized';
|
|
102
141
|
accessMode = data.accessMode ?? null;
|
|
103
|
-
|
|
104
|
-
|
|
142
|
+
if (health === 'ok')
|
|
143
|
+
try {
|
|
144
|
+
const activity = await fetch(`http://127.0.0.1:${runtime.port}/activity.json`, { signal: AbortSignal.timeout(1000) }).then(r => r.json());
|
|
145
|
+
lastToolCallAt = typeof activity.lastToolCallAt === 'string' ? activity.lastToolCallAt : null;
|
|
146
|
+
}
|
|
147
|
+
catch { /* Activity is optional; it must not turn a healthy service into an outage. */ }
|
|
105
148
|
}
|
|
106
149
|
catch {
|
|
107
150
|
health = 'unreachable';
|
|
108
151
|
}
|
|
109
152
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
catch { }
|
|
153
|
+
const active = runtime?.active ?? null;
|
|
154
|
+
const phase = runtime?.phase ?? (runtimeProblem ? 'unknown' : 'stopped');
|
|
155
|
+
if (phase === 'unknown')
|
|
156
|
+
health = 'unknown';
|
|
116
157
|
const configState = configProblem ? (configProblem.includes('ENOENT') ? 'missing' : 'invalid') : runtime?.phase === 'running' ? (saved?.effectiveRevision === active?.effectiveRevision ? 'applied' : 'pending') : 'on-next-start';
|
|
117
158
|
return { schemaVersion: 1, event: 'GATEWAY_STATUS', instanceId: instance.id, name: instance.name, archived: instance.archived, source: instance.source,
|
|
118
|
-
phase
|
|
159
|
+
phase, configState, configProblem, runtimeProblem, runtime, saved, lastActive, lastRun,
|
|
119
160
|
port: runtime?.port ?? saved?.config.port ?? null, configuredPort: saved?.config.port ?? null, active, revision: saved?.revision ?? null,
|
|
120
161
|
health, accessMode, lastToolCallAt, profile: instance.legacy ?? 'setup', installed: false, registered: false,
|
|
121
|
-
label: instance.name, workspaceMode:
|
|
122
|
-
writeScope:
|
|
123
|
-
coreLauncher: runtime?.launcher ?? 'unknown', coreOwnerPid: runtime?.pid ?? null,
|
|
124
|
-
executionMode: runtime
|
|
162
|
+
label: instance.name, workspaceMode: active?.config.mode ?? (phase === 'stopped' ? saved?.config.mode : null) ?? null,
|
|
163
|
+
writeScope: active?.config.writeScope ?? (phase === 'stopped' ? saved?.config.writeScope : null) ?? null, coreControl: runtime?.phase === 'running' ? 'RUNNING' : 'unavailable',
|
|
164
|
+
coreLauncher: runtime?.launcher ?? 'unknown', coreOwnerPid: runtime?.pid ?? null,
|
|
165
|
+
executionMode: runtime ? (runtime.executionMode === 'background' ? 'background' : 'foreground-owned') : phase === 'stopped' ? 'stopped' : 'unknown', loginAutostart: 'disabled',
|
|
125
166
|
connectionState: health === 'ok' ? (lastToolCallAt ? 'local-ready-activity-recorded' : 'local-ready-remote-unverified') : health, requiredAccess: instance.requiredAccess };
|
|
126
167
|
}
|
|
168
|
+
export async function stopInstance(instance, home = homedir(), options = {}) {
|
|
169
|
+
const timeoutMs = options.timeoutMs ?? 65000;
|
|
170
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
|
|
171
|
+
throw Error('INSTANCE_STOP_TIMEOUT_INVALID');
|
|
172
|
+
const deadline = Date.now() + timeoutMs;
|
|
173
|
+
const owner = await ownerFor(instance);
|
|
174
|
+
if (!owner || !alive(owner.pid)) {
|
|
175
|
+
const status = await instanceStatus(instance, home);
|
|
176
|
+
if (status.phase !== 'stopped')
|
|
177
|
+
throw Error(status.runtimeProblem ?? 'INSTANCE_STOP_UNVERIFIED');
|
|
178
|
+
return { result: 'ALREADY_STOPPED', instanceId: instance.id, runId: null, restarted: false };
|
|
179
|
+
}
|
|
180
|
+
await exchange(instance, owner, 'stop', undefined, Math.max(1, Math.min(2000, deadline - Date.now())));
|
|
181
|
+
const lastFile = path.join(instancePaths(instance, home).snapshots, 'last-runtime.json');
|
|
182
|
+
// The terminal snapshot is committed only after worker/lease/socket cleanup.
|
|
183
|
+
// Do not use a free port or a dead PID alone as proof of successful shutdown.
|
|
184
|
+
while (Date.now() < deadline) {
|
|
185
|
+
let current;
|
|
186
|
+
try {
|
|
187
|
+
current = await ownerFor(instance);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
if (e.code === 'ENOENT') {
|
|
191
|
+
await pause(25);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
const lastRun = await snapshot(lastFile, runtimeStateSchema);
|
|
197
|
+
if (lastRun?.runId === owner.runId && lastRun.phase === 'stopped' && current?.runId !== owner.runId) {
|
|
198
|
+
return { result: 'STOPPED', instanceId: instance.id, runId: owner.runId, restarted: current !== null };
|
|
199
|
+
}
|
|
200
|
+
if (lastRun?.runId === owner.runId && lastRun.phase === 'error')
|
|
201
|
+
throw Error('INSTANCE_STOP_FAILED: ' + lastRun.problem);
|
|
202
|
+
await pause(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
203
|
+
}
|
|
204
|
+
throw Error('INSTANCE_STOP_TIMEOUT: cleanup was not confirmed; run starmux doctor');
|
|
205
|
+
}
|
|
127
206
|
export async function runInstance(instance, options = {}) {
|
|
128
207
|
const home = options.home ?? homedir();
|
|
129
208
|
const parent = options.appParentPid;
|
|
@@ -133,9 +212,10 @@ export async function runInstance(instance, options = {}) {
|
|
|
133
212
|
throw Error('INSTANCE_ARCHIVED');
|
|
134
213
|
const initial = await loadInstanceConfig(instance);
|
|
135
214
|
await ensureAccessReady(await resolveInstance(instance.id, home), home);
|
|
215
|
+
const identity = await runtimeIdentity();
|
|
136
216
|
const p = runtimePaths(instance.id);
|
|
137
217
|
createPrivateDirectory(controlRoot());
|
|
138
|
-
const owner = { instanceId: instance.id, runId: randomBytes(16).toString('hex'), pid: process.pid, token: randomBytes(32).toString('hex'), launcher: parent ? 'app' : 'cli', appParentPid: parent ?? null };
|
|
218
|
+
const owner = { protocolVersion: INSTANCE_CONTROL_VERSION, port: initial.config.port, instanceId: instance.id, runId: randomBytes(16).toString('hex'), pid: process.pid, token: randomBytes(32).toString('hex'), launcher: parent ? 'app' : 'cli', appParentPid: parent ?? null };
|
|
139
219
|
await changeRegistry(async (registry) => {
|
|
140
220
|
const registered = registry.instances.find(i => i.id === instance.id);
|
|
141
221
|
if (!registered || registered.archived)
|
|
@@ -165,14 +245,15 @@ export async function runInstance(instance, options = {}) {
|
|
|
165
245
|
}
|
|
166
246
|
await atomicPrivate(p.meta, JSON.stringify(owner));
|
|
167
247
|
}, home);
|
|
168
|
-
const
|
|
169
|
-
const state = { instanceId: instance.id, runId: owner.runId, pid: process.pid, launcher: owner.launcher, appParentPid: parent ?? null, executionMode: options.background ? 'background' : 'foreground', phase: 'starting', port: null, active: null, problem: null, ...identity };
|
|
248
|
+
const state = { protocolVersion: INSTANCE_CONTROL_VERSION, instanceId: instance.id, runId: owner.runId, pid: process.pid, launcher: owner.launcher, appParentPid: parent ?? null, executionMode: options.background ? 'background' : 'foreground', phase: 'starting', port: null, active: null, problem: null, ...identity };
|
|
170
249
|
let worker = null;
|
|
171
250
|
let portLease = null;
|
|
172
251
|
let server = null;
|
|
173
252
|
let ending = false;
|
|
174
253
|
let expectedWorkerExit = false;
|
|
175
254
|
let sequence = Promise.resolve();
|
|
255
|
+
let stopRequest = null;
|
|
256
|
+
const clients = new Set();
|
|
176
257
|
let finish;
|
|
177
258
|
const finished = new Promise(r => { finish = r; });
|
|
178
259
|
const serialize = (fn) => { const next = sequence.then(fn); sequence = next.catch(() => { }); return next; };
|
|
@@ -189,22 +270,40 @@ export async function runInstance(instance, options = {}) {
|
|
|
189
270
|
if (!child)
|
|
190
271
|
return;
|
|
191
272
|
expectedWorkerExit = true;
|
|
192
|
-
await new Promise(resolve => {
|
|
273
|
+
await new Promise((resolve, reject) => {
|
|
193
274
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
194
275
|
resolve();
|
|
195
276
|
return;
|
|
196
277
|
}
|
|
197
278
|
let forced = false;
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
state.problem = 'WORKER_STOP_FORCED';
|
|
279
|
+
const finish = (error) => { clearTimeout(grace); clearTimeout(deadline); child.off('exit', exited); child.off('error', failed); error ? reject(error) : resolve(); };
|
|
280
|
+
const exited = () => { if (forced)
|
|
281
|
+
state.problem = 'WORKER_STOP_FORCED'; finish(); };
|
|
282
|
+
const failed = (error) => finish(error);
|
|
283
|
+
const grace = setTimeout(() => { forced = true; try {
|
|
284
|
+
child.kill('SIGKILL');
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
finish(e);
|
|
288
|
+
} }, 8000);
|
|
289
|
+
const deadline = setTimeout(() => finish(Error('INSTANCE_WORKER_STOP_TIMEOUT')), 12000);
|
|
290
|
+
child.once('exit', exited);
|
|
291
|
+
child.once('error', failed);
|
|
201
292
|
if (child.connected)
|
|
202
|
-
child.send({ type: 'gateway-stop' })
|
|
293
|
+
child.send({ type: 'gateway-stop' }, error => { if (error && child.exitCode === null && child.signalCode === null)
|
|
294
|
+
try {
|
|
295
|
+
child.kill('SIGTERM');
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
finish(e);
|
|
299
|
+
} });
|
|
203
300
|
else
|
|
204
301
|
child.kill('SIGTERM');
|
|
205
302
|
});
|
|
206
303
|
worker = null;
|
|
207
304
|
expectedWorkerExit = false;
|
|
305
|
+
state.active = null;
|
|
306
|
+
state.port = null;
|
|
208
307
|
}
|
|
209
308
|
async function shutdown(reason) {
|
|
210
309
|
if (ending)
|
|
@@ -215,11 +314,24 @@ export async function runInstance(instance, options = {}) {
|
|
|
215
314
|
await stopWorker();
|
|
216
315
|
await portLease?.release();
|
|
217
316
|
portLease = null;
|
|
218
|
-
state.phase = 'stopped';
|
|
219
317
|
state.port = null;
|
|
220
|
-
emit('INSTANCE_STOPPED');
|
|
221
318
|
finish();
|
|
222
319
|
}
|
|
320
|
+
function requestStop(reason) {
|
|
321
|
+
if (!stopRequest) {
|
|
322
|
+
stopRequest = serialize(() => shutdown(reason));
|
|
323
|
+
void stopRequest.catch(async (error) => {
|
|
324
|
+
state.phase = 'error';
|
|
325
|
+
state.problem = 'INSTANCE_STOP_FAILED: ' + error.message;
|
|
326
|
+
emit('INSTANCE_ERROR');
|
|
327
|
+
try {
|
|
328
|
+
await atomicPrivate(path.join(instancePaths(instance, home).snapshots, 'last-runtime.json'), JSON.stringify(state));
|
|
329
|
+
}
|
|
330
|
+
catch { }
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return stopRequest;
|
|
334
|
+
}
|
|
223
335
|
async function startWorker(config, lease) {
|
|
224
336
|
await ensureAccessReady(await resolveInstance(instance.id, home), home);
|
|
225
337
|
if (!await availablePort(config.config.port))
|
|
@@ -263,13 +375,13 @@ export async function runInstance(instance, options = {}) {
|
|
|
263
375
|
state.phase = 'error';
|
|
264
376
|
state.problem = 'INSTANCE_WORKER_EXITED';
|
|
265
377
|
emit('INSTANCE_ERROR');
|
|
266
|
-
void
|
|
378
|
+
void requestStop('INSTANCE_WORKER_EXITED');
|
|
267
379
|
} });
|
|
268
380
|
emit('INSTANCE_RUNNING');
|
|
269
381
|
}
|
|
270
382
|
const active = (loaded) => ({ config: loaded.config, revision: loaded.revision, effectiveRevision: loaded.effectiveRevision, sourcePath: loaded.source.path });
|
|
271
383
|
async function apply(revision) {
|
|
272
|
-
if (ending)
|
|
384
|
+
if (ending || stopRequest)
|
|
273
385
|
throw Error('INSTANCE_STOPPING');
|
|
274
386
|
const current = await resolveInstance(instance.id, home);
|
|
275
387
|
const next = await loadInstanceConfig(current);
|
|
@@ -286,7 +398,7 @@ export async function runInstance(instance, options = {}) {
|
|
|
286
398
|
if (!await availablePort(next.config.port))
|
|
287
399
|
throw Error('PORT_IN_USE: ' + next.config.port);
|
|
288
400
|
candidateLease = await acquireControl(next.config.port, { launcher: owner.launcher });
|
|
289
|
-
candidateLease.onStop(
|
|
401
|
+
candidateLease.onStop(() => requestStop());
|
|
290
402
|
}
|
|
291
403
|
state.phase = 'starting';
|
|
292
404
|
await stopWorker();
|
|
@@ -315,7 +427,7 @@ export async function runInstance(instance, options = {}) {
|
|
|
315
427
|
throw Error(state.problem);
|
|
316
428
|
}
|
|
317
429
|
}
|
|
318
|
-
const signal = () => { void
|
|
430
|
+
const signal = () => { void requestStop(); };
|
|
319
431
|
const parentTimer = parent ? setInterval(() => { if (process.ppid !== parent)
|
|
320
432
|
signal(); }, 400) : null;
|
|
321
433
|
parentTimer?.unref();
|
|
@@ -323,6 +435,8 @@ export async function runInstance(instance, options = {}) {
|
|
|
323
435
|
process.on('SIGINT', signal);
|
|
324
436
|
try {
|
|
325
437
|
server = createServer({ allowHalfOpen: true }, socket => {
|
|
438
|
+
clients.add(socket);
|
|
439
|
+
socket.once('close', () => clients.delete(socket));
|
|
326
440
|
socket.setTimeout(65000, () => socket.destroy());
|
|
327
441
|
let raw = '';
|
|
328
442
|
let handled = false;
|
|
@@ -344,21 +458,25 @@ export async function runInstance(instance, options = {}) {
|
|
|
344
458
|
const envelope = JSON.parse(raw);
|
|
345
459
|
if (typeof envelope.body !== 'string' || !equal(envelope.proof, signature(owner.token, envelope.body)))
|
|
346
460
|
throw Error('CONTROL_DENIED');
|
|
347
|
-
const
|
|
348
|
-
nonce
|
|
349
|
-
|
|
461
|
+
const parsed = JSON.parse(envelope.body);
|
|
462
|
+
// Recover the nonce before validating the version so an authenticated
|
|
463
|
+
// caller receives an explicit protocol error rather than a bad proof.
|
|
464
|
+
if (parsed && typeof parsed === 'object' && 'nonce' in parsed && typeof parsed.nonce === 'string')
|
|
465
|
+
nonce = parsed.nonce;
|
|
466
|
+
checkInstanceProtocol(parsed);
|
|
467
|
+
const req = controlRequestSchema.parse(parsed);
|
|
468
|
+
if (req.runId !== owner.runId)
|
|
350
469
|
throw Error('CONTROL_DENIED');
|
|
351
|
-
if (req.method === '
|
|
352
|
-
await serialize(shutdown);
|
|
353
|
-
else if (req.method === 'apply')
|
|
470
|
+
if (req.method === 'apply')
|
|
354
471
|
await serialize(() => apply(req.revision));
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
472
|
+
const body = JSON.stringify({ protocolVersion: INSTANCE_CONTROL_VERSION, state, result: req.method === 'stop' ? 'STOP_ACCEPTED' : 'STATE' });
|
|
473
|
+
socket.end(JSON.stringify({ body, proof: signature(owner.token, nonce + body) }) + '\n', () => {
|
|
474
|
+
if (req.method === 'stop')
|
|
475
|
+
void requestStop();
|
|
476
|
+
});
|
|
359
477
|
}
|
|
360
478
|
catch (error) {
|
|
361
|
-
const body = JSON.stringify({ state, error: error.message });
|
|
479
|
+
const body = JSON.stringify({ protocolVersion: INSTANCE_CONTROL_VERSION, state, result: 'STATE', error: error.message });
|
|
362
480
|
socket.end(JSON.stringify({ body, proof: signature(owner.token, nonce + body) }) + '\n');
|
|
363
481
|
}
|
|
364
482
|
})();
|
|
@@ -366,7 +484,7 @@ export async function runInstance(instance, options = {}) {
|
|
|
366
484
|
});
|
|
367
485
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(p.socket, () => resolve()); });
|
|
368
486
|
portLease = await acquireControl(initial.config.port, { launcher: owner.launcher });
|
|
369
|
-
portLease.onStop(
|
|
487
|
+
portLease.onStop(() => requestStop());
|
|
370
488
|
await serialize(() => startWorker(active(initial), portLease));
|
|
371
489
|
if (options.onReady)
|
|
372
490
|
await options.onReady();
|
|
@@ -384,13 +502,34 @@ export async function runInstance(instance, options = {}) {
|
|
|
384
502
|
clearInterval(parentTimer);
|
|
385
503
|
process.off('SIGTERM', signal);
|
|
386
504
|
process.off('SIGINT', signal);
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
505
|
+
try {
|
|
506
|
+
await stopWorker();
|
|
507
|
+
await portLease?.release();
|
|
508
|
+
if (server)
|
|
509
|
+
await new Promise((resolve, reject) => {
|
|
510
|
+
const timer = setTimeout(() => { for (const client of clients)
|
|
511
|
+
client.destroy(); }, 1500);
|
|
512
|
+
server.close(error => { clearTimeout(timer); error ? reject(error) : resolve(); });
|
|
513
|
+
});
|
|
514
|
+
if (ending)
|
|
515
|
+
state.phase = 'stopped';
|
|
516
|
+
// Commit completion last. A stop acknowledgement only means accepted;
|
|
517
|
+
// readers require this snapshot AND release of this run's owner record.
|
|
518
|
+
await atomicPrivate(path.join(instancePaths(instance, home).snapshots, 'last-runtime.json'), JSON.stringify(state));
|
|
519
|
+
const current = await ownerFor(instance);
|
|
520
|
+
if (current?.runId === owner.runId)
|
|
521
|
+
await rm(p.root, { recursive: true, force: true });
|
|
522
|
+
if (state.phase === 'stopped')
|
|
523
|
+
emit('INSTANCE_STOPPED');
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
state.phase = 'error';
|
|
527
|
+
state.problem = 'INSTANCE_CLEANUP_FAILED: ' + error.message;
|
|
528
|
+
try {
|
|
529
|
+
await atomicPrivate(path.join(instancePaths(instance, home).snapshots, 'last-runtime.json'), JSON.stringify(state));
|
|
530
|
+
}
|
|
531
|
+
catch { }
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
395
534
|
}
|
|
396
535
|
}
|
package/dist/src/platform.js
CHANGED
|
@@ -59,6 +59,9 @@ export function createPrivateDirectory(folder) {
|
|
|
59
59
|
throw Error('PRIVATE_DIRECTORY_UNSAFE');
|
|
60
60
|
}
|
|
61
61
|
export function controlRoot() {
|
|
62
|
+
// Local control address contract: keep independent of the package version,
|
|
63
|
+
// install path, project directory and display name. A branding edit must not
|
|
64
|
+
// silently move the rendezvous point of an already-running supervisor.
|
|
62
65
|
if (process.platform === 'darwin')
|
|
63
66
|
return `/private/tmp/starmux-${process.getuid()}`;
|
|
64
67
|
const id = windows ? createHash('sha256').update(homedir().toLowerCase()).digest('hex').slice(0, 16) : String(process.getuid());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "starmux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Connect AI clients to files, terminals and development tools on your own computer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/cookyecat/starmux#readme",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"status": "node bin/starmux.mjs status",
|
|
32
32
|
"build": "node scripts/build.mjs",
|
|
33
33
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
34
|
+
"test": "tsc -p tsconfig.json && node --test tests/*.test.mjs",
|
|
34
35
|
"prepare": "node scripts/prepare.mjs",
|
|
35
36
|
"prepack": "npm run build",
|
|
36
37
|
"sync:upstream": "npm install --save-dev --save-exact @wonderwhy-er/desktop-commander@latest --ignore-scripts && npm run build"
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"bin/starmux.mjs",
|
|
64
65
|
"scripts/prepare.mjs",
|
|
65
66
|
"LICENSE",
|
|
67
|
+
"CHANGELOG.md",
|
|
66
68
|
"README.md"
|
|
67
69
|
]
|
|
68
70
|
}
|