@shogo-ai/worker 1.7.4
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/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/shogo.mjs +38 -0
- package/package.json +56 -0
- package/src/cli.ts +127 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/runtime.ts +104 -0
- package/src/commands/start.ts +252 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/config.test.ts +136 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +321 -0
- package/src/lib/config.ts +63 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/paths.ts +35 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +645 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +664 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Stable per-machine UUID stored at ~/.shogo/device-id.
|
|
5
|
+
*
|
|
6
|
+
* The cloud uses (workspaceId, deviceId) as a dedupe key — when the
|
|
7
|
+
* same machine re-runs `shogo login`, the previous device-tagged API
|
|
8
|
+
* key is auto-revoked so we don't accumulate "ghost" device rows in
|
|
9
|
+
* the dashboard. Stability across re-logins is what makes this work,
|
|
10
|
+
* so we persist the id at first generation rather than rolling a new
|
|
11
|
+
* one each time.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { DEVICE_ID_FILE, ensureHome } from './paths.ts';
|
|
16
|
+
|
|
17
|
+
export function getOrCreateDeviceId(): string {
|
|
18
|
+
if (existsSync(DEVICE_ID_FILE)) {
|
|
19
|
+
const value = readFileSync(DEVICE_ID_FILE, 'utf-8').trim();
|
|
20
|
+
if (value) return value;
|
|
21
|
+
}
|
|
22
|
+
ensureHome();
|
|
23
|
+
const id = randomUUID();
|
|
24
|
+
writeFileSync(DEVICE_ID_FILE, id, { mode: 0o600 });
|
|
25
|
+
chmodSync(DEVICE_ID_FILE, 0o600);
|
|
26
|
+
return id;
|
|
27
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Path helpers for the Shogo Worker CLI.
|
|
5
|
+
* Config, PID, and logs live under ~/.shogo/ on all platforms.
|
|
6
|
+
*/
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { mkdirSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
export const HOME_DIR = join(homedir(), '.shogo');
|
|
12
|
+
export const CONFIG_FILE = join(HOME_DIR, 'config.json');
|
|
13
|
+
export const CREDENTIALS_FILE = join(HOME_DIR, 'credentials.json');
|
|
14
|
+
/** Stable per-machine UUID. Generated on first `shogo login`, persisted
|
|
15
|
+
* verbatim, sent up as `deviceId` so cloud dedupes across re-logins. */
|
|
16
|
+
export const DEVICE_ID_FILE = join(HOME_DIR, 'device-id');
|
|
17
|
+
export const PID_FILE = join(HOME_DIR, 'worker.pid');
|
|
18
|
+
export const LOGS_DIR = join(HOME_DIR, 'logs');
|
|
19
|
+
export const WORKER_LOG = join(LOGS_DIR, 'worker.log');
|
|
20
|
+
export const WORKER_ERR = join(LOGS_DIR, 'worker.err.log');
|
|
21
|
+
|
|
22
|
+
/** Default install location for the AGPL agent-runtime binary. */
|
|
23
|
+
export const RUNTIME_DIR = join(HOME_DIR, 'runtime');
|
|
24
|
+
export const RUNTIME_BIN = join(RUNTIME_DIR, process.platform === 'win32' ? 'agent-runtime.exe' : 'agent-runtime');
|
|
25
|
+
export const RUNTIME_VERSION_FILE = join(RUNTIME_DIR, 'version.json');
|
|
26
|
+
|
|
27
|
+
export function ensureHome(): void {
|
|
28
|
+
mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
29
|
+
mkdirSync(LOGS_DIR, { recursive: true, mode: 0o700 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ensureRuntimeDir(): void {
|
|
33
|
+
ensureHome();
|
|
34
|
+
mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 });
|
|
35
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Preflight checks for `shogo worker start --debug`.
|
|
5
|
+
*
|
|
6
|
+
* Verifies: runtime version, worker directory, proxy (when set), all three
|
|
7
|
+
* allowlist hosts (control/tunnel/artifacts), and API-key validity. The
|
|
8
|
+
* allowlist probes match what `docs/my-machines-networking.md` tells
|
|
9
|
+
* security teams to open, so if preflight passes we know the firewall is
|
|
10
|
+
* correctly configured.
|
|
11
|
+
*
|
|
12
|
+
* The `criticality` field on each host controls the overall pass/fail:
|
|
13
|
+
* - fatal → must reach. Preflight fails.
|
|
14
|
+
* - graceful → should reach, but the worker will start anyway (we only
|
|
15
|
+
* warn, because tunnel-direct + artifacts are optional per
|
|
16
|
+
* the failure-modes table).
|
|
17
|
+
*/
|
|
18
|
+
import pc from 'picocolors';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { deriveAllowlist, probeProxy, type ProxyConfig, type AllowlistHost } from './transport.ts';
|
|
21
|
+
|
|
22
|
+
export interface Check {
|
|
23
|
+
name: string;
|
|
24
|
+
/** 'fatal' ⇒ overall preflight fails; 'graceful' ⇒ prints a warning only. */
|
|
25
|
+
criticality?: 'fatal' | 'graceful';
|
|
26
|
+
run(): Promise<{ ok: boolean; detail?: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 5000;
|
|
30
|
+
|
|
31
|
+
async function probeHealth(url: string, timeoutMs: number): Promise<{ ok: boolean; detail: string }> {
|
|
32
|
+
try {
|
|
33
|
+
const ctl = new AbortController();
|
|
34
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
35
|
+
const r = await fetch(`${url.replace(/\/$/, '')}/health`, { signal: ctl.signal }).catch(() => null);
|
|
36
|
+
clearTimeout(t);
|
|
37
|
+
if (!r) return { ok: false, detail: 'no response (firewall? DNS?)' };
|
|
38
|
+
return { ok: true, detail: `HTTP ${r.status}` };
|
|
39
|
+
} catch (err: any) {
|
|
40
|
+
return { ok: false, detail: err?.message ?? 'unknown error' };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const makeChecks = (opts: {
|
|
45
|
+
cloudUrl: string;
|
|
46
|
+
apiKey: string;
|
|
47
|
+
workerDir: string;
|
|
48
|
+
proxy?: ProxyConfig | null;
|
|
49
|
+
}): Check[] => {
|
|
50
|
+
const checks: Check[] = [
|
|
51
|
+
{
|
|
52
|
+
name: 'Runtime (node >= 20)',
|
|
53
|
+
criticality: 'fatal',
|
|
54
|
+
async run() {
|
|
55
|
+
const [major] = process.versions.node.split('.').map(Number);
|
|
56
|
+
return (major ?? 0) >= 20
|
|
57
|
+
? { ok: true, detail: `node v${process.versions.node}` }
|
|
58
|
+
: { ok: false, detail: `node v${process.versions.node} — need >=20` };
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'Worker directory exists',
|
|
63
|
+
criticality: 'fatal',
|
|
64
|
+
async run() {
|
|
65
|
+
return existsSync(opts.workerDir)
|
|
66
|
+
? { ok: true, detail: opts.workerDir }
|
|
67
|
+
: { ok: false, detail: `${opts.workerDir} does not exist` };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
if (opts.proxy) {
|
|
73
|
+
checks.push({
|
|
74
|
+
name: `Proxy reachable (${safeHost(opts.proxy.url)})`,
|
|
75
|
+
criticality: 'fatal',
|
|
76
|
+
async run() {
|
|
77
|
+
return probeProxy(opts.proxy!);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const allowlist = deriveAllowlist(opts.cloudUrl);
|
|
83
|
+
for (const entry of allowlist) {
|
|
84
|
+
checks.push({
|
|
85
|
+
name: `Reach ${entry.host}${entry.purpose !== 'control' ? pc.dim(` (${entry.purpose})`) : ''}`,
|
|
86
|
+
criticality: entry.criticality,
|
|
87
|
+
run: () => probeHealth(entry.url, DEFAULT_PROBE_TIMEOUT_MS),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
checks.push({
|
|
92
|
+
name: 'API key valid',
|
|
93
|
+
criticality: 'fatal',
|
|
94
|
+
async run() {
|
|
95
|
+
try {
|
|
96
|
+
const r = await fetch(`${opts.cloudUrl}/api/instances/heartbeat`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'content-type': 'application/json', 'x-api-key': opts.apiKey },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
hostname: 'preflight',
|
|
101
|
+
name: 'preflight',
|
|
102
|
+
os: 'preflight',
|
|
103
|
+
arch: 'preflight',
|
|
104
|
+
metadata: { preflight: true },
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
if (r.status === 401 || r.status === 403) {
|
|
108
|
+
return { ok: false, detail: `HTTP ${r.status} — key rejected` };
|
|
109
|
+
}
|
|
110
|
+
return r.ok
|
|
111
|
+
? { ok: true, detail: `HTTP ${r.status}` }
|
|
112
|
+
: { ok: false, detail: `HTTP ${r.status}` };
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
return { ok: false, detail: err?.message ?? 'unknown error' };
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return checks;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function safeHost(raw: string): string {
|
|
123
|
+
try { return new URL(raw).host; } catch { return raw; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runPreflight(checks: Check[]): Promise<boolean> {
|
|
127
|
+
console.log(pc.bold('\nShogo Worker — Preflight\n'));
|
|
128
|
+
let fatalFailed = false;
|
|
129
|
+
let gracefulFailed = 0;
|
|
130
|
+
for (const c of checks) {
|
|
131
|
+
process.stdout.write(` ${pc.dim('...')} ${c.name}`);
|
|
132
|
+
const result = await c.run();
|
|
133
|
+
process.stdout.write('\r');
|
|
134
|
+
if (result.ok) {
|
|
135
|
+
console.log(
|
|
136
|
+
` ${pc.green('✓')} ${c.name}${result.detail ? pc.dim(` — ${result.detail}`) : ''}`,
|
|
137
|
+
);
|
|
138
|
+
} else if (c.criticality === 'graceful') {
|
|
139
|
+
console.log(
|
|
140
|
+
` ${pc.yellow('◦')} ${c.name}${result.detail ? pc.dim(` — ${result.detail}`) : ''}${pc.yellow(' (optional — worker will still start)')}`,
|
|
141
|
+
);
|
|
142
|
+
gracefulFailed++;
|
|
143
|
+
} else {
|
|
144
|
+
console.log(
|
|
145
|
+
` ${pc.red('✗')} ${c.name}${result.detail ? pc.dim(` — ${result.detail}`) : ''}`,
|
|
146
|
+
);
|
|
147
|
+
fatalFailed = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!fatalFailed && gracefulFailed === 0) {
|
|
151
|
+
console.log(pc.green('\nAll checks passed.\n'));
|
|
152
|
+
} else if (!fatalFailed) {
|
|
153
|
+
console.log(pc.yellow(`\nStarting with ${gracefulFailed} optional host(s) blocked. See docs/my-machines-networking.md.\n`));
|
|
154
|
+
} else {
|
|
155
|
+
console.log(pc.red('\nPreflight failed — fix blocking issues before starting.\n'));
|
|
156
|
+
}
|
|
157
|
+
return !fatalFailed;
|
|
158
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Manages the lifecycle of the spawned apps/api worker process.
|
|
5
|
+
*
|
|
6
|
+
* Single-instance enforcement via a PID file at ~/.shogo/worker.pid:
|
|
7
|
+
* one worker per machine is intentional — mirrors Cursor's `agent worker`
|
|
8
|
+
* behaviour so the cloud-side instance identity stays 1:1 with a machine
|
|
9
|
+
* rather than multiplexing across forks.
|
|
10
|
+
*
|
|
11
|
+
* Signal hygiene: callers that hold the CLI process in the foreground
|
|
12
|
+
* (`shogo worker start --foreground`) should invoke `installShutdownHooks()`
|
|
13
|
+
* so Ctrl-C / SIGTERM tears down the child and clears the PID file.
|
|
14
|
+
* The detached codepath doesn't need it — once `child.unref()` runs the
|
|
15
|
+
* CLI exits and the child owns its own PID file.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
18
|
+
import { existsSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { PID_FILE, WORKER_LOG, WORKER_ERR, ensureHome } from './paths.ts';
|
|
20
|
+
|
|
21
|
+
export interface SpawnOpts {
|
|
22
|
+
entry: string;
|
|
23
|
+
runner: 'bun' | 'node';
|
|
24
|
+
env: NodeJS.ProcessEnv;
|
|
25
|
+
cwd: string;
|
|
26
|
+
detach?: boolean;
|
|
27
|
+
inheritStdio?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readPid(): number | null {
|
|
31
|
+
if (!existsSync(PID_FILE)) return null;
|
|
32
|
+
const raw = readFileSync(PID_FILE, 'utf-8').trim();
|
|
33
|
+
const n = parseInt(raw, 10);
|
|
34
|
+
return Number.isFinite(n) ? n : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isRunning(pid: number): boolean {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearPid(): void {
|
|
47
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function spawnWorker(opts: SpawnOpts): { pid: number; child: ChildProcess } {
|
|
51
|
+
ensureHome();
|
|
52
|
+
|
|
53
|
+
const existingPid = readPid();
|
|
54
|
+
if (existingPid && isRunning(existingPid)) {
|
|
55
|
+
throw new Error(`Worker already running (pid=${existingPid}). Run \`shogo worker stop\` first.`);
|
|
56
|
+
}
|
|
57
|
+
if (existingPid) clearPid();
|
|
58
|
+
|
|
59
|
+
const stdio: ("ignore" | "inherit" | number)[] = opts.inheritStdio
|
|
60
|
+
? ['ignore', 'inherit', 'inherit']
|
|
61
|
+
: ['ignore', openSync(WORKER_LOG, 'a'), openSync(WORKER_ERR, 'a')];
|
|
62
|
+
|
|
63
|
+
const child = spawn(opts.runner, [opts.entry], {
|
|
64
|
+
cwd: opts.cwd,
|
|
65
|
+
env: opts.env,
|
|
66
|
+
detached: !!opts.detach,
|
|
67
|
+
stdio: stdio as any,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!child.pid) throw new Error('Failed to spawn worker process.');
|
|
71
|
+
writeFileSync(PID_FILE, String(child.pid), { mode: 0o600 });
|
|
72
|
+
|
|
73
|
+
if (opts.detach) child.unref();
|
|
74
|
+
return { pid: child.pid, child };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Install SIGINT / SIGTERM / exit handlers that forward the signal to the
|
|
79
|
+
* foreground child and clear the PID file. Idempotent — safe to call twice.
|
|
80
|
+
*
|
|
81
|
+
* Only meaningful for foreground runs; detached workers own their own
|
|
82
|
+
* lifecycle after `child.unref()`.
|
|
83
|
+
*/
|
|
84
|
+
export function installShutdownHooks(child: ChildProcess): void {
|
|
85
|
+
let shutdownStarted = false;
|
|
86
|
+
const shutdown = (signal: NodeJS.Signals) => {
|
|
87
|
+
if (shutdownStarted) return;
|
|
88
|
+
shutdownStarted = true;
|
|
89
|
+
try { child.kill(signal); } catch { /* already gone */ }
|
|
90
|
+
clearPid();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
94
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
95
|
+
process.once('SIGHUP', () => shutdown('SIGHUP'));
|
|
96
|
+
process.once('exit', () => {
|
|
97
|
+
if (!shutdownStarted) clearPid();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Propagate child's exit to this process for foreground runs.
|
|
101
|
+
child.on('exit', (code, signal) => {
|
|
102
|
+
clearPid();
|
|
103
|
+
if (signal) process.exit(128 + (signalToInt(signal) ?? 0));
|
|
104
|
+
process.exit(code ?? 0);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function signalToInt(signal: NodeJS.Signals): number | undefined {
|
|
109
|
+
const map: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGTERM: 15 };
|
|
110
|
+
return map[signal];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function stopWorker(signal: NodeJS.Signals = 'SIGTERM'): { killedPid: number | null } {
|
|
114
|
+
const pid = readPid();
|
|
115
|
+
if (!pid) return { killedPid: null };
|
|
116
|
+
if (!isRunning(pid)) {
|
|
117
|
+
clearPid();
|
|
118
|
+
return { killedPid: null };
|
|
119
|
+
}
|
|
120
|
+
try { process.kill(pid, signal); } catch {}
|
|
121
|
+
clearPid();
|
|
122
|
+
return { killedPid: pid };
|
|
123
|
+
}
|