shadok-ai 0.17.3 → 0.17.5
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/dist/live-handover.d.ts +47 -0
- package/dist/live-handover.js +58 -0
- package/dist/live-handover.js.map +1 -0
- package/dist/selfrepo.d.ts +11 -3
- package/dist/selfrepo.js +13 -5
- package/dist/selfrepo.js.map +1 -1
- package/dist/server.js +121 -23
- package/dist/server.js.map +1 -1
- package/dist/worktree.d.ts +19 -4
- package/dist/worktree.js +25 -10
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { WebSocket } from "ws";
|
|
2
|
+
/**
|
|
3
|
+
* The subset of a `Live` that a handover reads and mutates. Kept structural so
|
|
4
|
+
* the server's full `Live` satisfies it with no cast, and a test can build a
|
|
5
|
+
* fake. It DELIBERATELY omits `pilot`, `cwd`, `id` and `transcriptHome`: the
|
|
6
|
+
* handover must never be able to kill the agent or clear the shared tail
|
|
7
|
+
* position, and leaving those fields out of the type makes that a compile-time
|
|
8
|
+
* guarantee rather than a comment someone can regress past.
|
|
9
|
+
*/
|
|
10
|
+
export interface HandoverLive {
|
|
11
|
+
clients: Set<WebSocket>;
|
|
12
|
+
rawClients?: Set<WebSocket>;
|
|
13
|
+
screenTimer: ReturnType<typeof setInterval> | null;
|
|
14
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
15
|
+
retryTimer: ReturnType<typeof setTimeout> | null;
|
|
16
|
+
stopTail: (() => void) | null;
|
|
17
|
+
rawOff?: (() => void) | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Hand a superseded `Live`'s viewers onto the `Live` that replaced it, and quiet
|
|
21
|
+
* the old one WITHOUT touching the agent. Returns how many viewers moved.
|
|
22
|
+
*
|
|
23
|
+
* The bug (shared-ledger [5d8d]): when a session's pilot has exited but the
|
|
24
|
+
* channel still has web/Telegram viewers attached, a resume takes the
|
|
25
|
+
* `createSession` path and `sessions.set(id, newLive)` REPLACES the registry
|
|
26
|
+
* entry — leaving the old `Live`, with its clients, orphaned. Its tail keeps
|
|
27
|
+
* running on the same transcript, so those clients still see the agent's
|
|
28
|
+
* ANSWERS, but every `broadcast` now targets the new `Live`: they miss the other
|
|
29
|
+
* interfaces' prompt echoes (the reported symptom: Telegram messages invisible
|
|
30
|
+
* on the web until reload), dialogs, and working/turn-done. Two `Live` for one
|
|
31
|
+
* agent; "server code correct in isolation", the split is the cause.
|
|
32
|
+
*
|
|
33
|
+
* The cure is one `Live` per agent: hand the viewers over and tear the old one
|
|
34
|
+
* down. Teardown here is DELIBERATELY not `destroySession`. The new `Live` may
|
|
35
|
+
* have adopted the very same tmux pane — a `hasExited` that latched on a single
|
|
36
|
+
* transient probe (invariants 23/25) leaves the pane alive — so killing the old
|
|
37
|
+
* pilot would kill the live agent, and clearing the shared tail position would
|
|
38
|
+
* make the new tail replay the whole transcript from byte 0. So we stop the old
|
|
39
|
+
* `Live`'s own tail and timers and detach its raw pipe, and nothing else. (The
|
|
40
|
+
* type can't express `pilot.kill()` or `clearTailPos` anyway — see `HandoverLive`.)
|
|
41
|
+
*
|
|
42
|
+
* Raw terminal viewers are NOT re-wired onto the new pilot's pipe: the raw
|
|
43
|
+
* subscription is per-pilot and is rebuilt when the engine room is next opened.
|
|
44
|
+
* Moving the chat `clients` set is what the reported bug needs; the old raw pipe
|
|
45
|
+
* is simply turned off so the retired `Live` goes fully quiet.
|
|
46
|
+
*/
|
|
47
|
+
export declare function supersedeSession(old: HandoverLive, next: HandoverLive): number;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand a superseded `Live`'s viewers onto the `Live` that replaced it, and quiet
|
|
3
|
+
* the old one WITHOUT touching the agent. Returns how many viewers moved.
|
|
4
|
+
*
|
|
5
|
+
* The bug (shared-ledger [5d8d]): when a session's pilot has exited but the
|
|
6
|
+
* channel still has web/Telegram viewers attached, a resume takes the
|
|
7
|
+
* `createSession` path and `sessions.set(id, newLive)` REPLACES the registry
|
|
8
|
+
* entry — leaving the old `Live`, with its clients, orphaned. Its tail keeps
|
|
9
|
+
* running on the same transcript, so those clients still see the agent's
|
|
10
|
+
* ANSWERS, but every `broadcast` now targets the new `Live`: they miss the other
|
|
11
|
+
* interfaces' prompt echoes (the reported symptom: Telegram messages invisible
|
|
12
|
+
* on the web until reload), dialogs, and working/turn-done. Two `Live` for one
|
|
13
|
+
* agent; "server code correct in isolation", the split is the cause.
|
|
14
|
+
*
|
|
15
|
+
* The cure is one `Live` per agent: hand the viewers over and tear the old one
|
|
16
|
+
* down. Teardown here is DELIBERATELY not `destroySession`. The new `Live` may
|
|
17
|
+
* have adopted the very same tmux pane — a `hasExited` that latched on a single
|
|
18
|
+
* transient probe (invariants 23/25) leaves the pane alive — so killing the old
|
|
19
|
+
* pilot would kill the live agent, and clearing the shared tail position would
|
|
20
|
+
* make the new tail replay the whole transcript from byte 0. So we stop the old
|
|
21
|
+
* `Live`'s own tail and timers and detach its raw pipe, and nothing else. (The
|
|
22
|
+
* type can't express `pilot.kill()` or `clearTailPos` anyway — see `HandoverLive`.)
|
|
23
|
+
*
|
|
24
|
+
* Raw terminal viewers are NOT re-wired onto the new pilot's pipe: the raw
|
|
25
|
+
* subscription is per-pilot and is rebuilt when the engine room is next opened.
|
|
26
|
+
* Moving the chat `clients` set is what the reported bug needs; the old raw pipe
|
|
27
|
+
* is simply turned off so the retired `Live` goes fully quiet.
|
|
28
|
+
*/
|
|
29
|
+
export function supersedeSession(old, next) {
|
|
30
|
+
let moved = 0;
|
|
31
|
+
for (const c of old.clients) {
|
|
32
|
+
next.clients.add(c);
|
|
33
|
+
moved++;
|
|
34
|
+
}
|
|
35
|
+
old.clients.clear();
|
|
36
|
+
// Quiet the retired Live: its own tail (so one transcript is not tailed
|
|
37
|
+
// twice — the "two tails share one pos file" side effect noted in [5d8d]),
|
|
38
|
+
// its timers, and its raw pipe. Never the pilot, never the tail position.
|
|
39
|
+
if (old.screenTimer) {
|
|
40
|
+
clearInterval(old.screenTimer);
|
|
41
|
+
old.screenTimer = null;
|
|
42
|
+
}
|
|
43
|
+
if (old.idleTimer) {
|
|
44
|
+
clearTimeout(old.idleTimer);
|
|
45
|
+
old.idleTimer = null;
|
|
46
|
+
}
|
|
47
|
+
if (old.retryTimer) {
|
|
48
|
+
clearTimeout(old.retryTimer);
|
|
49
|
+
old.retryTimer = null;
|
|
50
|
+
}
|
|
51
|
+
old.stopTail?.();
|
|
52
|
+
old.stopTail = null;
|
|
53
|
+
old.rawOff?.();
|
|
54
|
+
old.rawOff = null;
|
|
55
|
+
old.rawClients?.clear();
|
|
56
|
+
return moved;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=live-handover.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-handover.js","sourceRoot":"","sources":["../src/live-handover.ts"],"names":[],"mappings":"AAoBA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAiB,EAAE,IAAkB;IACpE,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACpB,KAAK,EAAE,CAAC;IACV,CAAC;IACD,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IAEpB,wEAAwE;IACxE,2EAA2E;IAC3E,0EAA0E;IAC1E,IAAI,GAAG,CAAC,WAAW,EAAE,CAAC;QACpB,aAAa,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAC/B,GAAG,CAAC,WAAW,GAAG,IAAI,CAAC;IACzB,CAAC;IACD,IAAI,GAAG,CAAC,SAAS,EAAE,CAAC;QAClB,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC5B,GAAG,CAAC,SAAS,GAAG,IAAI,CAAC;IACvB,CAAC;IACD,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC;QACnB,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QAC7B,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC;IACxB,CAAC;IACD,GAAG,CAAC,QAAQ,EAAE,EAAE,CAAC;IACjB,GAAG,CAAC,QAAQ,GAAG,IAAI,CAAC;IACpB,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC;IACf,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC;IAClB,GAAG,CAAC,UAAU,EAAE,KAAK,EAAE,CAAC;IAExB,OAAO,KAAK,CAAC;AACf,CAAC"}
|
package/dist/selfrepo.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
10
|
-
|
|
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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
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")
|
package/dist/selfrepo.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"selfrepo.js","sourceRoot":"","sources":["../src/selfrepo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,4CAA4C,CAAC;AAE1E
|
|
1
|
+
{"version":3,"file":"selfrepo.js","sourceRoot":"","sources":["../src/selfrepo.ts"],"names":[],"mappings":"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"}
|
package/dist/server.js
CHANGED
|
@@ -65,6 +65,7 @@ import { getProfile, profileArgs, permissionModeArgs, isPermissionMode, isEffort
|
|
|
65
65
|
import { BOOTSTRAP_ADMIN, loadAccounts, verifyPassword, sessionSecret, signSession, readSessionFull, sessionAlive, saveAccounts, userWriteVerdict, newInvite, inviteVerdict, hashPassword, promptAuthor, signSessionKey, readSessionKey, signTalkHandle, readTalkHandle, } from "./accounts.js";
|
|
66
66
|
import { admins2fa, authenticationOptions, enrollCodeVerdict, isInternalClient, keepsTwoAdminsVerdict, loginNext, newEnrollCode, publicPasskey, putChallenge, readPending, registrationOptions, resetVerdict, signPending, takeChallenge, twoFactorEnableVerdict, verifyAuthentication, verifyRegistration, PENDING_TTL_MS, } from "./twofactor.js";
|
|
67
67
|
import { ensureSelfRepo } from "./selfrepo.js";
|
|
68
|
+
import { supersedeSession } from "./live-handover.js";
|
|
68
69
|
import { createWorktree, pruneWorktree, ensureWorktreeCheckout, gitDiff, isGitRepo, listPastSessions, } from "./worktree.js";
|
|
69
70
|
import { latestVersion, update } from "./updater.js";
|
|
70
71
|
import { resolveChannel } from "./update-channel.js";
|
|
@@ -1554,7 +1555,9 @@ app.get("/diff", (req, res) => {
|
|
|
1554
1555
|
// Past worktree sessions of a repo (for reopening unfinished work).
|
|
1555
1556
|
app.get("/recover", (req, res) => {
|
|
1556
1557
|
const repo = String(req.query.repo ?? "").trim() || process.cwd();
|
|
1557
|
-
const list = isGitRepo(repo)
|
|
1558
|
+
const list = isGitRepo(repo)
|
|
1559
|
+
? listPastSessions(repo, agentStateHome(commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE })))
|
|
1560
|
+
: [];
|
|
1558
1561
|
// `repo` is a caller-supplied path: a caller who knows another group's HOME
|
|
1559
1562
|
// could enumerate that group's past worktree sessions (branch/commit metadata +
|
|
1560
1563
|
// first-prompt preview). Filter each past session to the caller's visible
|
|
@@ -1617,10 +1620,18 @@ app.put("/mute", (req, res) => {
|
|
|
1617
1620
|
// Materialises shadok-ai's own source for the "Tweak Shadok-AI" CTA, and hands
|
|
1618
1621
|
// back the directory to start the agent in. Sits here so it inherits the same
|
|
1619
1622
|
// password gate as its neighbours.
|
|
1620
|
-
app.post("/tweak/prepare", (_req, res) => {
|
|
1621
|
-
|
|
1623
|
+
app.post("/tweak/prepare", async (_req, res) => {
|
|
1624
|
+
// The tweak agent runs as the commons drop user; its self-repo clone must live
|
|
1625
|
+
// in THAT user's home (reachable under 0700 /root) and be owned by it — same
|
|
1626
|
+
// reason as the worktree created off it. `commonsOsUser → null` in mono-tenant
|
|
1627
|
+
// makes `agentStateHome(null)` the server home and the chown a no-op, so
|
|
1628
|
+
// nothing changes there.
|
|
1629
|
+
const commonsUser = commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
|
|
1630
|
+
const r = ensureSelfRepo(agentStateHome(commonsUser));
|
|
1622
1631
|
if (r.error)
|
|
1623
1632
|
return res.status(500).json({ error: r.error });
|
|
1633
|
+
if (r.cwd)
|
|
1634
|
+
await chownAgentStateToDropUser(r.cwd, commonsUser);
|
|
1624
1635
|
res.json({ cwd: r.cwd });
|
|
1625
1636
|
});
|
|
1626
1637
|
// Running version + latest seen on npm (null if not yet polled / check disabled).
|
|
@@ -2120,11 +2131,16 @@ app.post("/groups/members", (req, res) => {
|
|
|
2120
2131
|
// DEPLOYMENT_MODE gate and the honest-failure rules (T3/T4) all live here. ──────
|
|
2121
2132
|
/** Where a group's HOME is created (`SHADOK_HOME_BASE` override, else `/home`). */
|
|
2122
2133
|
const GROUP_HOME_BASE = (process.env.SHADOK_HOME_BASE ?? "").trim() || DEFAULT_HOME_BASE;
|
|
2123
|
-
/** The HOME
|
|
2124
|
-
*
|
|
2125
|
-
*
|
|
2126
|
-
*
|
|
2127
|
-
|
|
2134
|
+
/** The HOME that holds ALL of an agent's on-disk state: its transcript
|
|
2135
|
+
* (`.claude/projects`, read by the content tail and `loadHistory`) AND its
|
|
2136
|
+
* worktrees + self-repo clone (`.shadok-ai/worktrees`, `.shadok-ai/self`). The
|
|
2137
|
+
* drop user's own home when the agent is privilege-dropped (multi-tenant
|
|
2138
|
+
* isolation), else the server's own home (commons / mono-tenant). The single
|
|
2139
|
+
* answer every one of those paths must use: the server's `/root` is mode 0700
|
|
2140
|
+
* and un-traversable by a dropped user, so state created there is silently
|
|
2141
|
+
* unreachable — a reply that never streams, or a Tweak agent wedged on Claude
|
|
2142
|
+
* Code's invalid-settings screen (its worktree lived under `/root`). */
|
|
2143
|
+
function agentStateHome(user) {
|
|
2128
2144
|
return user ? homeForUser(user, GROUP_HOME_BASE) : os.homedir();
|
|
2129
2145
|
}
|
|
2130
2146
|
/** The COMMONS tier for secret resolution: Main plus every PUBLIC non-Main group.
|
|
@@ -2138,11 +2154,13 @@ function commonsGroupIds() {
|
|
|
2138
2154
|
ids.push(g.id);
|
|
2139
2155
|
return ids;
|
|
2140
2156
|
}
|
|
2141
|
-
/** The
|
|
2142
|
-
* a
|
|
2143
|
-
* re-derived from the channel's
|
|
2144
|
-
* the
|
|
2145
|
-
|
|
2157
|
+
/** The state HOME for a CHANNEL that may not be live (e.g. GET /download of a
|
|
2158
|
+
* past session, or pruning a worktree as the session ends): a live session's
|
|
2159
|
+
* own `transcriptHome` when we have it, else re-derived from the channel's
|
|
2160
|
+
* group the same way a spawn's drop user is — the group's `sk-*` user, or the
|
|
2161
|
+
* commons user when migrated, else the server. Governs both the transcript and
|
|
2162
|
+
* the worktree location, so prune finds exactly what the spawn created. */
|
|
2163
|
+
function channelStateHome(chan) {
|
|
2146
2164
|
const live = sessions.get(chan.sessionId);
|
|
2147
2165
|
if (live)
|
|
2148
2166
|
return live.transcriptHome;
|
|
@@ -2150,7 +2168,7 @@ function channelTranscriptHome(chan) {
|
|
|
2150
2168
|
return os.homedir();
|
|
2151
2169
|
const g = chan.group ? loadGroups().find((x) => x.id === chan.group) : null;
|
|
2152
2170
|
const user = (g ? osUserFor(g) : null) ?? commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
|
|
2153
|
-
return
|
|
2171
|
+
return agentStateHome(user);
|
|
2154
2172
|
}
|
|
2155
2173
|
/** Task 12: the one shared pot every group HOME links as `~/shared`. */
|
|
2156
2174
|
const SHARED_POT_PATH = (process.env.SHADOK_SHARED_POT_PATH ?? "").trim() || DEFAULT_SHARED_POT_PATH;
|
|
@@ -2171,6 +2189,43 @@ function runPrivileged(cmd, argv) {
|
|
|
2171
2189
|
p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`))));
|
|
2172
2190
|
});
|
|
2173
2191
|
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Make a freshly-created worktree (or self-repo clone) usable by the DROP USER.
|
|
2194
|
+
* The server creates it as ROOT, so the checkout AND git's own worktree admin
|
|
2195
|
+
* dir (`<repo>/.git/worktrees/<name>`, where the agent's index/HEAD/logs live)
|
|
2196
|
+
* are root-owned — and a dropped agent could neither write them nor, before this
|
|
2197
|
+
* fix, even reach them under the 0700 server HOME. Now the checkout lives in the
|
|
2198
|
+
* drop user's OWN home (`agentStateHome` → `worktreeDir`), so only ownership is
|
|
2199
|
+
* left: chown BOTH the checkout and (for a worktree) its external admin dir to
|
|
2200
|
+
* the user. Best-effort and logged — a chown failure degrades to the old symptom
|
|
2201
|
+
* rather than failing the spawn, and the log names it. No-op with no drop user
|
|
2202
|
+
* (mono-tenant, or commons-as-root before migration): ownership is already right.
|
|
2203
|
+
*/
|
|
2204
|
+
async function chownAgentStateToDropUser(checkout, user) {
|
|
2205
|
+
if (!user)
|
|
2206
|
+
return;
|
|
2207
|
+
const owner = `${user}:${user}`;
|
|
2208
|
+
try {
|
|
2209
|
+
await runPrivileged("chown", ["-R", owner, checkout]);
|
|
2210
|
+
// git's admin dir sits under the REPO's gitdir, not the checkout; resolve it
|
|
2211
|
+
// from inside the checkout so a worktree `.git` FILE is followed to it. Only
|
|
2212
|
+
// chown it when OUTSIDE the checkout (a worktree); for a plain clone it is
|
|
2213
|
+
// `<checkout>/.git`, already covered by the recursive chown above.
|
|
2214
|
+
let adminDir = "";
|
|
2215
|
+
try {
|
|
2216
|
+
adminDir = execFileSync("git", ["-C", checkout, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", timeout: 10_000 }).trim();
|
|
2217
|
+
}
|
|
2218
|
+
catch {
|
|
2219
|
+
/* not a git checkout: the recursive chown already covered it */
|
|
2220
|
+
}
|
|
2221
|
+
if (adminDir && !adminDir.startsWith(path.resolve(checkout) + path.sep)) {
|
|
2222
|
+
await runPrivileged("chown", ["-R", owner, adminDir]);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
catch (e) {
|
|
2226
|
+
console.warn(`state chown to ${user} failed for ${checkout} (agent may be unable to write it): ${e?.message ?? e}`);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2174
2229
|
/** The shadok skills to install: [package source dir, installed name]. Mirrored
|
|
2175
2230
|
* into the SERVER HOME by `seedXxxSkill` and, for multi-user isolation, into the
|
|
2176
2231
|
* machine-wide ENTERPRISE tier by `seedSystemSkills`. */
|
|
@@ -4125,7 +4180,7 @@ app.get("/download", (req, res) => {
|
|
|
4125
4180
|
if (!allowed) {
|
|
4126
4181
|
let raw;
|
|
4127
4182
|
try {
|
|
4128
|
-
raw = fs.readFileSync(transcriptFilePath(chan.cwd, session,
|
|
4183
|
+
raw = fs.readFileSync(transcriptFilePath(chan.cwd, session, channelStateHome(chan)), "utf8");
|
|
4129
4184
|
}
|
|
4130
4185
|
catch {
|
|
4131
4186
|
return res.status(403).type("text").send("that file was not sent by this agent");
|
|
@@ -5162,6 +5217,12 @@ async function endChannel(sessionId) {
|
|
|
5162
5217
|
const s = sessions.get(sessionId);
|
|
5163
5218
|
const worktreeBranch = s?.worktree?.branch ?? ch?.branch;
|
|
5164
5219
|
const repo = s?.worktree?.repo ?? ch?.repo;
|
|
5220
|
+
// Resolve the worktree's home WHILE the session is still live (its own
|
|
5221
|
+
// `transcriptHome` is the exact home the spawn created it in); fall back to the
|
|
5222
|
+
// channel's group-derived home once it is gone. Prune must look in the same
|
|
5223
|
+
// place createWorktree wrote, or a dropped agent's checkout under /home/<user>
|
|
5224
|
+
// would never be cleaned.
|
|
5225
|
+
const worktreeHome = s?.transcriptHome ?? (ch ? channelStateHome(ch) : os.homedir());
|
|
5165
5226
|
if (s) {
|
|
5166
5227
|
broadcast(s, { type: "stopped" });
|
|
5167
5228
|
await s.pilot.stop().catch(() => { });
|
|
@@ -5171,7 +5232,7 @@ async function endChannel(sessionId) {
|
|
|
5171
5232
|
// merged), keep it if there's uncommitted or unmerged work. Stops the empty-
|
|
5172
5233
|
// worktree pile-up while never discarding real work.
|
|
5173
5234
|
if (repo && worktreeBranch) {
|
|
5174
|
-
const outcome = pruneWorktree(repo, worktreeBranch);
|
|
5235
|
+
const outcome = pruneWorktree(repo, worktreeBranch, worktreeHome);
|
|
5175
5236
|
if (outcome !== "none")
|
|
5176
5237
|
console.log(`worktree ${worktreeBranch}: ${outcome} on session end`);
|
|
5177
5238
|
}
|
|
@@ -5270,8 +5331,8 @@ group = null) {
|
|
|
5270
5331
|
const pilot = makePilot(id, cwd, args, profile, model, effort, user, group);
|
|
5271
5332
|
// A dropped group agent writes its transcript under ITS OWN home, not the
|
|
5272
5333
|
// server's — so the content tail / history must read from there (invariant:
|
|
5273
|
-
// all content comes from the .jsonl). `
|
|
5274
|
-
const transcriptHome =
|
|
5334
|
+
// all content comes from the .jsonl). `agentStateHome` is the one answer.
|
|
5335
|
+
const transcriptHome = agentStateHome(user);
|
|
5275
5336
|
// Resumed sessions start with what the transcript already consumed.
|
|
5276
5337
|
const seededUsage = scanUsage(sessionFilePath(cwd, id, transcriptHome));
|
|
5277
5338
|
const backend = sessionBackend(profile, group);
|
|
@@ -6032,8 +6093,11 @@ wss.on("connection", (ws, req) => {
|
|
|
6032
6093
|
// so a server updated underneath it refreshes the page automatically.
|
|
6033
6094
|
send(versionMessage());
|
|
6034
6095
|
ws.on("close", () => {
|
|
6096
|
+
// Detach from the CURRENT Live for this id, not the closure's possibly
|
|
6097
|
+
// retired one: a handover ([5d8d]) moved this ws onto the new Live, so
|
|
6098
|
+
// detaching the old object would leak the ws in the live one's client set.
|
|
6035
6099
|
if (session)
|
|
6036
|
-
detach(ws, session);
|
|
6100
|
+
detach(ws, sessions.get(session.id) ?? session);
|
|
6037
6101
|
session = null;
|
|
6038
6102
|
});
|
|
6039
6103
|
ws.on("message", async (raw) => {
|
|
@@ -6045,6 +6109,16 @@ wss.on("connection", (ws, req) => {
|
|
|
6045
6109
|
return fail("unreadable message");
|
|
6046
6110
|
}
|
|
6047
6111
|
try {
|
|
6112
|
+
// This connection can outlive the Live it attached to: a resume that
|
|
6113
|
+
// supersedes an exited-but-watched session (see `supersedeSession`,
|
|
6114
|
+
// [5d8d]) moved this ws onto the NEW Live, but this closure still points
|
|
6115
|
+
// at the retired one. Re-point it so every handler below — and the
|
|
6116
|
+
// takeover/retry logic — acts on the single real Live for this id.
|
|
6117
|
+
if (session) {
|
|
6118
|
+
const current = sessions.get(session.id);
|
|
6119
|
+
if (current && current !== session)
|
|
6120
|
+
session = current;
|
|
6121
|
+
}
|
|
6048
6122
|
// Any user takeover cancels a pending auto-retry and ends the streak.
|
|
6049
6123
|
// "prompt" is settled inside its own case instead: a prompt refused on
|
|
6050
6124
|
// pace grounds sends nothing, so it must not count as a takeover — it
|
|
@@ -6176,9 +6250,11 @@ wss.on("connection", (ws, req) => {
|
|
|
6176
6250
|
}
|
|
6177
6251
|
return m ?? null;
|
|
6178
6252
|
};
|
|
6253
|
+
// The drop user a NEW spawn will run as — same inputs as the isolation
|
|
6254
|
+
// resolution further down. Hoisted out of the MT-token guard because the
|
|
6255
|
+
// worktree below must land in (and be owned by) this same user's home.
|
|
6256
|
+
let earlyUser = null;
|
|
6179
6257
|
if (!resumed) {
|
|
6180
|
-
// A new spawn has no stored group; same inputs as the isolation
|
|
6181
|
-
// resolution further down.
|
|
6182
6258
|
const earlyIso = resolveSpawnIsolation({
|
|
6183
6259
|
mode: DEPLOYMENT_MODE,
|
|
6184
6260
|
resumed,
|
|
@@ -6187,15 +6263,22 @@ wss.on("connection", (ws, req) => {
|
|
|
6187
6263
|
storedGroupId: null,
|
|
6188
6264
|
requestedGroupId: typeof msg.group === "string" && msg.group.trim() ? msg.group.trim() : null,
|
|
6189
6265
|
});
|
|
6190
|
-
|
|
6266
|
+
earlyUser = earlyIso.user ?? commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
|
|
6191
6267
|
const earlyMsg = mtRefusal(earlyUser);
|
|
6192
6268
|
if (earlyMsg)
|
|
6193
6269
|
return fail(earlyMsg);
|
|
6194
6270
|
}
|
|
6195
6271
|
if (msg.worktree && !resumed && isGitRepo(cwd)) {
|
|
6196
6272
|
try {
|
|
6197
|
-
|
|
6273
|
+
// Create the checkout under the DROP USER's own home (not the
|
|
6274
|
+
// server's `/root`, which that user cannot traverse) and hand it to
|
|
6275
|
+
// them, so a dropped agent — notably every Tweak agent — can
|
|
6276
|
+
// actually work in it instead of landing on the invalid-settings
|
|
6277
|
+
// screen. `agentStateHome(null)` is the server home, so mono-tenant
|
|
6278
|
+
// is byte-for-byte unchanged.
|
|
6279
|
+
worktree = createWorktree(cwd, id.slice(0, 8), agentStateHome(earlyUser));
|
|
6198
6280
|
effectiveCwd = worktree.path;
|
|
6281
|
+
await chownAgentStateToDropUser(worktree.path, earlyUser);
|
|
6199
6282
|
}
|
|
6200
6283
|
catch (e) {
|
|
6201
6284
|
return fail("worktree creation failed: " + (e instanceof Error ? e.message : String(e)));
|
|
@@ -6388,6 +6471,21 @@ wss.on("connection", (ws, req) => {
|
|
|
6388
6471
|
if (mtMsg)
|
|
6389
6472
|
return fail(mtMsg);
|
|
6390
6473
|
session = await createSession(id, effectiveCwd, args, worktree, profile, model, effort, spawnActor({ account: me?.name, origin: msg.origin, peer: callerPeer, parent: parentAtStart, agent: callerAgent?.name }), dropUser, agentGroupId);
|
|
6474
|
+
// One Live per agent (shared-ledger [5d8d]). We only reach here when
|
|
6475
|
+
// there was no live session to attach to — but `existing` may be a
|
|
6476
|
+
// RETIRED Live whose pilot has exited while viewers are still
|
|
6477
|
+
// attached (a web tab, a Telegram bridge). `createSession` has just
|
|
6478
|
+
// replaced it in the registry; without a handover those viewers are
|
|
6479
|
+
// orphaned on the old object — still fed the agent's answers by its
|
|
6480
|
+
// lingering tail, but cut off from every broadcast on the new Live
|
|
6481
|
+
// (the other interfaces' prompt echoes, dialogs, working/turn-done).
|
|
6482
|
+
// Move them onto the new Live and quiet the old one — without killing
|
|
6483
|
+
// the agent, which the new Live may now be driving on the same pane.
|
|
6484
|
+
if (existing && existing !== session) {
|
|
6485
|
+
const moved = supersedeSession(existing, session);
|
|
6486
|
+
if (moved)
|
|
6487
|
+
console.log(`session ${id.slice(0, 8)}: superseded a retired Live, moved ${moved} viewer(s)`);
|
|
6488
|
+
}
|
|
6391
6489
|
session.clients.add(ws);
|
|
6392
6490
|
if (resumed) {
|
|
6393
6491
|
const turns = historyWithOffers(loadHistory(effectiveCwd, id, session.transcriptHome), id);
|