shadok-ai 0.17.2 → 0.17.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.
@@ -6,8 +6,16 @@
6
6
  * diff before connecting any GitHub account.
7
7
  */
8
8
  export declare const SELF_REPO_URL = "https://github.com/shadok-ai/shadok-ai.git";
9
- /** Our own copy, never the directory the running server was launched from. */
10
- export declare function selfRepoDir(): string;
9
+ /**
10
+ * Our own copy, never the directory the running server was launched from.
11
+ * `stateHome` defaults to the server's own home (mono-tenant / commons before
12
+ * migration), but is the DROPPED agent's home (`/home/<user>`) once agents run
13
+ * as a non-root OS user: the server's `/root` is mode 0700 and un-traversable by
14
+ * that user, so a clone there strands the Tweak agent — same reason as
15
+ * `worktreeDir` in `worktree.ts`. The caller makes the clone writable by the
16
+ * drop user; this only fixes WHERE it lands.
17
+ */
18
+ export declare function selfRepoDir(stateHome?: string): string;
11
19
  export type SelfRepoPlan = "clone" | "update" | "reclone";
12
20
  /** Pure: what to do with the checkout, given what is on disk. */
13
21
  export declare function selfRepoPlan(exists: boolean, isRepo: boolean): SelfRepoPlan;
