@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.
Files changed (259) hide show
  1. package/.env.example +15 -0
  2. package/README.md +178 -0
  3. package/dist/mcp-server/src/bin.d.ts +3 -0
  4. package/dist/mcp-server/src/bin.d.ts.map +1 -0
  5. package/dist/mcp-server/src/bin.js +10 -0
  6. package/dist/mcp-server/src/bin.js.map +1 -0
  7. package/dist/mcp-server/src/cli/setup.d.ts +2 -0
  8. package/dist/mcp-server/src/cli/setup.d.ts.map +1 -0
  9. package/dist/mcp-server/src/cli/setup.js +274 -0
  10. package/dist/mcp-server/src/cli/setup.js.map +1 -0
  11. package/dist/mcp-server/src/config/defaults.d.ts +16 -0
  12. package/dist/mcp-server/src/config/defaults.d.ts.map +1 -0
  13. package/dist/mcp-server/src/config/defaults.js +19 -0
  14. package/dist/mcp-server/src/config/defaults.js.map +1 -0
  15. package/dist/mcp-server/src/config/env.d.ts +73 -0
  16. package/dist/mcp-server/src/config/env.d.ts.map +1 -0
  17. package/dist/mcp-server/src/config/env.js +72 -0
  18. package/dist/mcp-server/src/config/env.js.map +1 -0
  19. package/dist/mcp-server/src/config/index.d.ts +3 -0
  20. package/dist/mcp-server/src/config/index.d.ts.map +1 -0
  21. package/dist/mcp-server/src/config/index.js +22 -0
  22. package/dist/mcp-server/src/config/index.js.map +1 -0
  23. package/dist/mcp-server/src/context/generator.d.ts +16 -0
  24. package/dist/mcp-server/src/context/generator.d.ts.map +1 -0
  25. package/dist/mcp-server/src/context/generator.js +61 -0
  26. package/dist/mcp-server/src/context/generator.js.map +1 -0
  27. package/dist/mcp-server/src/context/index.d.ts +3 -0
  28. package/dist/mcp-server/src/context/index.d.ts.map +1 -0
  29. package/dist/mcp-server/src/context/index.js +10 -0
  30. package/dist/mcp-server/src/context/index.js.map +1 -0
  31. package/dist/mcp-server/src/context/templates.d.ts +8 -0
  32. package/dist/mcp-server/src/context/templates.d.ts.map +1 -0
  33. package/dist/mcp-server/src/context/templates.js +41 -0
  34. package/dist/mcp-server/src/context/templates.js.map +1 -0
  35. package/dist/mcp-server/src/git/branch.d.ts +13 -0
  36. package/dist/mcp-server/src/git/branch.d.ts.map +1 -0
  37. package/dist/mcp-server/src/git/branch.js +49 -0
  38. package/dist/mcp-server/src/git/branch.js.map +1 -0
  39. package/dist/mcp-server/src/git/diff.d.ts +10 -0
  40. package/dist/mcp-server/src/git/diff.d.ts.map +1 -0
  41. package/dist/mcp-server/src/git/diff.js +39 -0
  42. package/dist/mcp-server/src/git/diff.js.map +1 -0
  43. package/dist/mcp-server/src/git/index.d.ts +5 -0
  44. package/dist/mcp-server/src/git/index.d.ts.map +1 -0
  45. package/dist/mcp-server/src/git/index.js +16 -0
  46. package/dist/mcp-server/src/git/index.js.map +1 -0
  47. package/dist/mcp-server/src/git/merge.d.ts +6 -0
  48. package/dist/mcp-server/src/git/merge.d.ts.map +1 -0
  49. package/dist/mcp-server/src/git/merge.js +30 -0
  50. package/dist/mcp-server/src/git/merge.js.map +1 -0
  51. package/dist/mcp-server/src/git/worktree.d.ts +11 -0
  52. package/dist/mcp-server/src/git/worktree.d.ts.map +1 -0
  53. package/dist/mcp-server/src/git/worktree.js +38 -0
  54. package/dist/mcp-server/src/git/worktree.js.map +1 -0
  55. package/dist/mcp-server/src/http-wrapper.d.ts +6 -0
  56. package/dist/mcp-server/src/http-wrapper.d.ts.map +1 -0
  57. package/dist/mcp-server/src/http-wrapper.js +85 -0
  58. package/dist/mcp-server/src/http-wrapper.js.map +1 -0
  59. package/dist/mcp-server/src/index.d.ts +2 -0
  60. package/dist/mcp-server/src/index.d.ts.map +1 -0
  61. package/dist/mcp-server/src/index.js +28 -0
  62. package/dist/mcp-server/src/index.js.map +1 -0
  63. package/dist/mcp-server/src/platform-client/client.d.ts +17 -0
  64. package/dist/mcp-server/src/platform-client/client.d.ts.map +1 -0
  65. package/dist/mcp-server/src/platform-client/client.js +68 -0
  66. package/dist/mcp-server/src/platform-client/client.js.map +1 -0
  67. package/dist/mcp-server/src/platform-client/index.d.ts +5 -0
  68. package/dist/mcp-server/src/platform-client/index.d.ts.map +1 -0
  69. package/dist/mcp-server/src/platform-client/index.js +10 -0
  70. package/dist/mcp-server/src/platform-client/index.js.map +1 -0
  71. package/dist/mcp-server/src/platform-client/mock-client.d.ts +28 -0
  72. package/dist/mcp-server/src/platform-client/mock-client.d.ts.map +1 -0
  73. package/dist/mcp-server/src/platform-client/mock-client.js +75 -0
  74. package/dist/mcp-server/src/platform-client/mock-client.js.map +1 -0
  75. package/dist/mcp-server/src/platform-client/polling.d.ts +9 -0
  76. package/dist/mcp-server/src/platform-client/polling.d.ts.map +1 -0
  77. package/dist/mcp-server/src/platform-client/polling.js +40 -0
  78. package/dist/mcp-server/src/platform-client/polling.js.map +1 -0
  79. package/dist/mcp-server/src/platform-client/types.d.ts +2 -0
  80. package/dist/mcp-server/src/platform-client/types.d.ts.map +1 -0
  81. package/dist/mcp-server/src/platform-client/types.js +3 -0
  82. package/dist/mcp-server/src/platform-client/types.js.map +1 -0
  83. package/dist/mcp-server/src/provisioning/authorized-keys.d.ts +14 -0
  84. package/dist/mcp-server/src/provisioning/authorized-keys.d.ts.map +1 -0
  85. package/dist/mcp-server/src/provisioning/authorized-keys.js +48 -0
  86. package/dist/mcp-server/src/provisioning/authorized-keys.js.map +1 -0
  87. package/dist/mcp-server/src/provisioning/cleanup.d.ts +19 -0
  88. package/dist/mcp-server/src/provisioning/cleanup.d.ts.map +1 -0
  89. package/dist/mcp-server/src/provisioning/cleanup.js +96 -0
  90. package/dist/mcp-server/src/provisioning/cleanup.js.map +1 -0
  91. package/dist/mcp-server/src/provisioning/index.d.ts +6 -0
  92. package/dist/mcp-server/src/provisioning/index.d.ts.map +1 -0
  93. package/dist/mcp-server/src/provisioning/index.js +24 -0
  94. package/dist/mcp-server/src/provisioning/index.js.map +1 -0
  95. package/dist/mcp-server/src/provisioning/linux-user.d.ts +15 -0
  96. package/dist/mcp-server/src/provisioning/linux-user.d.ts.map +1 -0
  97. package/dist/mcp-server/src/provisioning/linux-user.js +62 -0
  98. package/dist/mcp-server/src/provisioning/linux-user.js.map +1 -0
  99. package/dist/mcp-server/src/provisioning/privileged.d.ts +40 -0
  100. package/dist/mcp-server/src/provisioning/privileged.d.ts.map +1 -0
  101. package/dist/mcp-server/src/provisioning/privileged.js +123 -0
  102. package/dist/mcp-server/src/provisioning/privileged.js.map +1 -0
  103. package/dist/mcp-server/src/provisioning/ssh-config.d.ts +21 -0
  104. package/dist/mcp-server/src/provisioning/ssh-config.d.ts.map +1 -0
  105. package/dist/mcp-server/src/provisioning/ssh-config.js +161 -0
  106. package/dist/mcp-server/src/provisioning/ssh-config.js.map +1 -0
  107. package/dist/mcp-server/src/provisioning/tmux-session.d.ts +37 -0
  108. package/dist/mcp-server/src/provisioning/tmux-session.d.ts.map +1 -0
  109. package/dist/mcp-server/src/provisioning/tmux-session.js +123 -0
  110. package/dist/mcp-server/src/provisioning/tmux-session.js.map +1 -0
  111. package/dist/mcp-server/src/server.d.ts +3 -0
  112. package/dist/mcp-server/src/server.d.ts.map +1 -0
  113. package/dist/mcp-server/src/server.js +67 -0
  114. package/dist/mcp-server/src/server.js.map +1 -0
  115. package/dist/mcp-server/src/state/gig-store.d.ts +19 -0
  116. package/dist/mcp-server/src/state/gig-store.d.ts.map +1 -0
  117. package/dist/mcp-server/src/state/gig-store.js +52 -0
  118. package/dist/mcp-server/src/state/gig-store.js.map +1 -0
  119. package/dist/mcp-server/src/state/index.d.ts +4 -0
  120. package/dist/mcp-server/src/state/index.d.ts.map +1 -0
  121. package/dist/mcp-server/src/state/index.js +8 -0
  122. package/dist/mcp-server/src/state/index.js.map +1 -0
  123. package/dist/mcp-server/src/state/persistence.d.ts +13 -0
  124. package/dist/mcp-server/src/state/persistence.d.ts.map +1 -0
  125. package/dist/mcp-server/src/state/persistence.js +48 -0
  126. package/dist/mcp-server/src/state/persistence.js.map +1 -0
  127. package/dist/mcp-server/src/state/types.d.ts +15 -0
  128. package/dist/mcp-server/src/state/types.d.ts.map +1 -0
  129. package/dist/mcp-server/src/state/types.js +3 -0
  130. package/dist/mcp-server/src/state/types.js.map +1 -0
  131. package/dist/mcp-server/src/tools/dismiss-human.d.ts +25 -0
  132. package/dist/mcp-server/src/tools/dismiss-human.d.ts.map +1 -0
  133. package/dist/mcp-server/src/tools/dismiss-human.js +78 -0
  134. package/dist/mcp-server/src/tools/dismiss-human.js.map +1 -0
  135. package/dist/mcp-server/src/tools/index.d.ts +9 -0
  136. package/dist/mcp-server/src/tools/index.d.ts.map +1 -0
  137. package/dist/mcp-server/src/tools/index.js +20 -0
  138. package/dist/mcp-server/src/tools/index.js.map +1 -0
  139. package/dist/mcp-server/src/tools/list-humans.d.ts +18 -0
  140. package/dist/mcp-server/src/tools/list-humans.d.ts.map +1 -0
  141. package/dist/mcp-server/src/tools/list-humans.js +35 -0
  142. package/dist/mcp-server/src/tools/list-humans.js.map +1 -0
  143. package/dist/mcp-server/src/tools/message-human.d.ts +10 -0
  144. package/dist/mcp-server/src/tools/message-human.d.ts.map +1 -0
  145. package/dist/mcp-server/src/tools/message-human.js +19 -0
  146. package/dist/mcp-server/src/tools/message-human.js.map +1 -0
  147. package/dist/mcp-server/src/tools/schemas/dismiss-human.schema.d.ts +19 -0
  148. package/dist/mcp-server/src/tools/schemas/dismiss-human.schema.d.ts.map +1 -0
  149. package/dist/mcp-server/src/tools/schemas/dismiss-human.schema.js +22 -0
  150. package/dist/mcp-server/src/tools/schemas/dismiss-human.schema.js.map +1 -0
  151. package/dist/mcp-server/src/tools/schemas/list-humans.schema.d.ts +4 -0
  152. package/dist/mcp-server/src/tools/schemas/list-humans.schema.d.ts.map +1 -0
  153. package/dist/mcp-server/src/tools/schemas/list-humans.schema.js +7 -0
  154. package/dist/mcp-server/src/tools/schemas/list-humans.schema.js.map +1 -0
  155. package/dist/mcp-server/src/tools/schemas/message-human.schema.d.ts +13 -0
  156. package/dist/mcp-server/src/tools/schemas/message-human.schema.d.ts.map +1 -0
  157. package/dist/mcp-server/src/tools/schemas/message-human.schema.js +9 -0
  158. package/dist/mcp-server/src/tools/schemas/message-human.schema.js.map +1 -0
  159. package/dist/mcp-server/src/tools/schemas/summon-human.schema.d.ts +22 -0
  160. package/dist/mcp-server/src/tools/schemas/summon-human.schema.d.ts.map +1 -0
  161. package/dist/mcp-server/src/tools/schemas/summon-human.schema.js +18 -0
  162. package/dist/mcp-server/src/tools/schemas/summon-human.schema.js.map +1 -0
  163. package/dist/mcp-server/src/tools/summon-human.d.ts +31 -0
  164. package/dist/mcp-server/src/tools/summon-human.d.ts.map +1 -0
  165. package/dist/mcp-server/src/tools/summon-human.js +137 -0
  166. package/dist/mcp-server/src/tools/summon-human.js.map +1 -0
  167. package/dist/mcp-server/src/tunnel/client.d.ts +16 -0
  168. package/dist/mcp-server/src/tunnel/client.d.ts.map +1 -0
  169. package/dist/mcp-server/src/tunnel/client.js +100 -0
  170. package/dist/mcp-server/src/tunnel/client.js.map +1 -0
  171. package/dist/mcp-server/src/tunnel/index.d.ts +6 -0
  172. package/dist/mcp-server/src/tunnel/index.d.ts.map +1 -0
  173. package/dist/mcp-server/src/tunnel/index.js +28 -0
  174. package/dist/mcp-server/src/tunnel/index.js.map +1 -0
  175. package/dist/mcp-server/src/utils/errors.d.ts +28 -0
  176. package/dist/mcp-server/src/utils/errors.d.ts.map +1 -0
  177. package/dist/mcp-server/src/utils/errors.js +66 -0
  178. package/dist/mcp-server/src/utils/errors.js.map +1 -0
  179. package/dist/mcp-server/src/utils/exec.d.ts +7 -0
  180. package/dist/mcp-server/src/utils/exec.d.ts.map +1 -0
  181. package/dist/mcp-server/src/utils/exec.js +22 -0
  182. package/dist/mcp-server/src/utils/exec.js.map +1 -0
  183. package/dist/mcp-server/src/utils/ip.d.ts +6 -0
  184. package/dist/mcp-server/src/utils/ip.d.ts.map +1 -0
  185. package/dist/mcp-server/src/utils/ip.js +33 -0
  186. package/dist/mcp-server/src/utils/ip.js.map +1 -0
  187. package/dist/mcp-server/src/utils/logger.d.ts +20 -0
  188. package/dist/mcp-server/src/utils/logger.d.ts.map +1 -0
  189. package/dist/mcp-server/src/utils/logger.js +41 -0
  190. package/dist/mcp-server/src/utils/logger.js.map +1 -0
  191. package/dist/shared/src/contractor.types.d.ts +20 -0
  192. package/dist/shared/src/contractor.types.d.ts.map +1 -0
  193. package/dist/shared/src/contractor.types.js +3 -0
  194. package/dist/shared/src/contractor.types.js.map +1 -0
  195. package/dist/shared/src/gig.types.d.ts +32 -0
  196. package/dist/shared/src/gig.types.d.ts.map +1 -0
  197. package/dist/shared/src/gig.types.js +21 -0
  198. package/dist/shared/src/gig.types.js.map +1 -0
  199. package/dist/shared/src/index.d.ts +5 -0
  200. package/dist/shared/src/index.d.ts.map +1 -0
  201. package/dist/shared/src/index.js +21 -0
  202. package/dist/shared/src/index.js.map +1 -0
  203. package/dist/shared/src/mcp-tool.types.d.ts +45 -0
  204. package/dist/shared/src/mcp-tool.types.d.ts.map +1 -0
  205. package/dist/shared/src/mcp-tool.types.js +3 -0
  206. package/dist/shared/src/mcp-tool.types.js.map +1 -0
  207. package/dist/shared/src/platform-api.types.d.ts +73 -0
  208. package/dist/shared/src/platform-api.types.d.ts.map +1 -0
  209. package/dist/shared/src/platform-api.types.js +3 -0
  210. package/dist/shared/src/platform-api.types.js.map +1 -0
  211. package/package.json +41 -0
  212. package/src/bin.ts +7 -0
  213. package/src/cli/setup.ts +317 -0
  214. package/src/config/defaults.ts +21 -0
  215. package/src/config/env.ts +74 -0
  216. package/src/config/index.ts +2 -0
  217. package/src/context/generator.ts +71 -0
  218. package/src/context/index.ts +6 -0
  219. package/src/context/templates.ts +41 -0
  220. package/src/git/branch.ts +46 -0
  221. package/src/git/diff.ts +34 -0
  222. package/src/git/index.ts +4 -0
  223. package/src/git/merge.ts +36 -0
  224. package/src/git/worktree.ts +42 -0
  225. package/src/http-wrapper.ts +94 -0
  226. package/src/index.ts +32 -0
  227. package/src/platform-client/client.ts +93 -0
  228. package/src/platform-client/index.ts +4 -0
  229. package/src/platform-client/mock-client.ts +92 -0
  230. package/src/platform-client/polling.ts +53 -0
  231. package/src/platform-client/types.ts +9 -0
  232. package/src/provisioning/authorized-keys.ts +52 -0
  233. package/src/provisioning/cleanup.ts +106 -0
  234. package/src/provisioning/index.ts +13 -0
  235. package/src/provisioning/linux-user.ts +66 -0
  236. package/src/provisioning/privileged.ts +128 -0
  237. package/src/provisioning/ssh-config.ts +197 -0
  238. package/src/provisioning/tmux-session.ts +136 -0
  239. package/src/server.ts +111 -0
  240. package/src/state/gig-store.ts +56 -0
  241. package/src/state/index.ts +3 -0
  242. package/src/state/persistence.ts +42 -0
  243. package/src/state/types.ts +14 -0
  244. package/src/tools/dismiss-human.ts +103 -0
  245. package/src/tools/index.ts +9 -0
  246. package/src/tools/list-humans.ts +54 -0
  247. package/src/tools/message-human.ts +28 -0
  248. package/src/tools/schemas/dismiss-human.schema.ts +21 -0
  249. package/src/tools/schemas/list-humans.schema.ts +6 -0
  250. package/src/tools/schemas/message-human.schema.ts +8 -0
  251. package/src/tools/schemas/summon-human.schema.ts +19 -0
  252. package/src/tools/summon-human.ts +180 -0
  253. package/src/tunnel/client.ts +116 -0
  254. package/src/tunnel/index.ts +26 -0
  255. package/src/utils/errors.ts +64 -0
  256. package/src/utils/exec.ts +29 -0
  257. package/src/utils/ip.ts +31 -0
  258. package/src/utils/logger.ts +55 -0
  259. 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,3 @@
1
+ export { GigStore } from "./gig-store.js";
2
+ export { StatePersistence } from "./persistence.js";
3
+ export type { ActiveGig } from "./types.js";
@@ -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
+ }