centralu 0.1.0-beta.4 → 0.1.0-beta.41

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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `centralu serve --window` (#1223): what the desktop window on Windows runs in place of a host of its own when
3
+ * `centralu serve` is installed in its data folder (`start_plan`'s `Host::Serve` in the window). One host per data
4
+ * folder: a linked hub's serve holds this one, or will, so the window uses that host rather than start one the folder's
5
+ * lock refuses.
6
+ */
7
+ import { join } from 'node:path'
8
+ import { readServeState } from './serve-files.mjs'
9
+ import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
10
+ import { hostProvesOf, probeHost, protocolVersionOf } from './serve-host.mjs'
11
+ import { serveFolder } from './serve-run.mjs'
12
+ import { runDetach } from './serve-detach.mjs'
13
+ import { startInSignIn } from './serve-autostart.mjs'
14
+ import { say, sleep } from './serve-util.mjs'
15
+
16
+ /**
17
+ * Makes sure the folder's serve host runs, then prints the window's ready line, `{"ready":true,"port","token","db"}`
18
+ * (the line a host the window starts prints), and stays until the window goes.
19
+ *
20
+ * - No host answers: one is started the way `--detach` starts it, in this Windows sign-in through the scheduled task,
21
+ * so it outlives the window and the hub's link finds it (`serve --connection`).
22
+ * - A host in a network logon answers (the one the hub's ssh started before anyone signed in): the window uses it now,
23
+ * and the sign-in task is started, whose serve moves it into this sign-in once nothing runs there
24
+ * (`moveIntoSignIn`, serve-handover.mjs).
25
+ * - stdin is the window (it runs this with stdin a pipe): when it ends, this exits 0 and leaves the host running,
26
+ * since it serves the hub's link too.
27
+ * - The host stops answering for `misses` polls in a row (the hand-over's gap, or it ended): this exits 1, and the
28
+ * window's supervisor starts this again, which finds the new host or starts one.
29
+ *
30
+ * Resolves with the exit code. `detach`, `signIn`, `pollMs`, `misses` and `stdin` are parameters for tests.
31
+ */
32
+ export async function runWindow({
33
+ env,
34
+ home,
35
+ entry,
36
+ version,
37
+ cliPath,
38
+ platform = process.platform,
39
+ write = (line) => process.stdout.write(line),
40
+ stdin = process.stdin,
41
+ detach = runDetach,
42
+ signIn = startInSignIn,
43
+ pollMs = 5_000,
44
+ misses = 3,
45
+ }) {
46
+ const { dataDir } = serveFolder({ env, home, cliPath, platform })
47
+ const prove = hostProvesOf(entry)
48
+ const protocolVersion = protocolVersionOf(entry) ?? 0
49
+ const probe = () => {
50
+ const state = readServeState(dataDir)
51
+ return state ? probeHost({ port: state.port ?? DEFAULT_SERVE_PORT, token: state.token, protocolVersion, prove }) : Promise.resolve({ running: false })
52
+ }
53
+
54
+ let host = await probe()
55
+ if (!host.running) {
56
+ say(`no Centralu host runs for ${dataDir}: starting one in this Windows sign-in, which keeps running for a linked hub when the window closes`)
57
+ let answer = null
58
+ await detach({ env, home, entry, version, cliPath, platform, write: (line) => (answer = line) })
59
+ const d = parseLine(answer)
60
+ if (!d?.hostRunning) {
61
+ say(`could not start the host: ${d?.detach?.message ?? 'centralu serve --detach did not answer'}`)
62
+ return 1
63
+ }
64
+ host = await probe()
65
+ if (!host.running) {
66
+ say('the host started, then did not answer')
67
+ return 1
68
+ }
69
+ } else if (host.logon === 'network') {
70
+ const s = await signIn({ dataDir, env, home, platform }).catch((e) => ({ started: false, reason: 'error', message: e.message }))
71
+ say(
72
+ s.started
73
+ ? 'the host runs in a network logon (started over ssh): this window uses it, and it moves into this sign-in once nothing runs there'
74
+ : `the host runs in a network logon (started over ssh), and could not be moved into this sign-in (${s.message ?? s.reason})`,
75
+ )
76
+ }
77
+
78
+ const state = readServeState(dataDir)
79
+ write(`${JSON.stringify({ ready: true, port: state.port ?? DEFAULT_SERVE_PORT, token: state.token, db: join(dataDir, 'store.db') })}\n`)
80
+
81
+ return await new Promise((resolve) => {
82
+ let done = false
83
+ const finish = (code) => {
84
+ if (done) return
85
+ done = true
86
+ resolve(code)
87
+ }
88
+ stdin.once('end', () => finish(0))
89
+ stdin.once('close', () => finish(0))
90
+ stdin.resume()
91
+ void (async () => {
92
+ let missed = 0
93
+ while (!done) {
94
+ await sleep(pollMs)
95
+ if (done) return
96
+ missed = (await probe()).running ? 0 : missed + 1
97
+ if (missed >= misses) {
98
+ say(`the host for ${dataDir} stopped answering; the window starts this again`)
99
+ finish(1)
100
+ }
101
+ }
102
+ })()
103
+ })
104
+ }
105
+
106
+ function parseLine(text) {
107
+ try {
108
+ return JSON.parse(String(text ?? '').trim().split('\n').at(-1))
109
+ } catch {
110
+ return null
111
+ }
112
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Windows and WSL for `centralu serve`: which way a start outlives the ssh session, a Windows command
3
+ * line, the PowerShell that asks WMI to create a process, and the Windows programs reached by full path
4
+ * (from Windows itself, or through the drive a WSL distro mounts).
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { systemRoot } from './platform.mjs'
8
+ import { runCaptured } from './serve-util.mjs'
9
+
10
+ /**
11
+ * How `--detach` starts the launcher so that it outlives the session that asked (measured,
12
+ * docs/plans/remote-hub.md §10.4):
13
+ *
14
+ * setsid posix: a new session, no terminal, nothing from the ssh session's process group. Once
15
+ * `--detach` returns, the launcher is reparented to init (the second fork of the usual
16
+ * double fork); it opens no terminal, so it can never get a controlling one back
17
+ * wmi Windows: `Win32_Process.Create`. Windows' OpenSSH ends every process of a session with
18
+ * it, `Start-Process` included; a process WMI creates is not part of the session
19
+ * wsl inside WSL: the same WMI call from the Windows side, creating `wsl.exe -d <distro>
20
+ * --exec <launcher>`. A `setsid` launcher inside the distro survives the session but not the
21
+ * distro, which WSL stops about 15 s after its last `wsl.exe` client exits; this
22
+ * `wsl.exe` is a client for as long as the host runs, and ending it ends the host
23
+ */
24
+ export function detachHow(platform, env, interop = wslInterop) {
25
+ if (platform === 'win32') return 'wmi'
26
+ if (platform === 'linux' && env.WSL_DISTRO_NAME && interop()) return 'wsl'
27
+ return 'setsid'
28
+ }
29
+
30
+ export function wslInterop() {
31
+ return existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')
32
+ }
33
+
34
+ /**
35
+ * Which Windows logon this process runs in (#1224), from `whoami /groups` as the host reads its own (`logonFromGroups`
36
+ * in agent-host's os.ts): `interactive` (S-1-5-4), `network` (S-1-5-2), `batch`, `service`, or null when it cannot
37
+ * tell. By the SIDs, which read the same in every language
38
+ */
39
+ export async function ownLogon(env, run = runCaptured) {
40
+ const r = await run(windowsTool('wmi', env, ['System32', 'whoami.exe']), ['/groups', '/fo', 'csv', '/nh'], env).catch(() => null)
41
+ const out = String(r?.stdout ?? '')
42
+ if (/"S-1-5-4"/.test(out)) return 'interactive'
43
+ if (/"S-1-5-2"/.test(out)) return 'network'
44
+ if (/"S-1-5-3"/.test(out)) return 'batch'
45
+ if (/"S-1-5-6"/.test(out)) return 'service'
46
+ return null
47
+ }
48
+
49
+ /** A WSL distro's name for `wsl.exe -d`, refused when it could be read as anything else */
50
+ export function wslDistro(distro) {
51
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro ?? '')) throw new Error(`not a WSL distro name: ${distro}`)
52
+ return distro
53
+ }
54
+
55
+ /** A Linux user's name for `wsl.exe -u`, refused when it could be read as anything else */
56
+ export function wslUser(user) {
57
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user ?? '')) throw new Error(`cannot name the user ${user} to wsl.exe`)
58
+ return user
59
+ }
60
+
61
+ /**
62
+ * One argument for a Windows command line, as `CommandLineToArgvW` and the C runtime read it: always
63
+ * quoted, backslashes before the closing quote doubled. A double quote or a line break cannot be a
64
+ * path on Windows and is refused rather than escaped.
65
+ */
66
+ export function windowsArg(arg) {
67
+ const s = String(arg)
68
+ if (/["\r\n\0]/.test(s)) throw new Error(`cannot pass ${JSON.stringify(s)} on a Windows command line`)
69
+ return `"${s.replace(/(\\+)$/, '$1$1')}"`
70
+ }
71
+
72
+ /**
73
+ * The first line of a script run by Windows PowerShell 5.1: its own module path. Started from
74
+ * PowerShell 7 (pwsh, a `DefaultShell` of it, a CI runner), 5.1 inherits pwsh's `PSModulePath`, whose
75
+ * first folder holds 7.x copies of the built-in modules that 5.1 cannot load: `Invoke-CimMethod` and
76
+ * `Get-ScheduledTask` are then "found in the module … but the module could not be loaded". Same line as
77
+ * the hub's (links/tunnel.ts, `PS_PREAMBLE`)
78
+ */
79
+ export const PS_MODULE_PATH = "if ($env:PSModulePath -match '\\x5cPowerShell\\x5c') { $env:PSModulePath = \"$env:ProgramFiles\\WindowsPowerShell\\Modules;$PSHOME\\Modules\" }"
80
+
81
+ /** A PowerShell expression for `s`: base64 decoded there, so no quote reaches PowerShell's parser */
82
+ export function psText(s) {
83
+ return `([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(String(s), 'utf8').toString('base64')}')))`
84
+ }
85
+
86
+ /** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
87
+ export function wmiScript({ commandLine, cwd = null }) {
88
+ return [
89
+ "$ErrorActionPreference = 'Stop'",
90
+ PS_MODULE_PATH,
91
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
92
+ `$a = @{ CommandLine = ${psText(commandLine)} }`,
93
+ ...(cwd ? [`$a.CurrentDirectory = ${psText(cwd)}`] : []),
94
+ // No console window when this runs from a desktop session; left out where it cannot be built
95
+ 'try { $a.ProcessStartupInformation = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { }',
96
+ 'try {',
97
+ ' $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a',
98
+ " if ($r.ReturnValue -eq 21 -and $a.ContainsKey('ProcessStartupInformation')) { $a.Remove('ProcessStartupInformation'); $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a }",
99
+ ' "CENTRALU-WMI $($r.ReturnValue) $($r.ProcessId)"',
100
+ '} catch { "CENTRALU-WMI-ERROR $($_.Exception.Message)" }',
101
+ ].join('\n')
102
+ }
103
+
104
+ /** `Win32_Process.Create`'s return values (Microsoft's documentation of the method) */
105
+ const WMI_CODES = { 2: 'access denied', 3: 'insufficient privilege', 8: 'unknown failure', 9: 'path not found', 21: 'invalid parameter' }
106
+
107
+ /** What the WMI script said: `{ ok: true, pid }` or `{ ok: false, message }` */
108
+ export function parseWmiAnswer(stdout) {
109
+ const text = String(stdout ?? '')
110
+ const m = /CENTRALU-WMI (\d+) (\d*)/.exec(text)
111
+ if (m) {
112
+ const rv = Number(m[1])
113
+ if (rv === 0 && Number(m[2]) > 0) return { ok: true, pid: Number(m[2]) }
114
+ return { ok: false, message: `WMI's Win32_Process.Create returned ${rv}${WMI_CODES[rv] ? ` (${WMI_CODES[rv]})` : ''}` }
115
+ }
116
+ const e = /CENTRALU-WMI-ERROR (.*)/.exec(text)
117
+ if (e) return { ok: false, message: `WMI refused to create the process: ${e[1].trim()}` }
118
+ return { ok: false, message: 'PowerShell did not answer the WMI request' }
119
+ }
120
+
121
+ /**
122
+ * The sentence for a start WMI did not make. Some managed machines block process creation through
123
+ * WMI (Microsoft Defender's attack surface reduction rule "Block process creations originating from
124
+ * PSExec and WMI commands", off by default); the hub then runs the host bound to its link
125
+ * (docs/plans/remote-hub.md §10.9, decision 3).
126
+ */
127
+ export function wmiBlockedMessage(detail) {
128
+ return (
129
+ `Windows did not start Centralu through WMI (${detail}). Some managed Windows machines block it ` +
130
+ '(Microsoft Defender\'s attack surface reduction rule "Block process creations originating from PSExec and WMI commands"). ' +
131
+ 'Centralu can run there while this computer is linked instead, and stops when the link does.'
132
+ )
133
+ }
134
+
135
+ /** Windows PowerShell 5.1, by full path: under %SystemRoot% on Windows, through the drive WSL mounts inside a distro */
136
+ export function windowsPowershell(how, env, mounts) {
137
+ return windowsTool(how, env, ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'], mounts)
138
+ }
139
+
140
+ /** A program under the Windows folder, by full path, from Windows or from inside a WSL distro; null when unreachable */
141
+ export function windowsTool(how, env, rel, mounts = () => readFileSync('/proc/mounts', 'utf8')) {
142
+ if (how === 'wmi') return [systemRoot(env), ...rel].join('\\')
143
+ let text = ''
144
+ try {
145
+ text = mounts()
146
+ } catch {
147
+ // no /proc: not WSL after all
148
+ }
149
+ for (const line of text.split('\n')) {
150
+ const [, at, type] = line.split(' ')
151
+ if (!at || !(type === '9p' || type === 'drvfs')) continue
152
+ const candidate = [at, 'Windows', ...rel].join('/')
153
+ if (existsSync(candidate)) return candidate
154
+ }
155
+ return null
156
+ }
157
+
158
+ /** A script as `-EncodedCommand` takes it: UTF-16LE, in base64 */
159
+ export function encodedCommand(script) {
160
+ return Buffer.from(script, 'utf16le').toString('base64')
161
+ }
162
+
163
+ /**
164
+ * Runs `script` in the Windows PowerShell at `ps` and reads what it printed with `parse` (`{ ok, … }`). When it printed
165
+ * nothing and PowerShell itself complained (a policy, a module it could not load), the answer is the last line of that.
166
+ */
167
+ export async function askPowershell(ps, script, env, parse, run = runCaptured) {
168
+ const r = await run(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodedCommand(script)], env)
169
+ const a = parse(r.stdout)
170
+ return a.ok || r.stdout.trim() || !r.stderr.trim() ? a : { ok: false, message: r.stderr.trim().split(/\r?\n/).at(-1) }
171
+ }
package/bin/serve.mjs ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `centralu serve`: the host without the app, for the remote mode's first phase (#82).
3
+ *
4
+ * The person starts this by hand on another machine (an SSH server, later a laptop). It runs the
5
+ * same bundled host the app runs (`resources/host/main.mjs`, on the system Node), with no window
6
+ * and no keeper, bound to 127.0.0.1 only. The app on the person's own computer reaches it through
7
+ * an SSH local forward, so nothing ever listens on a public interface. The design and its limits
8
+ * are in docs/agent-host.md §4.7; the trust argument in docs/security-boundaries.md.
9
+ *
10
+ * `centralu serve --connection` is the one question the client asks over `ssh <target>`: where to
11
+ * forward and how to authenticate. It answers with one JSON line on stdout and nothing else.
12
+ *
13
+ * Everything here takes its inputs as arguments (environment, home, platform) instead of reading
14
+ * `process`, so `tooling/launcher-serve.test.ts` can import it: `centralu.mjs` runs a command at
15
+ * import time and cannot be imported by a test. package.json "files" lists the whole bin/
16
+ * directory, so this ships automatically.
17
+ *
18
+ * This file is the launcher's `serve` as one module, for `centralu.mjs`, the tests, and the hub's
19
+ * installer (which imports an installed version's `serve.mjs` for `managedLauncherScript`). The code
20
+ * lives beside it, one concern per file: the arguments (`serve-args.mjs`), the files in the data
21
+ * folder (`serve-files.mjs`), the port (`serve-port.mjs`), the launchers and the managed install
22
+ * (`serve-install.mjs`), the host and its socket (`serve-host.mjs`), the foreground serve
23
+ * (`serve-run.mjs`), `--detach` and `--stop` (`serve-detach.mjs`), `--autostart`
24
+ * (`serve-autostart.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
25
+ * (`serve-util.mjs`).
26
+ */
27
+ export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolderIsAlive, processStartTime, lockHolder, lockHolderPid, readServePid, SERVE_STOPPED_FILE, SERVE_LOGS_KEPT, markStopped, readStopped, rotateServeLog } from './serve-files.mjs'
28
+ export { detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, ownLogon } from './serve-windows.mjs'
29
+ export { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, parseNetstatListening } from './serve-port.mjs'
30
+ export { SERVE_HELP, AUTOSTART_ACTIONS, parseServeArgs } from './serve-args.mjs'
31
+ export { launcherScript, ensureLauncher, remoteLayout, parsePointer, installInfo, managedLauncherScript } from './serve-install.mjs'
32
+ export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop, askHostRpc, serveFrom, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
33
+ export { stopServedHost, moveIntoSignIn, HANDOVER_WAIT_MS } from './serve-handover.mjs'
34
+ export { runWindow } from './serve-window.mjs'
35
+ export { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart, startInSignIn } from './serve-autostart.mjs'
36
+ export { printConnection, runServe, rotateToken } from './serve-run.mjs'
37
+ export { CARRIED_ENV, encodeChildSpec, decodeChildSpec, startDetached, runDetach, runDetachedChild, runStop } from './serve-detach.mjs'
package/bin/swap.mjs ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Replacing an installed copy of the app without a moment where there is none.
3
+ *
4
+ * Lives apart from `centralu.mjs` for the same reason `platform.mjs` does: the launcher runs a
5
+ * command at import time, so a test cannot import it. The paths come in as arguments, so
6
+ * `tooling/launcher-swap.test.ts` runs this against temporary folders instead of `/Applications`.
7
+ */
8
+ import { existsSync, renameSync, rmSync } from 'node:fs'
9
+
10
+ /**
11
+ * Puts a new copy at `target`: `fill(staging)` writes it beside the old one, the old one is
12
+ * renamed aside, the new one is renamed in, and only then is the old one deleted.
13
+ *
14
+ * The old copy is never deleted before the new one is complete. Deleting first and copying
15
+ * after left a person whose copy failed (a full disk, a permission, an interrupted `ditto`)
16
+ * with no app at all, from a command they ran to update it. A copy that fails here leaves the
17
+ * old install untouched, and a rename that fails puts the old one back.
18
+ *
19
+ * Returns whether something was replaced, and where the old copy was left if it could not be
20
+ * deleted (null when it was).
21
+ */
22
+ export function swapInto(target, fill, now = Date.now()) {
23
+ const staging = `${target}.new`
24
+ const aside = `${target}.old-${now}`
25
+ rmSync(staging, { recursive: true, force: true })
26
+ try {
27
+ fill(staging)
28
+ } catch (e) {
29
+ rmSync(staging, { recursive: true, force: true })
30
+ throw e
31
+ }
32
+ const replaced = existsSync(target)
33
+ if (replaced) renameSync(target, aside)
34
+ try {
35
+ renameSync(staging, target)
36
+ } catch (e) {
37
+ if (replaced) renameSync(aside, target)
38
+ rmSync(staging, { recursive: true, force: true })
39
+ throw e
40
+ }
41
+ if (!replaced) return { replaced, leftAt: null }
42
+ try {
43
+ rmSync(aside, { recursive: true, force: true })
44
+ return { replaced, leftAt: null }
45
+ } catch {
46
+ return { replaced, leftAt: aside }
47
+ }
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.41",
4
4
  "description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
5
5
  "license": "MIT",
6
6
  "author": "ijun17",
@@ -18,8 +18,10 @@
18
18
  "agent",
19
19
  "macos",
20
20
  "linux",
21
+ "windows",
21
22
  "desktop"
22
23
  ],
24
+ "protocolVersion": 1,
23
25
  "type": "module",
24
26
  "bin": {
25
27
  "centralu": "bin/centralu.mjs"
@@ -28,15 +30,17 @@
28
30
  "bin"
29
31
  ],
30
32
  "engines": {
31
- "node": ">=22"
33
+ "node": ">=22.14.0"
32
34
  },
33
35
  "os": [
34
36
  "darwin",
35
- "linux"
37
+ "linux",
38
+ "win32"
36
39
  ],
37
40
  "optionalDependencies": {
38
- "centralu-darwin-arm64": "0.1.0-beta.4",
39
- "centralu-linux-arm64": "0.1.0-beta.4",
40
- "centralu-linux-x64": "0.1.0-beta.4"
41
+ "@centralu/darwin-arm64": "0.1.0-beta.41",
42
+ "@centralu/linux-arm64": "0.1.0-beta.41",
43
+ "@centralu/linux-x64": "0.1.0-beta.41",
44
+ "@centralu/win32-x64": "0.1.0-beta.41"
41
45
  }
42
46
  }