talon-agent 5.26.4 → 5.26.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/package.json +1 -1
- package/src/app.ts +8 -0
- package/src/backend/agy/auth.ts +2 -2
- package/src/backend/agy/mcp/config.ts +3 -3
- package/src/backend/claude-sdk/usage/plan-usage.ts +2 -2
- package/src/backend/codex/discovery.ts +2 -2
- package/src/backend/codex/plan-usage.ts +2 -2
- package/src/backend/codex/token-usage.ts +2 -2
- package/src/core/auth/status.ts +3 -3
- package/src/core/backup/plan.ts +4 -4
- package/src/core/backup/restore.ts +3 -3
- package/src/core/backup/snapshot.ts +3 -2
- package/src/core/backup/sources/sessions.ts +29 -0
- package/src/core/engine/gateway-actions/native/params.ts +3 -3
- package/src/core/layout/claude-relink.ts +131 -0
- package/src/core/layout/index.ts +170 -0
- package/src/core/layout/mounts.ts +129 -0
- package/src/core/layout/persistence.ts +132 -0
- package/src/plugins/mempalace/provision.ts +4 -4
- package/src/plugins/playwright/provision.ts +2 -2
- package/src/util/fs-path.ts +103 -3
- package/src/util/paths.ts +6 -2
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -212,6 +212,14 @@ const { config } = await withConfigGuard(() =>
|
|
|
212
212
|
),
|
|
213
213
|
);
|
|
214
214
|
|
|
215
|
+
// Before any backend resumes a session: carry Claude transcripts over a
|
|
216
|
+
// Talon-home move (their project slug is the cwd), and in a container
|
|
217
|
+
// alert when a backend's session store isn't on a persistent volume.
|
|
218
|
+
await bootPhase("storage layout", async () => {
|
|
219
|
+
const { runStorageLayoutChecks } = await import("./core/layout/index.js");
|
|
220
|
+
await runStorageLayoutChecks(config as unknown as Record<string, unknown>);
|
|
221
|
+
});
|
|
222
|
+
|
|
215
223
|
// Record this process as the daemon. The gateway port is appended once
|
|
216
224
|
// the gateway binds (it may fall back from the default on EADDRINUSE).
|
|
217
225
|
const bootedAt = new Date().toISOString();
|
package/src/backend/agy/auth.ts
CHANGED
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
* the host.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
17
|
import { join } from "node:path";
|
|
19
18
|
import { readFileSync } from "node:fs";
|
|
19
|
+
import { userHome } from "../../util/fs-path.js";
|
|
20
20
|
|
|
21
21
|
/** Where the CLI caches its OAuth token. Env override for tests. */
|
|
22
22
|
function agyTokenPath(override?: string): string {
|
|
23
23
|
return (
|
|
24
24
|
override ||
|
|
25
25
|
process.env.TALON_AGY_TOKEN_FILE ||
|
|
26
|
-
join(
|
|
26
|
+
join(userHome(), ".gemini", "antigravity-cli", "antigravity-oauth-token")
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
* touch the real files.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { homedir } from "node:os";
|
|
32
31
|
import { join, dirname } from "node:path";
|
|
33
32
|
import { randomBytes } from "node:crypto";
|
|
34
33
|
import {
|
|
@@ -47,6 +46,7 @@ import { frontendsForChat } from "../../runtime/frontends.js";
|
|
|
47
46
|
import { logWarn } from "../../../util/log.js";
|
|
48
47
|
import { gatewayAuthHeaders } from "../../../core/engine/gateway-auth.js";
|
|
49
48
|
import { AGY_MCP_PREFIX } from "../constants.js";
|
|
49
|
+
import { userHome } from "../../../util/fs-path.js";
|
|
50
50
|
|
|
51
51
|
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
52
52
|
|
|
@@ -55,7 +55,7 @@ function agyMcpConfigPath(override?: string): string {
|
|
|
55
55
|
return (
|
|
56
56
|
override ||
|
|
57
57
|
process.env.TALON_AGY_MCP_CONFIG ||
|
|
58
|
-
join(
|
|
58
|
+
join(userHome(), ".gemini", "config", "mcp_config.json")
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -64,7 +64,7 @@ function agyMcpSnapshotDir(override?: string): string {
|
|
|
64
64
|
return (
|
|
65
65
|
override ||
|
|
66
66
|
process.env.TALON_AGY_MCP_SNAPSHOT_DIR ||
|
|
67
|
-
join(
|
|
67
|
+
join(userHome(), ".gemini", "antigravity-cli", "mcp")
|
|
68
68
|
);
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { readFile } from "node:fs/promises";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
16
|
import { join } from "node:path";
|
|
18
17
|
import { logWarn } from "../../../util/log.js";
|
|
19
18
|
import type {
|
|
20
19
|
PlanUsage,
|
|
21
20
|
PlanWindow,
|
|
22
21
|
} from "../../../core/agent-runtime/capabilities.js";
|
|
22
|
+
import { userHome } from "../../../util/fs-path.js";
|
|
23
23
|
|
|
24
24
|
// `cedar_ember=1` asks the endpoint to include banked limit resets (the
|
|
25
25
|
// claude.ai "Reset for free" grants); `skip_spend=1` drops the spend block we
|
|
@@ -38,7 +38,7 @@ let inFlight: Promise<PlanUsage | undefined> | undefined;
|
|
|
38
38
|
function credentialsPath(): string {
|
|
39
39
|
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
40
40
|
return join(
|
|
41
|
-
configDir && configDir.length > 0 ? configDir : join(
|
|
41
|
+
configDir && configDir.length > 0 ? configDir : join(userHome(), ".claude"),
|
|
42
42
|
".credentials.json",
|
|
43
43
|
);
|
|
44
44
|
}
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import { promises as fs } from "node:fs";
|
|
42
|
-
import { homedir } from "node:os";
|
|
43
42
|
import { join } from "node:path";
|
|
44
43
|
import { log, logDebug } from "../../util/log.js";
|
|
45
44
|
import { getState } from "./state.js";
|
|
46
45
|
import type { CodexAuthInfo } from "./auth.js";
|
|
47
46
|
import { normalizeReasoningLevels } from "../../core/models/reasoning-levels.js";
|
|
47
|
+
import { userHome } from "../../util/fs-path.js";
|
|
48
48
|
|
|
49
49
|
/** Shape of one entry returned by OpenAI's `/v1/models`. Sparse — only `id` is reliably present. */
|
|
50
50
|
interface OpenAiModelEntry {
|
|
@@ -99,7 +99,7 @@ export function getCodexCachePath(): string {
|
|
|
99
99
|
const home = process.env.CODEX_HOME?.trim();
|
|
100
100
|
return home && home.length > 0
|
|
101
101
|
? join(home, "models_cache.json")
|
|
102
|
-
: join(
|
|
102
|
+
: join(userHome(), ".codex", "models_cache.json");
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/** Default soft timeout when callers await an in-flight discovery. */
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { readFile, stat } from "node:fs/promises";
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
15
|
import { join } from "node:path";
|
|
17
16
|
import { logWarn } from "../../util/log.js";
|
|
18
17
|
import type {
|
|
19
18
|
PlanUsage,
|
|
20
19
|
PlanWindow,
|
|
21
20
|
} from "../../core/agent-runtime/capabilities.js";
|
|
21
|
+
import { userHome } from "../../util/fs-path.js";
|
|
22
22
|
|
|
23
23
|
const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
24
24
|
const REQUEST_TIMEOUT_MS = 5_000;
|
|
@@ -37,7 +37,7 @@ function authPath(): string {
|
|
|
37
37
|
const home = process.env.CODEX_HOME?.trim();
|
|
38
38
|
return home && home.length > 0
|
|
39
39
|
? join(home, "auth.json")
|
|
40
|
-
: join(
|
|
40
|
+
: join(userHome(), ".codex", "auth.json");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
interface CodexAuth {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { readFile, readdir } from "node:fs/promises";
|
|
32
|
-
import { homedir } from "node:os";
|
|
33
32
|
import { join } from "node:path";
|
|
33
|
+
import { userHome } from "../../util/fs-path.js";
|
|
34
34
|
|
|
35
35
|
export interface CodexLastTokenUsage {
|
|
36
36
|
/** Last API call's prompt size = current context fill in tokens. */
|
|
@@ -120,7 +120,7 @@ export interface CodexRolloutSnapshot {
|
|
|
120
120
|
* Codex CLI resolves its data directory.
|
|
121
121
|
*/
|
|
122
122
|
function codexHome(): string {
|
|
123
|
-
return process.env.CODEX_HOME ?? join(
|
|
123
|
+
return process.env.CODEX_HOME ?? join(userHome(), ".codex");
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
package/src/core/auth/status.ts
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { readFile, stat } from "node:fs/promises";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
16
|
import { join } from "node:path";
|
|
18
17
|
import { logWarn } from "../../util/log.js";
|
|
18
|
+
import { userHome } from "../../util/fs-path.js";
|
|
19
19
|
|
|
20
20
|
export type AuthProvider = "claude" | "codex";
|
|
21
21
|
|
|
@@ -44,14 +44,14 @@ export function claudeCredentialsPath(
|
|
|
44
44
|
): string {
|
|
45
45
|
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
46
46
|
return join(
|
|
47
|
-
configDir ? configDir : join(
|
|
47
|
+
configDir ? configDir : join(userHome(), ".claude"),
|
|
48
48
|
".credentials.json",
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function codexAuthPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
53
53
|
const home = env.CODEX_HOME?.trim();
|
|
54
|
-
return join(home ? home : join(
|
|
54
|
+
return join(home ? home : join(userHome(), ".codex"), "auth.json");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Providers the backends reported as dead since boot (cleared on login). */
|
package/src/core/backup/plan.ts
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
import { lstat, readdir, readlink } from "node:fs/promises";
|
|
23
23
|
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
24
|
-
import { homedir } from "node:os";
|
|
25
24
|
import type { BackupSettings } from "./types.js";
|
|
25
|
+
import { userHome } from "../../util/fs-path.js";
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* The workspace is mostly machine-generated bulk (uploads, media, build
|
|
@@ -219,9 +219,9 @@ export function workspaceRoots(patterns: readonly string[]): string[] {
|
|
|
219
219
|
/** Expand a leading `~` and resolve against the home directory. */
|
|
220
220
|
export function expandUserPath(path: string): string {
|
|
221
221
|
const trimmed = path.trim();
|
|
222
|
-
if (trimmed === "~") return
|
|
223
|
-
if (trimmed.startsWith("~/")) return resolve(
|
|
224
|
-
return isAbsolute(trimmed) ? resolve(trimmed) : resolve(
|
|
222
|
+
if (trimmed === "~") return userHome();
|
|
223
|
+
if (trimmed.startsWith("~/")) return resolve(userHome(), trimmed.slice(2));
|
|
224
|
+
return isAbsolute(trimmed) ? resolve(trimmed) : resolve(userHome(), trimmed);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
/** One member the builder will hand to the tar writer. */
|
|
@@ -36,7 +36,6 @@ import {
|
|
|
36
36
|
unlink,
|
|
37
37
|
copyFile,
|
|
38
38
|
} from "node:fs/promises";
|
|
39
|
-
import { homedir } from "node:os";
|
|
40
39
|
import { dirname, join, resolve } from "node:path";
|
|
41
40
|
import writeFileAtomic from "write-file-atomic";
|
|
42
41
|
import { dirs } from "../../util/paths.js";
|
|
@@ -73,6 +72,7 @@ import {
|
|
|
73
72
|
} from "./store.js";
|
|
74
73
|
import type { BackupTarget } from "./targets.js";
|
|
75
74
|
import type { BackupSettings, Manifest, SnapshotPart } from "./types.js";
|
|
75
|
+
import { userHome } from "../../util/fs-path.js";
|
|
76
76
|
|
|
77
77
|
/** A staged request older than this is stale and ignored. */
|
|
78
78
|
export const RESTORE_PENDING_MAX_AGE_MS = 10 * 60_000;
|
|
@@ -571,7 +571,7 @@ export async function restoreSnapshot(
|
|
|
571
571
|
const realHome = options.home === undefined;
|
|
572
572
|
const cloneTarget: CloneTarget = {
|
|
573
573
|
home,
|
|
574
|
-
userHome: options.userHome ??
|
|
574
|
+
userHome: options.userHome ?? userHome(),
|
|
575
575
|
env: options.env ?? (realHome ? process.env : {}),
|
|
576
576
|
};
|
|
577
577
|
const missing = await missingParts(manifest, home);
|
|
@@ -597,7 +597,7 @@ export async function restoreSnapshot(
|
|
|
597
597
|
pinned: true,
|
|
598
598
|
settings: options.settings,
|
|
599
599
|
home,
|
|
600
|
-
userHome: options.userHome ?? (realHome ?
|
|
600
|
+
userHome: options.userHome ?? (realHome ? userHome() : null),
|
|
601
601
|
env: cloneTarget.env,
|
|
602
602
|
});
|
|
603
603
|
checkpointId = checkpoint.id;
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
stat,
|
|
30
30
|
writeFile,
|
|
31
31
|
} from "node:fs/promises";
|
|
32
|
-
import {
|
|
32
|
+
import { hostname } from "node:os";
|
|
33
33
|
import { dirname, join, resolve } from "node:path";
|
|
34
34
|
import { pipeline } from "node:stream/promises";
|
|
35
35
|
import { dirs } from "../../util/paths.js";
|
|
@@ -89,6 +89,7 @@ import type {
|
|
|
89
89
|
SnapshotKind,
|
|
90
90
|
SnapshotPart,
|
|
91
91
|
} from "./types.js";
|
|
92
|
+
import { userHome } from "../../util/fs-path.js";
|
|
92
93
|
|
|
93
94
|
/** The part that holds WhatsApp auth and the userbot session. */
|
|
94
95
|
const LOGINS_PART = "logins.tar.zst";
|
|
@@ -467,7 +468,7 @@ async function sourceContext(
|
|
|
467
468
|
options.userHome !== undefined
|
|
468
469
|
? options.userHome
|
|
469
470
|
: realHome
|
|
470
|
-
?
|
|
471
|
+
? userHome()
|
|
471
472
|
: null,
|
|
472
473
|
env: options.env ?? (realHome ? process.env : {}),
|
|
473
474
|
config: await readConfigJson(home),
|
|
@@ -179,6 +179,35 @@ async function storeRoots(
|
|
|
179
179
|
return roots;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The directory each configured backend keeps its sessions (and sign-in)
|
|
184
|
+
* under, whether or not it exists yet. Claude is always listed: it is the
|
|
185
|
+
* default backend. The container storage check uses this to tell which
|
|
186
|
+
* of them would vanish with the container.
|
|
187
|
+
*/
|
|
188
|
+
export function backendStoreDirs(
|
|
189
|
+
userHome: string,
|
|
190
|
+
env: SourceContext["env"],
|
|
191
|
+
config: Record<string, unknown>,
|
|
192
|
+
): { backend: string; path: string }[] {
|
|
193
|
+
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
194
|
+
const out = [
|
|
195
|
+
{ backend: "claude", path: configDir || join(userHome, ".claude") },
|
|
196
|
+
];
|
|
197
|
+
const backends = enabledBackends(config);
|
|
198
|
+
for (const spec of STORES) {
|
|
199
|
+
if (!backends.has(spec.backend)) continue;
|
|
200
|
+
const dir = spec.dir(userHome, env);
|
|
201
|
+
// agy's store is one level inside ~/.gemini, which also holds its
|
|
202
|
+
// sign-in: check the whole directory.
|
|
203
|
+
out.push({
|
|
204
|
+
backend: spec.backend,
|
|
205
|
+
path: spec.backend === "agy" ? join(userHome, ".gemini") : dir,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
182
211
|
/**
|
|
183
212
|
* Every session root this machine has for the configured backends, in a
|
|
184
213
|
* deterministic order. Nothing outside the Talon home is looked at when
|
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
* something the handlers can use.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
7
|
import { join } from "node:path";
|
|
8
|
+
import { userHome } from "../../../../util/fs-path.js";
|
|
9
9
|
|
|
10
10
|
// — path parameter resolution ----------------------------------------------
|
|
11
11
|
|
|
12
12
|
/** Expand a leading `~` — local runs only; a device's home is not ours. */
|
|
13
13
|
function expandHome(path: string): string {
|
|
14
|
-
if (path === "~") return
|
|
15
|
-
if (path.startsWith("~/")) return join(
|
|
14
|
+
if (path === "~") return userHome();
|
|
15
|
+
if (path.startsWith("~/")) return join(userHome(), path.slice(2));
|
|
16
16
|
return path;
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep Claude transcripts resumable when the Talon home moves.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code files a session's transcript under
|
|
5
|
+
* `~/.claude/projects/<slug>/`, where the slug IS the absolute cwd with
|
|
6
|
+
* every non-alphanumeric character turned into `-`. Talon's cwd is
|
|
7
|
+
* `<TALON_HOME>/workspace`, so moving the home — `/home/bun/.talon` to
|
|
8
|
+
* `/data/.talon` when a container switches to the single data root, or a
|
|
9
|
+
* new TALON_HOME on a host — changes the slug, and every stored session
|
|
10
|
+
* id then points at a transcript Claude can no longer find.
|
|
11
|
+
*
|
|
12
|
+
* This copies the old slug directories' files under the new slug. It
|
|
13
|
+
* never overwrites a file that already exists there and never moves or
|
|
14
|
+
* deletes the originals: the old directories stay as they were.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { constants } from "node:fs";
|
|
18
|
+
import { copyFile, mkdir, readdir } from "node:fs/promises";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { relocateClaudeSlug } from "../backup/sources/relocate.js";
|
|
21
|
+
import { claudeProjectSlug, sessionCwds } from "../backup/sources/sessions.js";
|
|
22
|
+
|
|
23
|
+
export type RelinkResult = {
|
|
24
|
+
/** The old slug directory, left in place. */
|
|
25
|
+
from: string;
|
|
26
|
+
/** The directory the current home's sessions are looked up in. */
|
|
27
|
+
to: string;
|
|
28
|
+
/** Files copied (existing ones are skipped, never replaced). */
|
|
29
|
+
copied: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function matchesSlugOf(name: string, home: string): boolean {
|
|
33
|
+
return sessionCwds(home)
|
|
34
|
+
.map(claudeProjectSlug)
|
|
35
|
+
.some((slug) => name === slug || name.startsWith(`${slug}-`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Copy `src` into `dst` recursively; existing files win. Returns files copied. */
|
|
39
|
+
export async function mergeCopy(src: string, dst: string): Promise<number> {
|
|
40
|
+
await mkdir(dst, { recursive: true });
|
|
41
|
+
let copied = 0;
|
|
42
|
+
for (const entry of await readdir(src, { withFileTypes: true })) {
|
|
43
|
+
const from = join(src, entry.name);
|
|
44
|
+
const to = join(dst, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
copied += await mergeCopy(from, to);
|
|
47
|
+
} else if (entry.isFile()) {
|
|
48
|
+
try {
|
|
49
|
+
await copyFile(from, to, constants.COPYFILE_EXCL);
|
|
50
|
+
copied++;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return copied;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function listDir(dir: string): Promise<string[]> {
|
|
60
|
+
try {
|
|
61
|
+
return (await readdir(dir, { withFileTypes: true }))
|
|
62
|
+
.filter((e) => e.isDirectory())
|
|
63
|
+
.map((e) => e.name)
|
|
64
|
+
.sort();
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Old-slug directories to carry over: every project dir, in any of the
|
|
72
|
+
* `sources` projects directories, that belongs to one of `previousHomes`
|
|
73
|
+
* and not to `home`.
|
|
74
|
+
*/
|
|
75
|
+
async function findOldSlugDirs(input: {
|
|
76
|
+
home: string;
|
|
77
|
+
previousHomes: readonly string[];
|
|
78
|
+
sources: readonly string[];
|
|
79
|
+
}): Promise<{ from: string; name: string; previousHome: string }[]> {
|
|
80
|
+
const found: { from: string; name: string; previousHome: string }[] = [];
|
|
81
|
+
for (const dir of new Set(input.sources)) {
|
|
82
|
+
const names = await listDir(dir);
|
|
83
|
+
for (const previousHome of new Set(input.previousHomes)) {
|
|
84
|
+
if (previousHome === input.home) continue;
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
if (!matchesSlugOf(name, previousHome)) continue;
|
|
87
|
+
if (matchesSlugOf(name, input.home)) continue;
|
|
88
|
+
found.push({ from: join(dir, name), name, previousHome });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Copy transcripts filed under a previous home's slug to the current
|
|
97
|
+
* home's slug. `projectsDir` is where the current Claude looks;
|
|
98
|
+
* `extraSources` are other projects directories to read from (an old
|
|
99
|
+
* `~/.claude` still mounted during a migration). Sources in `skip` were
|
|
100
|
+
* carried over on an earlier boot. A directory that fails to copy is
|
|
101
|
+
* reported through `onError` and left for the next boot.
|
|
102
|
+
*/
|
|
103
|
+
export async function relinkClaudeProjects(input: {
|
|
104
|
+
home: string;
|
|
105
|
+
projectsDir: string;
|
|
106
|
+
previousHomes: readonly string[];
|
|
107
|
+
extraSources?: readonly string[];
|
|
108
|
+
skip?: ReadonlySet<string>;
|
|
109
|
+
onError?: (from: string, err: unknown) => void;
|
|
110
|
+
}): Promise<RelinkResult[]> {
|
|
111
|
+
const olds = await findOldSlugDirs({
|
|
112
|
+
home: input.home,
|
|
113
|
+
previousHomes: input.previousHomes,
|
|
114
|
+
sources: [input.projectsDir, ...(input.extraSources ?? [])],
|
|
115
|
+
});
|
|
116
|
+
const results: RelinkResult[] = [];
|
|
117
|
+
for (const { from, name, previousHome } of olds) {
|
|
118
|
+
if (input.skip?.has(from)) continue;
|
|
119
|
+
const to = join(
|
|
120
|
+
input.projectsDir,
|
|
121
|
+
relocateClaudeSlug(name, previousHome, input.home),
|
|
122
|
+
);
|
|
123
|
+
if (to === from) continue;
|
|
124
|
+
try {
|
|
125
|
+
results.push({ from, to, copied: await mergeCopy(from, to) });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
input.onError?.(from, err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage layout at boot: keep Claude transcripts reachable across a
|
|
3
|
+
* Talon-home move, and (in a container) warn when a backend's session
|
|
4
|
+
* store is not on a persistent volume. See docs/docker.md.
|
|
5
|
+
*
|
|
6
|
+
* Neither step throws: both are advisory, and a boot must never fail
|
|
7
|
+
* because a check could not read a directory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, mkdir } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import writeFileAtomic from "write-file-atomic";
|
|
14
|
+
import { claudeProjectsDir } from "../backup/sources/sessions.js";
|
|
15
|
+
import { raiseAlert } from "../frontend-runtime/alerts.js";
|
|
16
|
+
import { dirs } from "../../util/paths.js";
|
|
17
|
+
import { log, logWarn } from "../../util/log.js";
|
|
18
|
+
import { relinkClaudeProjects, type RelinkResult } from "./claude-relink.js";
|
|
19
|
+
import { readMountTable, type MountEntry } from "./mounts.js";
|
|
20
|
+
import {
|
|
21
|
+
describeFindings,
|
|
22
|
+
findEphemeralStores,
|
|
23
|
+
inContainer,
|
|
24
|
+
type StorageFinding,
|
|
25
|
+
} from "./persistence.js";
|
|
26
|
+
|
|
27
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Talon homes the published image has used. In a container either may
|
|
31
|
+
* hold the transcripts of a deployment that moved to the other.
|
|
32
|
+
*/
|
|
33
|
+
const CONTAINER_HOMES = ["/home/bun/.talon", "/data/.talon"] as const;
|
|
34
|
+
/** The pre-/data image's HOME, where an old `~/.claude` may still be mounted. */
|
|
35
|
+
const LEGACY_USER_HOME = "/home/bun";
|
|
36
|
+
|
|
37
|
+
/** What the last boot recorded: the home it ran in, dirs already copied. */
|
|
38
|
+
type RelinkState = { home: string; merged: string[] };
|
|
39
|
+
|
|
40
|
+
async function readState(file: string): Promise<RelinkState | null> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(await readFile(file, "utf8")) as unknown;
|
|
43
|
+
if (!raw || typeof raw !== "object") return null;
|
|
44
|
+
const { home, merged } = raw as Record<string, unknown>;
|
|
45
|
+
if (typeof home !== "string") return null;
|
|
46
|
+
return {
|
|
47
|
+
home,
|
|
48
|
+
merged: Array.isArray(merged)
|
|
49
|
+
? merged.filter((m): m is string => typeof m === "string")
|
|
50
|
+
: [],
|
|
51
|
+
};
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Carry Claude transcripts over from a previous Talon home's project slug.
|
|
59
|
+
* The previous home comes from the state file (which travels with a copied
|
|
60
|
+
* `.talon`, so it names the old path after a migration) and, in a
|
|
61
|
+
* container, from the image's known homes.
|
|
62
|
+
*/
|
|
63
|
+
export async function relinkAfterHomeMove(opts: {
|
|
64
|
+
home: string;
|
|
65
|
+
userHome: string;
|
|
66
|
+
env: Env;
|
|
67
|
+
container: boolean;
|
|
68
|
+
stateFile: string;
|
|
69
|
+
}): Promise<RelinkResult[]> {
|
|
70
|
+
const state = await readState(opts.stateFile);
|
|
71
|
+
const previousHomes = [
|
|
72
|
+
...(state ? [state.home] : []),
|
|
73
|
+
...(opts.container ? CONTAINER_HOMES : []),
|
|
74
|
+
].filter((h) => h !== opts.home);
|
|
75
|
+
const projectsDir = claudeProjectsDir(opts.userHome, opts.env);
|
|
76
|
+
const legacyProjects = join(LEGACY_USER_HOME, ".claude", "projects");
|
|
77
|
+
const results = await relinkClaudeProjects({
|
|
78
|
+
home: opts.home,
|
|
79
|
+
projectsDir,
|
|
80
|
+
previousHomes,
|
|
81
|
+
extraSources:
|
|
82
|
+
opts.container && legacyProjects !== projectsDir ? [legacyProjects] : [],
|
|
83
|
+
skip: new Set(state?.merged ?? []),
|
|
84
|
+
onError: (from, err) =>
|
|
85
|
+
logWarn(
|
|
86
|
+
"sessions",
|
|
87
|
+
`Could not copy Claude transcripts from ${from}: ${String(err)} (will retry next boot)`,
|
|
88
|
+
),
|
|
89
|
+
});
|
|
90
|
+
for (const r of results) {
|
|
91
|
+
log(
|
|
92
|
+
"sessions",
|
|
93
|
+
`Talon home moved: copied ${r.copied} Claude transcript file(s) from ${r.from} to ${r.to} (the original is kept)`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (state?.home !== opts.home || results.length > 0) {
|
|
97
|
+
const merged = [...(state?.merged ?? []), ...results.map((r) => r.from)];
|
|
98
|
+
await mkdir(dirname(opts.stateFile), { recursive: true });
|
|
99
|
+
await writeFileAtomic(
|
|
100
|
+
opts.stateFile,
|
|
101
|
+
JSON.stringify({ home: opts.home, merged: [...new Set(merged)] }) + "\n",
|
|
102
|
+
{ mode: 0o600 },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* In a container, raise one admin alert listing every store that would
|
|
110
|
+
* not survive a recreate. Returns the findings (empty outside a container
|
|
111
|
+
* or when the check is switched off with TALON_STORAGE_CHECK=0).
|
|
112
|
+
*/
|
|
113
|
+
export function checkContainerStorage(opts: {
|
|
114
|
+
talonHome: string;
|
|
115
|
+
userHome: string;
|
|
116
|
+
env: Env;
|
|
117
|
+
config: Record<string, unknown>;
|
|
118
|
+
container: boolean;
|
|
119
|
+
mounts?: readonly MountEntry[];
|
|
120
|
+
raise?: typeof raiseAlert;
|
|
121
|
+
}): StorageFinding[] {
|
|
122
|
+
if (!opts.container || opts.env.TALON_STORAGE_CHECK === "0") return [];
|
|
123
|
+
const findings = findEphemeralStores({
|
|
124
|
+
...opts,
|
|
125
|
+
mounts: opts.mounts ?? readMountTable(),
|
|
126
|
+
});
|
|
127
|
+
if (findings.length === 0) return [];
|
|
128
|
+
// Losing only Claude's account file costs a re-onboarding; losing a
|
|
129
|
+
// session store costs every conversation.
|
|
130
|
+
const onlyAccountFile = findings.every((f) =>
|
|
131
|
+
f.path.endsWith(".claude.json"),
|
|
132
|
+
);
|
|
133
|
+
(opts.raise ?? raiseAlert)(
|
|
134
|
+
"storage.ephemeral",
|
|
135
|
+
describeFindings(findings, opts.env),
|
|
136
|
+
{ severity: onlyAccountFile ? "warn" : "error" },
|
|
137
|
+
);
|
|
138
|
+
return findings;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Both steps, with this process's paths. Never throws. */
|
|
142
|
+
export async function runStorageLayoutChecks(
|
|
143
|
+
config: Record<string, unknown>,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const env = process.env;
|
|
146
|
+
const container = inContainer(env);
|
|
147
|
+
const userHome = homedir();
|
|
148
|
+
try {
|
|
149
|
+
await relinkAfterHomeMove({
|
|
150
|
+
home: dirs.root,
|
|
151
|
+
userHome,
|
|
152
|
+
env,
|
|
153
|
+
container,
|
|
154
|
+
stateFile: join(dirs.data, "claude-relink.json"),
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
logWarn("sessions", `Claude transcript relink failed: ${String(err)}`);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
checkContainerStorage({
|
|
161
|
+
talonHome: dirs.root,
|
|
162
|
+
userHome,
|
|
163
|
+
env,
|
|
164
|
+
config,
|
|
165
|
+
container,
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
logWarn("bot", `Container storage check failed: ${String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which filesystem a path lives on, read from /proc/self/mountinfo.
|
|
3
|
+
*
|
|
4
|
+
* Inside a container the root filesystem is the image's writable layer:
|
|
5
|
+
* whatever is written there is gone the moment the container is recreated
|
|
6
|
+
* (an image update, a NAS app redeploy). Only bind mounts and named
|
|
7
|
+
* volumes outlive it. This module answers "is this path on one of those?"
|
|
8
|
+
* without shelling out to `mountpoint` or `df`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
12
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** One line of /proc/self/mountinfo, the fields this module needs. */
|
|
15
|
+
export type MountEntry = {
|
|
16
|
+
/** Where it is mounted, in this mount namespace. */
|
|
17
|
+
mountPoint: string;
|
|
18
|
+
/** The directory of the source filesystem that is mounted there. */
|
|
19
|
+
root: string;
|
|
20
|
+
fsType: string;
|
|
21
|
+
source: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Where a path's data lives, as far as surviving a container recreate goes. */
|
|
25
|
+
export type Persistence =
|
|
26
|
+
| "persistent"
|
|
27
|
+
/** The container's own writable layer (or a tmpfs): lost on recreate. */
|
|
28
|
+
| "ephemeral"
|
|
29
|
+
/** An anonymous Docker volume: lost when the container is removed. */
|
|
30
|
+
| "anonymous";
|
|
31
|
+
|
|
32
|
+
/** mountinfo escapes space, tab, newline and backslash as octal. */
|
|
33
|
+
function unescape(field: string): string {
|
|
34
|
+
return field.replace(/\\([0-7]{3})/g, (_, oct: string) =>
|
|
35
|
+
String.fromCharCode(parseInt(oct, 8)),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse mountinfo text (proc(5)): `id parent maj:min root mountpoint opts
|
|
41
|
+
* [optional…] - fstype source superopts`. Malformed lines are skipped.
|
|
42
|
+
*/
|
|
43
|
+
export function parseMountInfo(text: string): MountEntry[] {
|
|
44
|
+
const out: MountEntry[] = [];
|
|
45
|
+
for (const line of text.split("\n")) {
|
|
46
|
+
const fields = line.trim().split(" ");
|
|
47
|
+
const dash = fields.indexOf("-");
|
|
48
|
+
if (fields.length < 6 || dash < 6 || dash + 2 >= fields.length) continue;
|
|
49
|
+
out.push({
|
|
50
|
+
root: unescape(fields[3]),
|
|
51
|
+
mountPoint: unescape(fields[4]),
|
|
52
|
+
fsType: fields[dash + 1],
|
|
53
|
+
source: unescape(fields[dash + 2]),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function covers(mountPoint: string, path: string): boolean {
|
|
60
|
+
if (mountPoint === "/") return true;
|
|
61
|
+
return path === mountPoint || path.startsWith(mountPoint + "/");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The mount a path is on: the longest covering mount point, and among
|
|
66
|
+
* equal ones the last listed (a later mount on the same point shadows the
|
|
67
|
+
* earlier one).
|
|
68
|
+
*/
|
|
69
|
+
export function mountFor(
|
|
70
|
+
path: string,
|
|
71
|
+
mounts: readonly MountEntry[],
|
|
72
|
+
): MountEntry | undefined {
|
|
73
|
+
let best: MountEntry | undefined;
|
|
74
|
+
for (const m of mounts) {
|
|
75
|
+
if (!covers(m.mountPoint, path)) continue;
|
|
76
|
+
if (!best || m.mountPoint.length >= best.mountPoint.length) best = m;
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Docker names anonymous volumes by a 64-hex id; named ones by name. */
|
|
82
|
+
const ANONYMOUS_VOLUME = /\/volumes\/[0-9a-f]{64}\/_data(\/|$)/;
|
|
83
|
+
|
|
84
|
+
/** Classify the mount a path is on. A missing mount counts as ephemeral. */
|
|
85
|
+
export function classifyMount(mount: MountEntry | undefined): Persistence {
|
|
86
|
+
if (!mount || mount.mountPoint === "/" || mount.fsType === "tmpfs")
|
|
87
|
+
return "ephemeral";
|
|
88
|
+
if (ANONYMOUS_VOLUME.test(mount.root)) return "anonymous";
|
|
89
|
+
return "persistent";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The real path `path` resolves to once created: symlinks in the part that
|
|
94
|
+
* exists are followed (a `~/.claude` symlinked onto a volume is on that
|
|
95
|
+
* volume), the part that doesn't is appended as-is.
|
|
96
|
+
*/
|
|
97
|
+
function resolveExisting(path: string): string {
|
|
98
|
+
let head = path;
|
|
99
|
+
const tail: string[] = [];
|
|
100
|
+
while (!existsSync(head)) {
|
|
101
|
+
const parent = dirname(head);
|
|
102
|
+
if (parent === head) return path;
|
|
103
|
+
tail.unshift(relative(parent, head));
|
|
104
|
+
head = parent;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return tail.length ? join(realpathSync(head), ...tail) : realpathSync(head);
|
|
108
|
+
} catch {
|
|
109
|
+
return path;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read this process's mount table; empty when there is none (not Linux). */
|
|
114
|
+
export function readMountTable(file = "/proc/self/mountinfo"): MountEntry[] {
|
|
115
|
+
try {
|
|
116
|
+
return parseMountInfo(readFileSync(file, "utf8"));
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether `path` would survive the container being recreated. */
|
|
123
|
+
export function persistenceOf(
|
|
124
|
+
path: string,
|
|
125
|
+
mounts: readonly MountEntry[],
|
|
126
|
+
): Persistence {
|
|
127
|
+
const real = resolveExisting(path).split(sep).join("/");
|
|
128
|
+
return classifyMount(mountFor(real, mounts));
|
|
129
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot check: in a container, is every backend's session store on a
|
|
3
|
+
* volume that outlives the container?
|
|
4
|
+
*
|
|
5
|
+
* The image keeps everything under one data root (HOME=/data). A setup
|
|
6
|
+
* that maps only part of it — commonly just `~/.talon` in a NAS "custom
|
|
7
|
+
* app" — runs fine until the first image update, which silently takes
|
|
8
|
+
* every Claude/Codex/OpenCode transcript with it: the database still
|
|
9
|
+
* names those sessions, but nothing is left to resume. This check turns
|
|
10
|
+
* that into one admin alert at boot, naming each path and the fix.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { backendStoreDirs } from "../backup/sources/sessions.js";
|
|
16
|
+
import { persistenceOf, type MountEntry, type Persistence } from "./mounts.js";
|
|
17
|
+
|
|
18
|
+
export type StorageFinding = {
|
|
19
|
+
path: string;
|
|
20
|
+
/** What the operator loses with it. */
|
|
21
|
+
label: string;
|
|
22
|
+
persistence: Exclude<Persistence, "persistent">;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
26
|
+
|
|
27
|
+
/** Whether this process runs inside a container (Docker, Podman, the image). */
|
|
28
|
+
export function inContainer(
|
|
29
|
+
env: Env,
|
|
30
|
+
exists: (path: string) => boolean = existsSync,
|
|
31
|
+
): boolean {
|
|
32
|
+
return (
|
|
33
|
+
env.TALON_CONTAINER === "1" ||
|
|
34
|
+
exists("/.dockerenv") ||
|
|
35
|
+
exists("/run/.containerenv")
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LABELS: Record<string, string> = {
|
|
40
|
+
claude: "Claude Code transcripts and sign-in",
|
|
41
|
+
codex: "Codex sessions and sign-in",
|
|
42
|
+
opencode: "OpenCode sessions",
|
|
43
|
+
kilo: "Kilo sessions",
|
|
44
|
+
agy: "Antigravity sign-in and conversations",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Every path whose loss costs the operator something, with what it holds. */
|
|
48
|
+
function storagePaths(input: {
|
|
49
|
+
talonHome: string;
|
|
50
|
+
userHome: string;
|
|
51
|
+
env: Env;
|
|
52
|
+
config: Record<string, unknown>;
|
|
53
|
+
}): { path: string; label: string }[] {
|
|
54
|
+
const paths = [
|
|
55
|
+
{
|
|
56
|
+
path: input.talonHome,
|
|
57
|
+
label: "Talon home: config, chat history, memory, keys",
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
for (const store of backendStoreDirs(
|
|
61
|
+
input.userHome,
|
|
62
|
+
input.env,
|
|
63
|
+
input.config,
|
|
64
|
+
)) {
|
|
65
|
+
paths.push({
|
|
66
|
+
path: store.path,
|
|
67
|
+
label: LABELS[store.backend] ?? `${store.backend} sessions`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// Claude Code keeps its account/onboarding state next to ~/.claude,
|
|
71
|
+
// not inside it — unless CLAUDE_CONFIG_DIR moves both.
|
|
72
|
+
if (!input.env.CLAUDE_CONFIG_DIR?.trim()) {
|
|
73
|
+
paths.push({
|
|
74
|
+
path: join(input.userHome, ".claude.json"),
|
|
75
|
+
label: "Claude Code account state",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return paths;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The stores that would not survive a container recreate. Empty when the
|
|
83
|
+
* mount table is unknown: no table, no claim either way.
|
|
84
|
+
*/
|
|
85
|
+
export function findEphemeralStores(input: {
|
|
86
|
+
talonHome: string;
|
|
87
|
+
userHome: string;
|
|
88
|
+
env: Env;
|
|
89
|
+
config: Record<string, unknown>;
|
|
90
|
+
mounts: readonly MountEntry[];
|
|
91
|
+
}): StorageFinding[] {
|
|
92
|
+
if (input.mounts.length === 0) return [];
|
|
93
|
+
const findings: StorageFinding[] = [];
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
for (const { path, label } of storagePaths(input)) {
|
|
96
|
+
if (seen.has(path)) continue;
|
|
97
|
+
seen.add(path);
|
|
98
|
+
const persistence = persistenceOf(path, input.mounts);
|
|
99
|
+
if (persistence !== "persistent")
|
|
100
|
+
findings.push({ path, label, persistence });
|
|
101
|
+
}
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const WHERE: Record<StorageFinding["persistence"], string> = {
|
|
106
|
+
ephemeral: "container filesystem, lost on every image update",
|
|
107
|
+
anonymous: "anonymous Docker volume, lost when the container is removed",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The admin alert text for a set of findings. */
|
|
111
|
+
export function describeFindings(
|
|
112
|
+
findings: readonly StorageFinding[],
|
|
113
|
+
env: Env,
|
|
114
|
+
): string {
|
|
115
|
+
const lines = findings.map(
|
|
116
|
+
(f) => `• ${f.path}: ${f.label} (${WHERE[f.persistence]})`,
|
|
117
|
+
);
|
|
118
|
+
const legacy =
|
|
119
|
+
env.TALON_LAYOUT === "legacy"
|
|
120
|
+
? "\nThis container runs the old /home/bun layout (only some paths mounted)."
|
|
121
|
+
: "";
|
|
122
|
+
return (
|
|
123
|
+
"Some of Talon's state is not on a persistent volume and will be lost " +
|
|
124
|
+
"when this container is recreated:\n" +
|
|
125
|
+
lines.join("\n") +
|
|
126
|
+
legacy +
|
|
127
|
+
"\n\nFix: mount one volume at /data and run with HOME=/data (the image " +
|
|
128
|
+
"default), after copying the paths above into it. Nothing is moved for " +
|
|
129
|
+
'you. Steps: docs/docker.md, "Upgrading from the old layout".' +
|
|
130
|
+
"\nSilence this check with TALON_STORAGE_CHECK=0."
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
|
-
import { homedir } from "node:os";
|
|
25
24
|
import { dirname, join, resolve } from "node:path";
|
|
26
25
|
import type { DoctorCheck } from "../../core/doctor/index.js";
|
|
27
26
|
import {
|
|
@@ -41,6 +40,7 @@ import {
|
|
|
41
40
|
type ProvisionState,
|
|
42
41
|
} from "../../core/plugin/provision.js";
|
|
43
42
|
import { dirs, files } from "../../util/paths.js";
|
|
43
|
+
import { userHome } from "../../util/fs-path.js";
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The mempalace version Talon installs and reconciles the managed venv
|
|
@@ -89,7 +89,7 @@ export interface MempalaceSection {
|
|
|
89
89
|
*/
|
|
90
90
|
export function resolveMempalacePaths(
|
|
91
91
|
section: MempalaceSection | undefined,
|
|
92
|
-
home =
|
|
92
|
+
home = userHome(),
|
|
93
93
|
): { pythonPath: string; palacePath: string } {
|
|
94
94
|
return {
|
|
95
95
|
pythonPath: resolve(
|
|
@@ -166,7 +166,7 @@ export async function provisionMempalace(
|
|
|
166
166
|
const exec = deps.exec ?? runStep;
|
|
167
167
|
const platform = deps.platform ?? process.platform;
|
|
168
168
|
const now = deps.now ?? Date.now;
|
|
169
|
-
const home = deps.home ??
|
|
169
|
+
const home = deps.home ?? userHome();
|
|
170
170
|
const pathExists = deps.pathExists ?? existsSync;
|
|
171
171
|
const target = section.version ?? MEMPALACE_PINNED_VERSION;
|
|
172
172
|
|
|
@@ -606,7 +606,7 @@ export async function inspectMempalace(
|
|
|
606
606
|
): Promise<DoctorCheck[]> {
|
|
607
607
|
const exec = deps.exec ?? runStep;
|
|
608
608
|
const pathExists = deps.pathExists ?? existsSync;
|
|
609
|
-
const home = deps.home ??
|
|
609
|
+
const home = deps.home ?? userHome();
|
|
610
610
|
const { pythonPath: python } = resolveMempalacePaths(section, home);
|
|
611
611
|
const pin = section.version ?? MEMPALACE_PINNED_VERSION;
|
|
612
612
|
const managed =
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
17
|
import { dirname, join, resolve } from "node:path";
|
|
19
18
|
import type { DoctorCheck } from "../../core/doctor/index.js";
|
|
20
19
|
import {
|
|
@@ -34,6 +33,7 @@ import {
|
|
|
34
33
|
bundledPlaywrightVersion,
|
|
35
34
|
couplingError,
|
|
36
35
|
} from "./version-coupling.js";
|
|
36
|
+
import { userHome } from "../../util/fs-path.js";
|
|
37
37
|
|
|
38
38
|
/** Engines whose builds Playwright manages (vs system channels). */
|
|
39
39
|
const MANAGED_ENGINES = new Set(["chromium", "firefox", "webkit"]);
|
|
@@ -173,7 +173,7 @@ function browserPresent(
|
|
|
173
173
|
): boolean {
|
|
174
174
|
const root = browsersRoot(
|
|
175
175
|
deps.platform ?? process.platform,
|
|
176
|
-
deps.home ??
|
|
176
|
+
deps.home ?? userHome(),
|
|
177
177
|
deps.env ?? process.env,
|
|
178
178
|
);
|
|
179
179
|
const present = new Set((deps.listDir ?? safeListDir)(root));
|
package/src/util/fs-path.ts
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* gateway action bodies) into a `fs.*` or send-media API call, route
|
|
11
11
|
* it through `expandFsPath` first so the leading `~/` is replaced
|
|
12
12
|
* with the actual home directory.
|
|
13
|
+
*
|
|
14
|
+
* Also home to `userHome()` and the test-only home guard (below) that every
|
|
15
|
+
* home-derived default path goes through.
|
|
13
16
|
*/
|
|
14
17
|
import { homedir } from "node:os";
|
|
15
|
-
import { isAbsolute, resolve } from "node:path";
|
|
18
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Resolve a model-supplied path to an absolute on-disk path.
|
|
@@ -25,8 +28,105 @@ import { isAbsolute, resolve } from "node:path";
|
|
|
25
28
|
*/
|
|
26
29
|
export function expandFsPath(input: string): string {
|
|
27
30
|
if (!input) return input;
|
|
28
|
-
if (input === "~") return
|
|
29
|
-
if (input.startsWith("~/")) return resolve(
|
|
31
|
+
if (input === "~") return userHome();
|
|
32
|
+
if (input.startsWith("~/")) return resolve(userHome(), input.slice(2));
|
|
30
33
|
if (isAbsolute(input)) return input;
|
|
31
34
|
return resolve(process.cwd(), input);
|
|
32
35
|
}
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* ── Home guard ──────────────────────────────────────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* Test-only tripwire: refuse to resolve a path into the real user home.
|
|
41
|
+
*
|
|
42
|
+
* The suite runs on developer machines and on the production host (the
|
|
43
|
+
* pre-flight lane), where `~/.talon` is a live install. Every vitest worker
|
|
44
|
+
* gets a throwaway HOME / TALON_HOME (src/__tests__/setup/home-isolation.ts),
|
|
45
|
+
* but a test that resets the env, mocks `os.homedir()` or spawns a child
|
|
46
|
+
* with a stripped environment can still land back on the real tree, and
|
|
47
|
+
* functions such as backup prune/restore default to `dirs.root`. So the
|
|
48
|
+
* resolvers themselves check: under vitest, a root that is the real home,
|
|
49
|
+
* an ancestor of it, or anything inside the real `~/.talon` is an error
|
|
50
|
+
* before a single byte is read or written.
|
|
51
|
+
*
|
|
52
|
+
* The real home comes from the password database (`os.userInfo()`), which
|
|
53
|
+
* on Node ignores HOME/USERPROFILE, and `os` is fetched with
|
|
54
|
+
* `process.getBuiltinModule` so a `vi.mock("node:os")` in the calling suite
|
|
55
|
+
* cannot hide it. The global setup also records the pre-override home in
|
|
56
|
+
* TALON_TEST_REAL_HOME, the fallback for hosts with no passwd entry.
|
|
57
|
+
*
|
|
58
|
+
* Outside vitest this is a no-op.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** Env var the vitest global setup fills with the home it replaced. */
|
|
62
|
+
export const REAL_HOME_ENV = "TALON_TEST_REAL_HOME";
|
|
63
|
+
|
|
64
|
+
function underVitest(): boolean {
|
|
65
|
+
return Boolean(process.env.VITEST);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The homes this process must never resolve into (deduplicated). */
|
|
69
|
+
export function realHomes(): string[] {
|
|
70
|
+
const homes = new Set<string>();
|
|
71
|
+
// Bun's userInfo().homedir follows $HOME, so it would report the temp
|
|
72
|
+
// home as the real one; under Bun only the recorded value is trusted.
|
|
73
|
+
if (!process.versions.bun) {
|
|
74
|
+
try {
|
|
75
|
+
const os = process.getBuiltinModule("node:os");
|
|
76
|
+
const home = os.userInfo().homedir;
|
|
77
|
+
if (home) homes.add(resolve(home));
|
|
78
|
+
} catch {
|
|
79
|
+
// No passwd entry (arbitrary container uid) — fall back to the env.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const recorded = process.env[REAL_HOME_ENV];
|
|
83
|
+
if (recorded) homes.add(resolve(recorded));
|
|
84
|
+
return [...homes];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function norm(p: string): string {
|
|
88
|
+
return process.platform === "win32" ? p.toLowerCase() : p;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when `child` is `parent` or lies inside it. */
|
|
92
|
+
function within(child: string, parent: string): boolean {
|
|
93
|
+
const rel = relative(norm(parent), norm(child));
|
|
94
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True when `path` is a real home, an ancestor of one, or inside a real
|
|
99
|
+
* `~/.talon`. A path elsewhere under the real home is allowed: on Windows the
|
|
100
|
+
* temp dir itself lives in `%USERPROFILE%\AppData\Local\Temp`.
|
|
101
|
+
*/
|
|
102
|
+
export function touchesRealHome(path: string): boolean {
|
|
103
|
+
const target = resolve(path);
|
|
104
|
+
return realHomes().some(
|
|
105
|
+
(home) => within(home, target) || within(target, resolve(home, ".talon")),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Throw when running under vitest and `path` resolves into the real home.
|
|
111
|
+
* `what` names the resolver in the error.
|
|
112
|
+
*/
|
|
113
|
+
export function assertNotRealHome(path: string, what: string): void {
|
|
114
|
+
if (!underVitest()) return;
|
|
115
|
+
if (!touchesRealHome(path)) return;
|
|
116
|
+
throw new Error(
|
|
117
|
+
`[home-guard] ${what} resolved to ${resolve(path)}, which is the real ` +
|
|
118
|
+
`user home or its ~/.talon. Tests must run against the per-worker ` +
|
|
119
|
+
`temp home (src/__tests__/setup/home-isolation.ts); set TALON_HOME / ` +
|
|
120
|
+
`HOME to a temp dir instead.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `os.homedir()`, guarded: under vitest it throws rather than hand back the
|
|
126
|
+
* real home. Use it wherever a default path is built from the user home.
|
|
127
|
+
*/
|
|
128
|
+
export function userHome(): string {
|
|
129
|
+
const home = homedir();
|
|
130
|
+
assertNotRealHome(home, "os.homedir()");
|
|
131
|
+
return home;
|
|
132
|
+
}
|
package/src/util/paths.ts
CHANGED
|
@@ -32,16 +32,20 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { resolve } from "node:path";
|
|
35
|
-
import {
|
|
35
|
+
import { assertNotRealHome, userHome } from "./fs-path.js";
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Root of the Talon data directory: ~/.talon/, relocatable via the
|
|
39
39
|
* TALON_HOME environment variable (containers, systemd units, tests).
|
|
40
40
|
* Resolved once at import time — a mid-process override does nothing.
|
|
41
|
+
* Under vitest, resolving to the real ~/.talon throws (util/fs-path.ts):
|
|
42
|
+
* the check runs on every evaluation of this module, so a suite that
|
|
43
|
+
* re-imports it after changing the env is checked again.
|
|
41
44
|
*/
|
|
42
45
|
const TALON_ROOT = resolve(
|
|
43
|
-
process.env.TALON_HOME || resolve(
|
|
46
|
+
process.env.TALON_HOME || resolve(userHome(), ".talon"),
|
|
44
47
|
);
|
|
48
|
+
assertNotRealHome(TALON_ROOT, "dirs.root (TALON_HOME / ~/.talon)");
|
|
45
49
|
|
|
46
50
|
// ── Directories ────────────────────────────────────────────────────────────
|
|
47
51
|
|