@bazaar.ai/mcp-human-agents 0.1.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/.env.example +15 -0
- package/README.md +178 -0
- package/dist/mcp-server/src/bin.d.ts +3 -0
- package/dist/mcp-server/src/bin.d.ts.map +1 -0
- package/dist/mcp-server/src/bin.js +10 -0
- package/dist/mcp-server/src/bin.js.map +1 -0
- package/dist/mcp-server/src/cli/setup.d.ts +2 -0
- package/dist/mcp-server/src/cli/setup.d.ts.map +1 -0
- package/dist/mcp-server/src/cli/setup.js +274 -0
- package/dist/mcp-server/src/cli/setup.js.map +1 -0
- package/dist/mcp-server/src/config/defaults.d.ts +16 -0
- package/dist/mcp-server/src/config/defaults.d.ts.map +1 -0
- package/dist/mcp-server/src/config/defaults.js +19 -0
- package/dist/mcp-server/src/config/defaults.js.map +1 -0
- package/dist/mcp-server/src/config/env.d.ts +73 -0
- package/dist/mcp-server/src/config/env.d.ts.map +1 -0
- package/dist/mcp-server/src/config/env.js +72 -0
- package/dist/mcp-server/src/config/env.js.map +1 -0
- package/dist/mcp-server/src/config/index.d.ts +3 -0
- package/dist/mcp-server/src/config/index.d.ts.map +1 -0
- package/dist/mcp-server/src/config/index.js +22 -0
- package/dist/mcp-server/src/config/index.js.map +1 -0
- package/dist/mcp-server/src/context/generator.d.ts +16 -0
- package/dist/mcp-server/src/context/generator.d.ts.map +1 -0
- package/dist/mcp-server/src/context/generator.js +61 -0
- package/dist/mcp-server/src/context/generator.js.map +1 -0
- package/dist/mcp-server/src/context/index.d.ts +3 -0
- package/dist/mcp-server/src/context/index.d.ts.map +1 -0
- package/dist/mcp-server/src/context/index.js +10 -0
- package/dist/mcp-server/src/context/index.js.map +1 -0
- package/dist/mcp-server/src/context/templates.d.ts +8 -0
- package/dist/mcp-server/src/context/templates.d.ts.map +1 -0
- package/dist/mcp-server/src/context/templates.js +41 -0
- package/dist/mcp-server/src/context/templates.js.map +1 -0
- package/dist/mcp-server/src/git/branch.d.ts +13 -0
- package/dist/mcp-server/src/git/branch.d.ts.map +1 -0
- package/dist/mcp-server/src/git/branch.js +49 -0
- package/dist/mcp-server/src/git/branch.js.map +1 -0
- package/dist/mcp-server/src/git/diff.d.ts +10 -0
- package/dist/mcp-server/src/git/diff.d.ts.map +1 -0
- package/dist/mcp-server/src/git/diff.js +39 -0
- package/dist/mcp-server/src/git/diff.js.map +1 -0
- package/dist/mcp-server/src/git/index.d.ts +5 -0
- package/dist/mcp-server/src/git/index.d.ts.map +1 -0
- package/dist/mcp-server/src/git/index.js +16 -0
- package/dist/mcp-server/src/git/index.js.map +1 -0
- package/dist/mcp-server/src/git/merge.d.ts +6 -0
- package/dist/mcp-server/src/git/merge.d.ts.map +1 -0
- package/dist/mcp-server/src/git/merge.js +30 -0
- package/dist/mcp-server/src/git/merge.js.map +1 -0
- package/dist/mcp-server/src/git/worktree.d.ts +11 -0
- package/dist/mcp-server/src/git/worktree.d.ts.map +1 -0
- package/dist/mcp-server/src/git/worktree.js +38 -0
- package/dist/mcp-server/src/git/worktree.js.map +1 -0
- package/dist/mcp-server/src/http-wrapper.d.ts +6 -0
- package/dist/mcp-server/src/http-wrapper.d.ts.map +1 -0
- package/dist/mcp-server/src/http-wrapper.js +85 -0
- package/dist/mcp-server/src/http-wrapper.js.map +1 -0
- package/dist/mcp-server/src/index.d.ts +2 -0
- package/dist/mcp-server/src/index.d.ts.map +1 -0
- package/dist/mcp-server/src/index.js +28 -0
- package/dist/mcp-server/src/index.js.map +1 -0
- package/dist/mcp-server/src/platform-client/client.d.ts +17 -0
- package/dist/mcp-server/src/platform-client/client.d.ts.map +1 -0
- package/dist/mcp-server/src/platform-client/client.js +68 -0
- package/dist/mcp-server/src/platform-client/client.js.map +1 -0
- package/dist/mcp-server/src/platform-client/index.d.ts +5 -0
- package/dist/mcp-server/src/platform-client/index.d.ts.map +1 -0
- package/dist/mcp-server/src/platform-client/index.js +10 -0
- package/dist/mcp-server/src/platform-client/index.js.map +1 -0
- package/dist/mcp-server/src/platform-client/mock-client.d.ts +28 -0
- package/dist/mcp-server/src/platform-client/mock-client.d.ts.map +1 -0
- package/dist/mcp-server/src/platform-client/mock-client.js +75 -0
- package/dist/mcp-server/src/platform-client/mock-client.js.map +1 -0
- package/dist/mcp-server/src/platform-client/polling.d.ts +9 -0
- package/dist/mcp-server/src/platform-client/polling.d.ts.map +1 -0
- package/dist/mcp-server/src/platform-client/polling.js +40 -0
- package/dist/mcp-server/src/platform-client/polling.js.map +1 -0
- package/dist/mcp-server/src/platform-client/types.d.ts +2 -0
- package/dist/mcp-server/src/platform-client/types.d.ts.map +1 -0
- package/dist/mcp-server/src/platform-client/types.js +3 -0
- package/dist/mcp-server/src/platform-client/types.js.map +1 -0
- package/dist/mcp-server/src/provisioning/authorized-keys.d.ts +14 -0
- package/dist/mcp-server/src/provisioning/authorized-keys.d.ts.map +1 -0
- package/dist/mcp-server/src/provisioning/authorized-keys.js +48 -0
- package/dist/mcp-server/src/provisioning/authorized-keys.js.map +1 -0
- package/dist/mcp-server/src/provisioning/cleanup.d.ts +19 -0
- package/dist/mcp-server/src/provisioning/cleanup.d.ts.map +1 -0
- package/dist/mcp-server/src/provisioning/cleanup.js +96 -0
- package/dist/mcp-server/src/provisioning/cleanup.js.map +1 -0
- package/dist/mcp-server/src/provisioning/index.d.ts +6 -0
- package/dist/mcp-server/src/provisioning/index.d.ts.map +1 -0
- package/dist/mcp-server/src/provisioning/index.js +24 -0
- package/dist/mcp-server/src/provisioning/index.js.map +1 -0
- package/dist/mcp-server/src/provisioning/linux-user.d.ts +15 -0
- package/dist/mcp-server/src/provisioning/linux-user.d.ts.map +1 -0
- package/dist/mcp-server/src/provisioning/linux-user.js +62 -0
- package/dist/mcp-server/src/provisioning/linux-user.js.map +1 -0
- package/dist/mcp-server/src/provisioning/privileged.d.ts +40 -0
- package/dist/mcp-server/src/provisioning/privileged.d.ts.map +1 -0
- package/dist/mcp-server/src/provisioning/privileged.js +123 -0
- package/dist/mcp-server/src/provisioning/privileged.js.map +1 -0
- package/dist/mcp-server/src/provisioning/ssh-config.d.ts +21 -0
- package/dist/mcp-server/src/provisioning/ssh-config.d.ts.map +1 -0
- package/dist/mcp-server/src/provisioning/ssh-config.js +161 -0
- package/dist/mcp-server/src/provisioning/ssh-config.js.map +1 -0
- package/dist/mcp-server/src/provisioning/tmux-session.d.ts +37 -0
- package/dist/mcp-server/src/provisioning/tmux-session.d.ts.map +1 -0
- package/dist/mcp-server/src/provisioning/tmux-session.js +123 -0
- package/dist/mcp-server/src/provisioning/tmux-session.js.map +1 -0
- package/dist/mcp-server/src/server.d.ts +3 -0
- package/dist/mcp-server/src/server.d.ts.map +1 -0
- package/dist/mcp-server/src/server.js +67 -0
- package/dist/mcp-server/src/server.js.map +1 -0
- package/dist/mcp-server/src/state/gig-store.d.ts +19 -0
- package/dist/mcp-server/src/state/gig-store.d.ts.map +1 -0
- package/dist/mcp-server/src/state/gig-store.js +52 -0
- package/dist/mcp-server/src/state/gig-store.js.map +1 -0
- package/dist/mcp-server/src/state/index.d.ts +4 -0
- package/dist/mcp-server/src/state/index.d.ts.map +1 -0
- package/dist/mcp-server/src/state/index.js +8 -0
- package/dist/mcp-server/src/state/index.js.map +1 -0
- package/dist/mcp-server/src/state/persistence.d.ts +13 -0
- package/dist/mcp-server/src/state/persistence.d.ts.map +1 -0
- package/dist/mcp-server/src/state/persistence.js +48 -0
- package/dist/mcp-server/src/state/persistence.js.map +1 -0
- package/dist/mcp-server/src/state/types.d.ts +15 -0
- package/dist/mcp-server/src/state/types.d.ts.map +1 -0
- package/dist/mcp-server/src/state/types.js +3 -0
- package/dist/mcp-server/src/state/types.js.map +1 -0
- package/dist/mcp-server/src/tools/dismiss-human.d.ts +25 -0
- package/dist/mcp-server/src/tools/dismiss-human.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/dismiss-human.js +78 -0
- package/dist/mcp-server/src/tools/dismiss-human.js.map +1 -0
- package/dist/mcp-server/src/tools/index.d.ts +9 -0
- package/dist/mcp-server/src/tools/index.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/index.js +20 -0
- package/dist/mcp-server/src/tools/index.js.map +1 -0
- package/dist/mcp-server/src/tools/list-humans.d.ts +18 -0
- package/dist/mcp-server/src/tools/list-humans.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/list-humans.js +35 -0
- package/dist/mcp-server/src/tools/list-humans.js.map +1 -0
- package/dist/mcp-server/src/tools/message-human.d.ts +10 -0
- package/dist/mcp-server/src/tools/message-human.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/message-human.js +19 -0
- package/dist/mcp-server/src/tools/message-human.js.map +1 -0
- package/dist/mcp-server/src/tools/schemas/dismiss-human.schema.d.ts +19 -0
- package/dist/mcp-server/src/tools/schemas/dismiss-human.schema.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/schemas/dismiss-human.schema.js +22 -0
- package/dist/mcp-server/src/tools/schemas/dismiss-human.schema.js.map +1 -0
- package/dist/mcp-server/src/tools/schemas/list-humans.schema.d.ts +4 -0
- package/dist/mcp-server/src/tools/schemas/list-humans.schema.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/schemas/list-humans.schema.js +7 -0
- package/dist/mcp-server/src/tools/schemas/list-humans.schema.js.map +1 -0
- package/dist/mcp-server/src/tools/schemas/message-human.schema.d.ts +13 -0
- package/dist/mcp-server/src/tools/schemas/message-human.schema.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/schemas/message-human.schema.js +9 -0
- package/dist/mcp-server/src/tools/schemas/message-human.schema.js.map +1 -0
- package/dist/mcp-server/src/tools/schemas/summon-human.schema.d.ts +22 -0
- package/dist/mcp-server/src/tools/schemas/summon-human.schema.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/schemas/summon-human.schema.js +18 -0
- package/dist/mcp-server/src/tools/schemas/summon-human.schema.js.map +1 -0
- package/dist/mcp-server/src/tools/summon-human.d.ts +31 -0
- package/dist/mcp-server/src/tools/summon-human.d.ts.map +1 -0
- package/dist/mcp-server/src/tools/summon-human.js +137 -0
- package/dist/mcp-server/src/tools/summon-human.js.map +1 -0
- package/dist/mcp-server/src/tunnel/client.d.ts +16 -0
- package/dist/mcp-server/src/tunnel/client.d.ts.map +1 -0
- package/dist/mcp-server/src/tunnel/client.js +100 -0
- package/dist/mcp-server/src/tunnel/client.js.map +1 -0
- package/dist/mcp-server/src/tunnel/index.d.ts +6 -0
- package/dist/mcp-server/src/tunnel/index.d.ts.map +1 -0
- package/dist/mcp-server/src/tunnel/index.js +28 -0
- package/dist/mcp-server/src/tunnel/index.js.map +1 -0
- package/dist/mcp-server/src/utils/errors.d.ts +28 -0
- package/dist/mcp-server/src/utils/errors.d.ts.map +1 -0
- package/dist/mcp-server/src/utils/errors.js +66 -0
- package/dist/mcp-server/src/utils/errors.js.map +1 -0
- package/dist/mcp-server/src/utils/exec.d.ts +7 -0
- package/dist/mcp-server/src/utils/exec.d.ts.map +1 -0
- package/dist/mcp-server/src/utils/exec.js +22 -0
- package/dist/mcp-server/src/utils/exec.js.map +1 -0
- package/dist/mcp-server/src/utils/ip.d.ts +6 -0
- package/dist/mcp-server/src/utils/ip.d.ts.map +1 -0
- package/dist/mcp-server/src/utils/ip.js +33 -0
- package/dist/mcp-server/src/utils/ip.js.map +1 -0
- package/dist/mcp-server/src/utils/logger.d.ts +20 -0
- package/dist/mcp-server/src/utils/logger.d.ts.map +1 -0
- package/dist/mcp-server/src/utils/logger.js +41 -0
- package/dist/mcp-server/src/utils/logger.js.map +1 -0
- package/dist/shared/src/contractor.types.d.ts +20 -0
- package/dist/shared/src/contractor.types.d.ts.map +1 -0
- package/dist/shared/src/contractor.types.js +3 -0
- package/dist/shared/src/contractor.types.js.map +1 -0
- package/dist/shared/src/gig.types.d.ts +32 -0
- package/dist/shared/src/gig.types.d.ts.map +1 -0
- package/dist/shared/src/gig.types.js +21 -0
- package/dist/shared/src/gig.types.js.map +1 -0
- package/dist/shared/src/index.d.ts +5 -0
- package/dist/shared/src/index.d.ts.map +1 -0
- package/dist/shared/src/index.js +21 -0
- package/dist/shared/src/index.js.map +1 -0
- package/dist/shared/src/mcp-tool.types.d.ts +45 -0
- package/dist/shared/src/mcp-tool.types.d.ts.map +1 -0
- package/dist/shared/src/mcp-tool.types.js +3 -0
- package/dist/shared/src/mcp-tool.types.js.map +1 -0
- package/dist/shared/src/platform-api.types.d.ts +73 -0
- package/dist/shared/src/platform-api.types.d.ts.map +1 -0
- package/dist/shared/src/platform-api.types.js +3 -0
- package/dist/shared/src/platform-api.types.js.map +1 -0
- package/package.json +41 -0
- package/src/bin.ts +7 -0
- package/src/cli/setup.ts +317 -0
- package/src/config/defaults.ts +21 -0
- package/src/config/env.ts +74 -0
- package/src/config/index.ts +2 -0
- package/src/context/generator.ts +71 -0
- package/src/context/index.ts +6 -0
- package/src/context/templates.ts +41 -0
- package/src/git/branch.ts +46 -0
- package/src/git/diff.ts +34 -0
- package/src/git/index.ts +4 -0
- package/src/git/merge.ts +36 -0
- package/src/git/worktree.ts +42 -0
- package/src/http-wrapper.ts +94 -0
- package/src/index.ts +32 -0
- package/src/platform-client/client.ts +93 -0
- package/src/platform-client/index.ts +4 -0
- package/src/platform-client/mock-client.ts +92 -0
- package/src/platform-client/polling.ts +53 -0
- package/src/platform-client/types.ts +9 -0
- package/src/provisioning/authorized-keys.ts +52 -0
- package/src/provisioning/cleanup.ts +106 -0
- package/src/provisioning/index.ts +13 -0
- package/src/provisioning/linux-user.ts +66 -0
- package/src/provisioning/privileged.ts +128 -0
- package/src/provisioning/ssh-config.ts +197 -0
- package/src/provisioning/tmux-session.ts +136 -0
- package/src/server.ts +111 -0
- package/src/state/gig-store.ts +56 -0
- package/src/state/index.ts +3 -0
- package/src/state/persistence.ts +42 -0
- package/src/state/types.ts +14 -0
- package/src/tools/dismiss-human.ts +103 -0
- package/src/tools/index.ts +9 -0
- package/src/tools/list-humans.ts +54 -0
- package/src/tools/message-human.ts +28 -0
- package/src/tools/schemas/dismiss-human.schema.ts +21 -0
- package/src/tools/schemas/list-humans.schema.ts +6 -0
- package/src/tools/schemas/message-human.schema.ts +8 -0
- package/src/tools/schemas/summon-human.schema.ts +19 -0
- package/src/tools/summon-human.ts +180 -0
- package/src/tunnel/client.ts +116 -0
- package/src/tunnel/index.ts +26 -0
- package/src/utils/errors.ts +64 -0
- package/src/utils/exec.ts +29 -0
- package/src/utils/ip.ts +31 -0
- package/src/utils/logger.ts +55 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { writeFile as fsWriteFile, copyFile as fsCopyFile, readFile as fsReadFile } from "node:fs/promises";
|
|
2
|
+
import { exec } from "../utils/exec.js";
|
|
3
|
+
import { getEnv } from "../config/env.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Privileged operation helpers.
|
|
7
|
+
*
|
|
8
|
+
* Two modes, controlled by `PROVISIONING_USE_SUDO`:
|
|
9
|
+
*
|
|
10
|
+
* - `false` (default): the MCP server is running as root (e.g. `sudo
|
|
11
|
+
* claude` or a systemd service running as root). All commands and
|
|
12
|
+
* file operations execute directly through Node fs / child_process.
|
|
13
|
+
*
|
|
14
|
+
* - `true`: the MCP server is running as an unprivileged user that
|
|
15
|
+
* has NOPASSWD sudo for the specific commands listed in
|
|
16
|
+
* `human-layer/mcp-server/README.md`. All commands are prefixed
|
|
17
|
+
* with `sudo -n`, and file operations go through `sudo tee` /
|
|
18
|
+
* `sudo cp` so writes work even when the target file is owned by
|
|
19
|
+
* root.
|
|
20
|
+
*
|
|
21
|
+
* Centralising this in one module means the provisioning code stays
|
|
22
|
+
* readable and the sudo decision lives in exactly one place.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function useSudo(): boolean {
|
|
26
|
+
return getEnv().PROVISIONING_USE_SUDO;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run a privileged shell command. With sudo enabled, the command
|
|
31
|
+
* is wrapped in `sudo -n bash -c '...'` so multi-step pipelines
|
|
32
|
+
* (`mkdir && chmod && chown`) still execute as root in one go.
|
|
33
|
+
*/
|
|
34
|
+
export async function runPrivileged(command: string): Promise<void> {
|
|
35
|
+
if (useSudo()) {
|
|
36
|
+
await exec(`sudo -n bash -c ${shQuote(command)}`);
|
|
37
|
+
} else {
|
|
38
|
+
await exec(command);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run a shell command as a specific (non-root) user. Used for things
|
|
44
|
+
* like `tmux new-session` where the resulting object must be owned by
|
|
45
|
+
* the contractor, not root, so that the contractor's SSH login can
|
|
46
|
+
* attach to it.
|
|
47
|
+
*
|
|
48
|
+
* `sudo -u user` works whether the caller is root (no sudoers needed)
|
|
49
|
+
* or an unprivileged user with NOPASSWD configured for that target.
|
|
50
|
+
*/
|
|
51
|
+
export async function runAsUser(
|
|
52
|
+
user: string,
|
|
53
|
+
command: string,
|
|
54
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
55
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(user)) {
|
|
56
|
+
throw new Error(`Invalid user name: ${user}`);
|
|
57
|
+
}
|
|
58
|
+
// We always go through sudo here. When running as root, no
|
|
59
|
+
// password / NOPASSWD is needed. When running as a regular user,
|
|
60
|
+
// they need NOPASSWD for `sudo -u <target>` (covered by the
|
|
61
|
+
// sudoers template in human-layer/mcp-server/README.md).
|
|
62
|
+
return exec(`sudo -n -u ${user} bash -c ${shQuote(command)}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read a file. Reads always go through Node fs — `/etc/ssh/sshd_config`
|
|
67
|
+
* is world-readable by default, so this rarely needs sudo. If a future
|
|
68
|
+
* deployment locks it down, we can revisit.
|
|
69
|
+
*/
|
|
70
|
+
export async function readPrivilegedFile(path: string): Promise<string> {
|
|
71
|
+
return fsReadFile(path, "utf-8");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write a file as root, regardless of which mode we're in.
|
|
76
|
+
* In sudo mode, content is piped through `sudo tee` and chmod runs
|
|
77
|
+
* separately.
|
|
78
|
+
*/
|
|
79
|
+
export async function writePrivilegedFile(
|
|
80
|
+
path: string,
|
|
81
|
+
content: string,
|
|
82
|
+
mode?: number,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
if (useSudo()) {
|
|
85
|
+
// `tee` writes the file as root; we pipe the content via stdin
|
|
86
|
+
// by base64-encoding it to dodge any quoting hazards.
|
|
87
|
+
const b64 = Buffer.from(content, "utf-8").toString("base64");
|
|
88
|
+
await exec(
|
|
89
|
+
`bash -c ${shQuote(`echo ${b64} | base64 -d | sudo -n tee ${shQuote(path)} > /dev/null`)}`,
|
|
90
|
+
);
|
|
91
|
+
if (typeof mode === "number") {
|
|
92
|
+
await exec(`sudo -n chmod ${mode.toString(8)} ${shQuote(path)}`);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
await fsWriteFile(path, content, mode != null ? { encoding: "utf-8", mode } : { encoding: "utf-8" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Copy a file as root. Used by the sshd_config backup/restore flow.
|
|
101
|
+
*/
|
|
102
|
+
export async function copyPrivilegedFile(src: string, dst: string): Promise<void> {
|
|
103
|
+
if (useSudo()) {
|
|
104
|
+
await exec(`sudo -n cp ${shQuote(src)} ${shQuote(dst)}`);
|
|
105
|
+
} else {
|
|
106
|
+
await fsCopyFile(src, dst);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `chown user:group path` as root.
|
|
112
|
+
*/
|
|
113
|
+
export async function chownPrivileged(owner: string, path: string): Promise<void> {
|
|
114
|
+
// shellEscape-style guard to avoid injection through `owner`
|
|
115
|
+
if (!/^[a-zA-Z0-9_.:-]+$/.test(owner)) {
|
|
116
|
+
throw new Error(`Invalid owner spec: ${owner}`);
|
|
117
|
+
}
|
|
118
|
+
if (useSudo()) {
|
|
119
|
+
await exec(`sudo -n chown ${owner} ${shQuote(path)}`);
|
|
120
|
+
} else {
|
|
121
|
+
await exec(`chown ${owner} ${shQuote(path)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Quote a value for safe inclusion in a single-quoted bash string. */
|
|
126
|
+
function shQuote(value: string): string {
|
|
127
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readPrivilegedFile,
|
|
3
|
+
writePrivilegedFile,
|
|
4
|
+
copyPrivilegedFile,
|
|
5
|
+
runPrivileged,
|
|
6
|
+
} from "./privileged.js";
|
|
7
|
+
import { SshConfigError } from "../utils/errors.js";
|
|
8
|
+
import { logger } from "../utils/logger.js";
|
|
9
|
+
import {
|
|
10
|
+
SSHD_CONFIG_PATH,
|
|
11
|
+
SSHD_MARKER_PREFIX,
|
|
12
|
+
SSHD_MARKER_SUFFIX,
|
|
13
|
+
} from "../config/defaults.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Add a ForceCommand block to sshd_config for a contractor.
|
|
17
|
+
*
|
|
18
|
+
* Uses marker comments for deterministic insertion/removal:
|
|
19
|
+
* # BEGIN human-layer:{username}
|
|
20
|
+
* Match User {username}
|
|
21
|
+
* ForceCommand tmux attach -t {session} || tmux new -s {session}
|
|
22
|
+
* AllowTcpForwarding no
|
|
23
|
+
* X11Forwarding no
|
|
24
|
+
* # END human-layer:{username}
|
|
25
|
+
*
|
|
26
|
+
* CRITICAL: This is the highest-risk module in the system.
|
|
27
|
+
* Incorrect sshd_config means locked-out VMs.
|
|
28
|
+
* Always backup, validate with sshd -t, then reload.
|
|
29
|
+
*/
|
|
30
|
+
export async function addForceCommand(
|
|
31
|
+
username: string,
|
|
32
|
+
tmuxSession: string,
|
|
33
|
+
configPath = SSHD_CONFIG_PATH,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
const beginMarker = `${SSHD_MARKER_PREFIX}${username}`;
|
|
36
|
+
const endMarker = `${SSHD_MARKER_SUFFIX}${username}`;
|
|
37
|
+
|
|
38
|
+
const block = [
|
|
39
|
+
beginMarker,
|
|
40
|
+
`Match User ${username}`,
|
|
41
|
+
` ForceCommand tmux attach -t ${tmuxSession} || tmux new -s ${tmuxSession}`,
|
|
42
|
+
" AllowTcpForwarding no",
|
|
43
|
+
" X11Forwarding no",
|
|
44
|
+
endMarker,
|
|
45
|
+
].join("\n");
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Backup current config
|
|
49
|
+
await copyPrivilegedFile(configPath, `${configPath}.bak`);
|
|
50
|
+
|
|
51
|
+
// Read current config
|
|
52
|
+
const current = await readPrivilegedFile(configPath);
|
|
53
|
+
|
|
54
|
+
// Remove existing block if present (idempotent)
|
|
55
|
+
const cleaned = removeBlock(current, beginMarker, endMarker);
|
|
56
|
+
|
|
57
|
+
// Append new block
|
|
58
|
+
const updated = cleaned.trimEnd() + "\n\n" + block + "\n";
|
|
59
|
+
|
|
60
|
+
// Write updated config
|
|
61
|
+
await writePrivilegedFile(configPath, updated);
|
|
62
|
+
|
|
63
|
+
// Validate with sshd -t
|
|
64
|
+
await validateSshdConfig(configPath);
|
|
65
|
+
|
|
66
|
+
// Reload sshd
|
|
67
|
+
await reloadSshd();
|
|
68
|
+
|
|
69
|
+
logger.info("ForceCommand added to sshd_config", {
|
|
70
|
+
user: username,
|
|
71
|
+
session: tmuxSession,
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Attempt to restore backup
|
|
75
|
+
try {
|
|
76
|
+
await copyPrivilegedFile(`${configPath}.bak`, configPath);
|
|
77
|
+
await reloadSshd();
|
|
78
|
+
logger.warn("Restored sshd_config from backup after error");
|
|
79
|
+
} catch (restoreError) {
|
|
80
|
+
logger.error("CRITICAL: Failed to restore sshd_config backup", {
|
|
81
|
+
error: String(restoreError),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new SshConfigError(
|
|
86
|
+
`Failed to add ForceCommand for ${username}: ${error}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Remove the ForceCommand block for a contractor from sshd_config.
|
|
93
|
+
*/
|
|
94
|
+
export async function removeForceCommand(
|
|
95
|
+
username: string,
|
|
96
|
+
configPath = SSHD_CONFIG_PATH,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const beginMarker = `${SSHD_MARKER_PREFIX}${username}`;
|
|
99
|
+
const endMarker = `${SSHD_MARKER_SUFFIX}${username}`;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await copyPrivilegedFile(configPath, `${configPath}.bak`);
|
|
103
|
+
|
|
104
|
+
const current = await readPrivilegedFile(configPath);
|
|
105
|
+
const cleaned = removeBlock(current, beginMarker, endMarker);
|
|
106
|
+
|
|
107
|
+
await writePrivilegedFile(configPath, cleaned);
|
|
108
|
+
await validateSshdConfig(configPath);
|
|
109
|
+
await reloadSshd();
|
|
110
|
+
|
|
111
|
+
logger.info("ForceCommand removed from sshd_config", {
|
|
112
|
+
user: username,
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
try {
|
|
116
|
+
await copyPrivilegedFile(`${configPath}.bak`, configPath);
|
|
117
|
+
await reloadSshd();
|
|
118
|
+
} catch {
|
|
119
|
+
logger.error("CRITICAL: Failed to restore sshd_config backup");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
throw new SshConfigError(
|
|
123
|
+
`Failed to remove ForceCommand for ${username}: ${error}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove a marker-delimited block from config text.
|
|
130
|
+
*/
|
|
131
|
+
function removeBlock(
|
|
132
|
+
content: string,
|
|
133
|
+
beginMarker: string,
|
|
134
|
+
endMarker: string,
|
|
135
|
+
): string {
|
|
136
|
+
const lines = content.split("\n");
|
|
137
|
+
const result: string[] = [];
|
|
138
|
+
let inBlock = false;
|
|
139
|
+
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
if (line.trim() === beginMarker) {
|
|
142
|
+
inBlock = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (line.trim() === endMarker) {
|
|
146
|
+
inBlock = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!inBlock) {
|
|
150
|
+
result.push(line);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return result.join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate sshd_config syntax. Throws if invalid.
|
|
159
|
+
*/
|
|
160
|
+
async function validateSshdConfig(
|
|
161
|
+
configPath = SSHD_CONFIG_PATH,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
try {
|
|
164
|
+
await runPrivileged(`sshd -t -f ${configPath}`);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new SshConfigError(`sshd_config validation failed: ${error}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reload sshd to apply config changes.
|
|
172
|
+
*
|
|
173
|
+
* Tries `systemctl reload` first (the systemd path) and falls back to
|
|
174
|
+
* `service ssh reload` for environments without systemd (some WSL2
|
|
175
|
+
* distros, minimal containers).
|
|
176
|
+
*/
|
|
177
|
+
async function reloadSshd(): Promise<void> {
|
|
178
|
+
try {
|
|
179
|
+
await runPrivileged("systemctl reload sshd");
|
|
180
|
+
return;
|
|
181
|
+
} catch {
|
|
182
|
+
/* fall through */
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await runPrivileged("systemctl reload ssh");
|
|
186
|
+
return;
|
|
187
|
+
} catch {
|
|
188
|
+
/* fall through */
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await runPrivileged("service ssh reload");
|
|
192
|
+
return;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new SshConfigError(`Failed to reload sshd: ${error}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { exec } from "../utils/exec.js";
|
|
2
|
+
import { runAsUser } from "./privileged.js";
|
|
3
|
+
import { TmuxError } from "../utils/errors.js";
|
|
4
|
+
import { logger } from "../utils/logger.js";
|
|
5
|
+
import { TMUX_SESSION_PREFIX } from "../config/defaults.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a tmux session name for a gig.
|
|
9
|
+
*/
|
|
10
|
+
export function sessionName(gigId: string): string {
|
|
11
|
+
return `${TMUX_SESSION_PREFIX}${gigId}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a new tmux session **owned by the contractor user** so that
|
|
16
|
+
* sshd's ForceCommand (`tmux attach -t ...`) running as that user can
|
|
17
|
+
* see and attach to it.
|
|
18
|
+
*
|
|
19
|
+
* Without `runAsUser`, a tmux session created by the MCP server (root)
|
|
20
|
+
* is owned by root, and the contractor's later attach silently fails
|
|
21
|
+
* because tmux scopes sessions per-uid via the socket directory.
|
|
22
|
+
*/
|
|
23
|
+
export async function createSession(
|
|
24
|
+
session: string,
|
|
25
|
+
workdir: string,
|
|
26
|
+
user?: string,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const cmd = `tmux new-session -d -s ${session} -c ${workdir}`;
|
|
29
|
+
try {
|
|
30
|
+
if (user) {
|
|
31
|
+
await runAsUser(user, cmd);
|
|
32
|
+
} else {
|
|
33
|
+
await exec(cmd);
|
|
34
|
+
}
|
|
35
|
+
logger.info("tmux session created", { session, workdir, user });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new TmuxError(`Failed to create tmux session ${session}: ${error}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if a tmux session has any clients attached.
|
|
43
|
+
* Used to detect if a contractor is currently connected.
|
|
44
|
+
*/
|
|
45
|
+
export async function hasClientsAttached(session: string): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
const { stdout } = await exec(`tmux list-clients -t ${session}`);
|
|
48
|
+
return stdout.trim().length > 0;
|
|
49
|
+
} catch {
|
|
50
|
+
// Session might not exist or have no clients
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if a tmux session exists.
|
|
57
|
+
*/
|
|
58
|
+
export async function sessionExists(session: string): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
await exec(`tmux has-session -t ${session}`);
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Send a display message to a tmux session.
|
|
69
|
+
* The message is shown as an overlay for the specified duration.
|
|
70
|
+
*/
|
|
71
|
+
export async function displayMessage(
|
|
72
|
+
session: string,
|
|
73
|
+
message: string,
|
|
74
|
+
durationMs = 10_000,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
try {
|
|
77
|
+
// Escape single quotes in message
|
|
78
|
+
const escaped = message.replace(/'/g, "'\\''");
|
|
79
|
+
await exec(
|
|
80
|
+
`tmux display-message -t ${session} -d ${durationMs} '${escaped}'`,
|
|
81
|
+
);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
logger.warn("Failed to display message in tmux session", { session });
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Kill a tmux session, optionally showing a warning first.
|
|
91
|
+
*/
|
|
92
|
+
export async function killSession(
|
|
93
|
+
session: string,
|
|
94
|
+
warningSeconds = 0,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
try {
|
|
97
|
+
if (warningSeconds > 0 && (await hasClientsAttached(session))) {
|
|
98
|
+
await displayMessage(
|
|
99
|
+
session,
|
|
100
|
+
`⚠ Session ending in ${warningSeconds} seconds`,
|
|
101
|
+
warningSeconds * 1000,
|
|
102
|
+
);
|
|
103
|
+
await sleep(warningSeconds * 1000);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await exec(`tmux kill-session -t ${session}`);
|
|
107
|
+
logger.info("tmux session killed", { session });
|
|
108
|
+
} catch {
|
|
109
|
+
// Session might already be gone
|
|
110
|
+
logger.debug("tmux session already gone", { session });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get the last activity time for a session (if detectable).
|
|
116
|
+
*/
|
|
117
|
+
export async function getLastActivity(
|
|
118
|
+
session: string,
|
|
119
|
+
): Promise<Date | undefined> {
|
|
120
|
+
try {
|
|
121
|
+
const { stdout } = await exec(
|
|
122
|
+
`tmux display-message -t ${session} -p '#{session_activity}'`,
|
|
123
|
+
);
|
|
124
|
+
const timestamp = parseInt(stdout.trim(), 10);
|
|
125
|
+
if (!isNaN(timestamp)) {
|
|
126
|
+
return new Date(timestamp * 1000);
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Can't determine activity
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sleep(ms: number): Promise<void> {
|
|
135
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
136
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { getEnv } from "./config/env.js";
|
|
3
|
+
import { GigStore } from "./state/gig-store.js";
|
|
4
|
+
import { StatePersistence } from "./state/persistence.js";
|
|
5
|
+
import { HttpPlatformClient } from "./platform-client/client.js";
|
|
6
|
+
import { MockPlatformClient } from "./platform-client/mock-client.js";
|
|
7
|
+
import type { PlatformClient } from "./platform-client/client.js";
|
|
8
|
+
import { logger } from "./utils/logger.js";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
summonHuman,
|
|
12
|
+
dismissHuman,
|
|
13
|
+
listHumans,
|
|
14
|
+
messageHuman,
|
|
15
|
+
SummonHumanInputSchema,
|
|
16
|
+
DismissHumanInputSchema,
|
|
17
|
+
ListHumansInputSchema,
|
|
18
|
+
MessageHumanInputSchema,
|
|
19
|
+
} from "./tools/index.js";
|
|
20
|
+
|
|
21
|
+
export async function createServer(): Promise<McpServer> {
|
|
22
|
+
const env = getEnv();
|
|
23
|
+
|
|
24
|
+
// Initialize dependencies
|
|
25
|
+
const gigStore = new GigStore();
|
|
26
|
+
const persistence = new StatePersistence(env.STATE_FILE_PATH);
|
|
27
|
+
|
|
28
|
+
// Restore state from disk (crash recovery)
|
|
29
|
+
await persistence.load(gigStore);
|
|
30
|
+
|
|
31
|
+
// Choose platform client
|
|
32
|
+
const platformClient: PlatformClient = env.USE_MOCK_PLATFORM
|
|
33
|
+
? new MockPlatformClient()
|
|
34
|
+
: new HttpPlatformClient(env.PLATFORM_API_URL, env.PLATFORM_API_KEY);
|
|
35
|
+
|
|
36
|
+
if (env.USE_MOCK_PLATFORM) {
|
|
37
|
+
logger.info("Using mock platform client");
|
|
38
|
+
} else {
|
|
39
|
+
logger.info("Using HTTP platform client", {
|
|
40
|
+
url: env.PLATFORM_API_URL,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const deps = { platformClient, gigStore };
|
|
45
|
+
|
|
46
|
+
// Create MCP server
|
|
47
|
+
const server = new McpServer({
|
|
48
|
+
name: "human-agents",
|
|
49
|
+
version: "0.1.0",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Register tools
|
|
53
|
+
server.tool(
|
|
54
|
+
"summon_human",
|
|
55
|
+
"Summon a human contractor to help with a task. They will SSH into the VM and work in a tmux session.",
|
|
56
|
+
SummonHumanInputSchema.shape,
|
|
57
|
+
async ({ reason, skills, context, urgency, worktree }) => {
|
|
58
|
+
const result = await summonHuman(
|
|
59
|
+
{ reason, skills, context, urgency, worktree },
|
|
60
|
+
deps,
|
|
61
|
+
);
|
|
62
|
+
await persistence.save(gigStore);
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
server.tool(
|
|
70
|
+
"dismiss_human",
|
|
71
|
+
"Dismiss a human contractor, revoke their access, and optionally merge their changes.",
|
|
72
|
+
DismissHumanInputSchema.shape,
|
|
73
|
+
async ({ gigId, merge, rating, resolutionNotes }) => {
|
|
74
|
+
const result = await dismissHuman(
|
|
75
|
+
{ gigId, merge, rating, resolutionNotes },
|
|
76
|
+
deps,
|
|
77
|
+
);
|
|
78
|
+
await persistence.save(gigStore);
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
server.tool(
|
|
86
|
+
"list_humans",
|
|
87
|
+
"List all currently active human contractors on this VM.",
|
|
88
|
+
ListHumansInputSchema.shape,
|
|
89
|
+
async () => {
|
|
90
|
+
const result = await listHumans(gigStore);
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
server.tool(
|
|
98
|
+
"message_human",
|
|
99
|
+
"Send a message to a human contractor's tmux session.",
|
|
100
|
+
MessageHumanInputSchema.shape,
|
|
101
|
+
async ({ gigId, message }) => {
|
|
102
|
+
const result = await messageHuman({ gigId, message }, gigStore);
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
logger.info("MCP server created with 4 tools registered");
|
|
110
|
+
return server;
|
|
111
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { GigNotFoundError } from "../utils/errors.js";
|
|
2
|
+
import { logger } from "../utils/logger.js";
|
|
3
|
+
import type { ActiveGig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory store of active gigs on this VM.
|
|
7
|
+
* Backed by optional file persistence for crash recovery.
|
|
8
|
+
*/
|
|
9
|
+
export class GigStore {
|
|
10
|
+
private gigs = new Map<string, ActiveGig>();
|
|
11
|
+
|
|
12
|
+
add(gig: ActiveGig): void {
|
|
13
|
+
this.gigs.set(gig.gigId, gig);
|
|
14
|
+
logger.info("Gig added to store", {
|
|
15
|
+
gigId: gig.gigId,
|
|
16
|
+
contractorName: gig.contractorName,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get(gigId: string): ActiveGig {
|
|
21
|
+
const gig = this.gigs.get(gigId);
|
|
22
|
+
if (!gig) throw new GigNotFoundError(gigId);
|
|
23
|
+
return gig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
tryGet(gigId: string): ActiveGig | undefined {
|
|
27
|
+
return this.gigs.get(gigId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
remove(gigId: string): void {
|
|
31
|
+
this.gigs.delete(gigId);
|
|
32
|
+
logger.info("Gig removed from store", { gigId });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
list(): ActiveGig[] {
|
|
36
|
+
return Array.from(this.gigs.values());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
has(gigId: string): boolean {
|
|
40
|
+
return this.gigs.has(gigId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Serialize all active gigs for crash recovery. */
|
|
44
|
+
serialize(): string {
|
|
45
|
+
return JSON.stringify(Array.from(this.gigs.values()), null, 2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Restore gigs from serialized data. */
|
|
49
|
+
restore(data: string): void {
|
|
50
|
+
const gigs: ActiveGig[] = JSON.parse(data);
|
|
51
|
+
for (const gig of gigs) {
|
|
52
|
+
this.gigs.set(gig.gigId, gig);
|
|
53
|
+
}
|
|
54
|
+
logger.info(`Restored ${gigs.length} gigs from persistence`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
import type { GigStore } from "./gig-store.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Persist gig store to disk for crash recovery.
|
|
8
|
+
* On startup, reads the file and restores any active gigs
|
|
9
|
+
* that need cleanup.
|
|
10
|
+
*/
|
|
11
|
+
export class StatePersistence {
|
|
12
|
+
constructor(private readonly filePath: string) {}
|
|
13
|
+
|
|
14
|
+
async save(store: GigStore): Promise<void> {
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
17
|
+
await writeFile(this.filePath, store.serialize(), "utf-8");
|
|
18
|
+
logger.debug("State persisted to disk", { path: this.filePath });
|
|
19
|
+
} catch (error) {
|
|
20
|
+
logger.warn("Failed to persist state", {
|
|
21
|
+
path: this.filePath,
|
|
22
|
+
error: String(error),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async load(store: GigStore): Promise<void> {
|
|
28
|
+
try {
|
|
29
|
+
const data = await readFile(this.filePath, "utf-8");
|
|
30
|
+
store.restore(data);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
33
|
+
logger.debug("No state file found, starting fresh");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
logger.warn("Failed to load state", {
|
|
37
|
+
path: this.filePath,
|
|
38
|
+
error: String(error),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ActiveGig {
|
|
2
|
+
gigId: string;
|
|
3
|
+
contractorName: string;
|
|
4
|
+
linuxUser: string;
|
|
5
|
+
tmuxSession: string;
|
|
6
|
+
worktreePath?: string;
|
|
7
|
+
sshCommand: string;
|
|
8
|
+
skills: string[];
|
|
9
|
+
rate: number;
|
|
10
|
+
startedAt: string;
|
|
11
|
+
status: "provisioning" | "active" | "dismissing";
|
|
12
|
+
/** Whether this gig uses a reverse tunnel instead of direct SSH. */
|
|
13
|
+
tunnelActive?: boolean;
|
|
14
|
+
}
|