@@ -26,4 +34,4 @@ export interface SelfRepoResult {
26
34
  * worktrees a tweak session owns are separate checkouts and are never touched —
27
35
  * refreshing under a live agent is how you lose its work.
28
36
  */
29
- export declare function ensureSelfRepo(): SelfRepoResult;
37
+ export declare function ensureSelfRepo(stateHome?: string): SelfRepoResult;
package/dist/selfrepo.js CHANGED
@@ -10,9 +10,17 @@ import path from "node:path";
10
10
  * diff before connecting any GitHub account.
11
11
  */
12
12
  export const SELF_REPO_URL = "https://github.com/shadok-ai/shadok-ai.git";
13
- /** Our own copy, never the directory the running server was launched from. */
14
- export function selfRepoDir() {
15
- return path.join(os.homedir(), ".shadok-ai", "self", "shadok-ai");
13
+ /**
14
+ * Our own copy, never the directory the running server was launched from.
15
+ * `stateHome` defaults to the server's own home (mono-tenant / commons before
16
+ * migration), but is the DROPPED agent's home (`/home/<user>`) once agents run
17
+ * as a non-root OS user: the server's `/root` is mode 0700 and un-traversable by
18
+ * that user, so a clone there strands the Tweak agent — same reason as
19
+ * `worktreeDir` in `worktree.ts`. The caller makes the clone writable by the
20
+ * drop user; this only fixes WHERE it lands.
21
+ */
22
+ export function selfRepoDir(stateHome = os.homedir()) {
23
+ return path.join(stateHome, ".shadok-ai", "self", "shadok-ai");
16
24
  }
17
25
  /** Pure: what to do with the checkout, given what is on disk. */
18
26
  export function selfRepoPlan(exists, isRepo) {
@@ -48,8 +56,8 @@ function git(args, cwd) {
48
56
  * worktrees a tweak session owns are separate checkouts and are never touched —
49
57
  * refreshing under a live agent is how you lose its work.
50
58
  */
51
- export function ensureSelfRepo() {
52
- const dir = selfRepoDir();
59
+ export function ensureSelfRepo(stateHome = os.homedir()) {
60
+ const dir = selfRepoDir(stateHome);
53
61
  const plan = selfRepoPlan(fs.existsSync(dir), fs.existsSync(path.join(dir, ".git")));
54
62
  try {
55
63
  if (plan === "reclone")
@@ -1 +1 @@
1
- {"version":3,"file":"selfrepo.js","sourceRoot":"","sources":["../src/selfrepo.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"selfrepo.js","sourceRoot":"","sources":["../src/selfrepo.ts"],"names":[],"mappings":"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"}
package/dist/server.js CHANGED
@@ -1554,7 +1554,9 @@ app.get("/diff", (req, res) => {
1554
1554
  // Past worktree sessions of a repo (for reopening unfinished work).
1555
1555
  app.get("/recover", (req, res) => {
1556
1556
  const repo = String(req.query.repo ?? "").trim() || process.cwd();
1557
- const list = isGitRepo(repo) ? listPastSessions(repo) : [];
1557
+ const list = isGitRepo(repo)
1558
+ ? listPastSessions(repo, agentStateHome(commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE })))
1559
+ : [];
1558
1560
  // `repo` is a caller-supplied path: a caller who knows another group's HOME
1559
1561
  // could enumerate that group's past worktree sessions (branch/commit metadata +
1560
1562
  // first-prompt preview). Filter each past session to the caller's visible
@@ -1617,10 +1619,18 @@ app.put("/mute", (req, res) => {
1617
1619
  // Materialises shadok-ai's own source for the "Tweak Shadok-AI" CTA, and hands
1618
1620
  // back the directory to start the agent in. Sits here so it inherits the same
1619
1621
  // password gate as its neighbours.
1620
- app.post("/tweak/prepare", (_req, res) => {
1621
- const r = ensureSelfRepo();
1622
+ app.post("/tweak/prepare", async (_req, res) => {
1623
+ // The tweak agent runs as the commons drop user; its self-repo clone must live
1624
+ // in THAT user's home (reachable under 0700 /root) and be owned by it — same
1625
+ // reason as the worktree created off it. `commonsOsUser → null` in mono-tenant
1626
+ // makes `agentStateHome(null)` the server home and the chown a no-op, so
1627
+ // nothing changes there.
1628
+ const commonsUser = commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
1629
+ const r = ensureSelfRepo(agentStateHome(commonsUser));
1622
1630
  if (r.error)
1623
1631
  return res.status(500).json({ error: r.error });
1632
+ if (r.cwd)
1633
+ await chownAgentStateToDropUser(r.cwd, commonsUser);
1624
1634
  res.json({ cwd: r.cwd });
1625
1635
  });
1626
1636
  // Running version + latest seen on npm (null if not yet polled / check disabled).
@@ -2120,11 +2130,16 @@ app.post("/groups/members", (req, res) => {
2120
2130
  // DEPLOYMENT_MODE gate and the honest-failure rules (T3/T4) all live here. ──────
2121
2131
  /** Where a group's HOME is created (`SHADOK_HOME_BASE` override, else `/home`). */
2122
2132
  const GROUP_HOME_BASE = (process.env.SHADOK_HOME_BASE ?? "").trim() || DEFAULT_HOME_BASE;
2123
- /** The HOME whose `.claude/projects` holds a given agent's transcript: the drop
2124
- * user's own home when the agent is privilege-dropped (multi-tenant isolation),
2125
- * else the server's own home (commons / mono-tenant). The single answer the
2126
- * content tail and `loadHistory` must use so an isolated agent's reply streams. */
2127
- function agentTranscriptHome(user) {
2133
+ /** The HOME that holds ALL of an agent's on-disk state: its transcript
2134
+ * (`.claude/projects`, read by the content tail and `loadHistory`) AND its
2135
+ * worktrees + self-repo clone (`.shadok-ai/worktrees`, `.shadok-ai/self`). The
2136
+ * drop user's own home when the agent is privilege-dropped (multi-tenant
2137
+ * isolation), else the server's own home (commons / mono-tenant). The single
2138
+ * answer every one of those paths must use: the server's `/root` is mode 0700
2139
+ * and un-traversable by a dropped user, so state created there is silently
2140
+ * unreachable — a reply that never streams, or a Tweak agent wedged on Claude
2141
+ * Code's invalid-settings screen (its worktree lived under `/root`). */
2142
+ function agentStateHome(user) {
2128
2143
  return user ? homeForUser(user, GROUP_HOME_BASE) : os.homedir();
2129
2144
  }
2130
2145
  /** The COMMONS tier for secret resolution: Main plus every PUBLIC non-Main group.
@@ -2138,11 +2153,13 @@ function commonsGroupIds() {
2138
2153
  ids.push(g.id);
2139
2154
  return ids;
2140
2155
  }
2141
- /** The transcript HOME for a CHANNEL that may not be live (e.g. GET /download of
2142
- * a past session): a live session's own `transcriptHome` when we have it, else
2143
- * re-derived from the channel's group the same way a spawn's drop user is —
2144
- * the group's `sk-*` user, or the commons user when migrated, else the server. */
2145
- function channelTranscriptHome(chan) {
2156
+ /** The state HOME for a CHANNEL that may not be live (e.g. GET /download of a
2157
+ * past session, or pruning a worktree as the session ends): a live session's
2158
+ * own `transcriptHome` when we have it, else re-derived from the channel's
2159
+ * group the same way a spawn's drop user is — the group's `sk-*` user, or the
2160
+ * commons user when migrated, else the server. Governs both the transcript and
2161
+ * the worktree location, so prune finds exactly what the spawn created. */
2162
+ function channelStateHome(chan) {
2146
2163
  const live = sessions.get(chan.sessionId);
2147
2164
  if (live)
2148
2165
  return live.transcriptHome;
@@ -2150,7 +2167,7 @@ function channelTranscriptHome(chan) {
2150
2167
  return os.homedir();
2151
2168
  const g = chan.group ? loadGroups().find((x) => x.id === chan.group) : null;
2152
2169
  const user = (g ? osUserFor(g) : null) ?? commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
2153
- return agentTranscriptHome(user);
2170
+ return agentStateHome(user);
2154
2171
  }
2155
2172
  /** Task 12: the one shared pot every group HOME links as `~/shared`. */
2156
2173
  const SHARED_POT_PATH = (process.env.SHADOK_SHARED_POT_PATH ?? "").trim() || DEFAULT_SHARED_POT_PATH;
@@ -2171,6 +2188,43 @@ function runPrivileged(cmd, argv) {
2171
2188
  p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`))));
2172
2189
  });
2173
2190
  }
2191
+ /**
2192
+ * Make a freshly-created worktree (or self-repo clone) usable by the DROP USER.
2193
+ * The server creates it as ROOT, so the checkout AND git's own worktree admin
2194
+ * dir (`<repo>/.git/worktrees/<name>`, where the agent's index/HEAD/logs live)
2195
+ * are root-owned — and a dropped agent could neither write them nor, before this
2196
+ * fix, even reach them under the 0700 server HOME. Now the checkout lives in the
2197
+ * drop user's OWN home (`agentStateHome` → `worktreeDir`), so only ownership is
2198
+ * left: chown BOTH the checkout and (for a worktree) its external admin dir to
2199
+ * the user. Best-effort and logged — a chown failure degrades to the old symptom
2200
+ * rather than failing the spawn, and the log names it. No-op with no drop user
2201
+ * (mono-tenant, or commons-as-root before migration): ownership is already right.
2202
+ */
2203
+ async function chownAgentStateToDropUser(checkout, user) {
2204
+ if (!user)
2205
+ return;
2206
+ const owner = `${user}:${user}`;
2207
+ try {
2208
+ await runPrivileged("chown", ["-R", owner, checkout]);
2209
+ // git's admin dir sits under the REPO's gitdir, not the checkout; resolve it
2210
+ // from inside the checkout so a worktree `.git` FILE is followed to it. Only
2211
+ // chown it when OUTSIDE the checkout (a worktree); for a plain clone it is
2212
+ // `<checkout>/.git`, already covered by the recursive chown above.
2213
+ let adminDir = "";
2214
+ try {
2215
+ adminDir = execFileSync("git", ["-C", checkout, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", timeout: 10_000 }).trim();
2216
+ }
2217
+ catch {
2218
+ /* not a git checkout: the recursive chown already covered it */
2219
+ }
2220
+ if (adminDir && !adminDir.startsWith(path.resolve(checkout) + path.sep)) {
2221
+ await runPrivileged("chown", ["-R", owner, adminDir]);
2222
+ }
2223
+ }
2224
+ catch (e) {
2225
+ console.warn(`state chown to ${user} failed for ${checkout} (agent may be unable to write it): ${e?.message ?? e}`);
2226
+ }
2227
+ }
2174
2228
  /** The shadok skills to install: [package source dir, installed name]. Mirrored
2175
2229
  * into the SERVER HOME by `seedXxxSkill` and, for multi-user isolation, into the
2176
2230
  * machine-wide ENTERPRISE tier by `seedSystemSkills`. */
@@ -4125,7 +4179,7 @@ app.get("/download", (req, res) => {
4125
4179
  if (!allowed) {
4126
4180
  let raw;
4127
4181
  try {
4128
- raw = fs.readFileSync(transcriptFilePath(chan.cwd, session, channelTranscriptHome(chan)), "utf8");
4182
+ raw = fs.readFileSync(transcriptFilePath(chan.cwd, session, channelStateHome(chan)), "utf8");
4129
4183
  }
4130
4184
  catch {
4131
4185
  return res.status(403).type("text").send("that file was not sent by this agent");
@@ -5162,6 +5216,12 @@ async function endChannel(sessionId) {
5162
5216
  const s = sessions.get(sessionId);
5163
5217
  const worktreeBranch = s?.worktree?.branch ?? ch?.branch;
5164
5218
  const repo = s?.worktree?.repo ?? ch?.repo;
5219
+ // Resolve the worktree's home WHILE the session is still live (its own
5220
+ // `transcriptHome` is the exact home the spawn created it in); fall back to the
5221
+ // channel's group-derived home once it is gone. Prune must look in the same
5222
+ // place createWorktree wrote, or a dropped agent's checkout under /home/<user>
5223
+ // would never be cleaned.
5224
+ const worktreeHome = s?.transcriptHome ?? (ch ? channelStateHome(ch) : os.homedir());
5165
5225
  if (s) {
5166
5226
  broadcast(s, { type: "stopped" });
5167
5227
  await s.pilot.stop().catch(() => { });
@@ -5171,7 +5231,7 @@ async function endChannel(sessionId) {
5171
5231
  // merged), keep it if there's uncommitted or unmerged work. Stops the empty-
5172
5232
  // worktree pile-up while never discarding real work.
5173
5233
  if (repo && worktreeBranch) {
5174
- const outcome = pruneWorktree(repo, worktreeBranch);
5234
+ const outcome = pruneWorktree(repo, worktreeBranch, worktreeHome);
5175
5235
  if (outcome !== "none")
5176
5236
  console.log(`worktree ${worktreeBranch}: ${outcome} on session end`);
5177
5237
  }
@@ -5270,8 +5330,8 @@ group = null) {
5270
5330
  const pilot = makePilot(id, cwd, args, profile, model, effort, user, group);
5271
5331
  // A dropped group agent writes its transcript under ITS OWN home, not the
5272
5332
  // server's — so the content tail / history must read from there (invariant:
5273
- // all content comes from the .jsonl). `agentTranscriptHome` is the one answer.
5274
- const transcriptHome = agentTranscriptHome(user);
5333
+ // all content comes from the .jsonl). `agentStateHome` is the one answer.
5334
+ const transcriptHome = agentStateHome(user);
5275
5335
  // Resumed sessions start with what the transcript already consumed.
5276
5336
  const seededUsage = scanUsage(sessionFilePath(cwd, id, transcriptHome));
5277
5337
  const backend = sessionBackend(profile, group);
@@ -6176,9 +6236,11 @@ wss.on("connection", (ws, req) => {
6176
6236
  }
6177
6237
  return m ?? null;
6178
6238
  };
6239
+ // The drop user a NEW spawn will run as — same inputs as the isolation
6240
+ // resolution further down. Hoisted out of the MT-token guard because the
6241
+ // worktree below must land in (and be owned by) this same user's home.
6242
+ let earlyUser = null;
6179
6243
  if (!resumed) {
6180
- // A new spawn has no stored group; same inputs as the isolation
6181
- // resolution further down.
6182
6244
  const earlyIso = resolveSpawnIsolation({
6183
6245
  mode: DEPLOYMENT_MODE,
6184
6246
  resumed,
@@ -6187,15 +6249,22 @@ wss.on("connection", (ws, req) => {
6187
6249
  storedGroupId: null,
6188
6250
  requestedGroupId: typeof msg.group === "string" && msg.group.trim() ? msg.group.trim() : null,
6189
6251
  });
6190
- const earlyUser = earlyIso.user ?? commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
6252
+ earlyUser = earlyIso.user ?? commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
6191
6253
  const earlyMsg = mtRefusal(earlyUser);
6192
6254
  if (earlyMsg)
6193
6255
  return fail(earlyMsg);
6194
6256
  }
6195
6257
  if (msg.worktree && !resumed && isGitRepo(cwd)) {
6196
6258
  try {
6197
- worktree = createWorktree(cwd, id.slice(0, 8));
6259
+ // Create the checkout under the DROP USER's own home (not the
6260
+ // server's `/root`, which that user cannot traverse) and hand it to
6261
+ // them, so a dropped agent — notably every Tweak agent — can
6262
+ // actually work in it instead of landing on the invalid-settings
6263
+ // screen. `agentStateHome(null)` is the server home, so mono-tenant
6264
+ // is byte-for-byte unchanged.
6265
+ worktree = createWorktree(cwd, id.slice(0, 8), agentStateHome(earlyUser));
6198
6266
  effectiveCwd = worktree.path;
6267
+ await chownAgentStateToDropUser(worktree.path, earlyUser);
6199
6268
  }
6200
6269
  catch (e) {
6201
6270
  return fail("worktree creation failed: " + (e instanceof Error ? e.message : String(e)));