@edspencer/paddock 0.61.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -4
- package/package.json +1 -1
- package/packages/server/dist/agent-errors.d.ts +41 -0
- package/packages/server/dist/agent-errors.js +127 -0
- package/packages/server/dist/app.js +57 -9
- package/packages/server/dist/claude-credentials.d.ts +116 -0
- package/packages/server/dist/claude-credentials.js +103 -0
- package/packages/server/dist/claude-home.d.ts +164 -25
- package/packages/server/dist/claude-home.js +528 -77
- package/packages/server/dist/claude-instructions.d.ts +121 -0
- package/packages/server/dist/claude-instructions.js +114 -0
- package/packages/server/dist/claude-mcp.d.ts +146 -0
- package/packages/server/dist/claude-mcp.js +367 -0
- package/packages/server/dist/claude-settings.d.ts +159 -0
- package/packages/server/dist/claude-settings.js +159 -0
- package/packages/server/dist/cli/args.d.ts +1 -1
- package/packages/server/dist/cli/args.js +39 -5
- package/packages/server/dist/cli/paddock.js +30 -6
- package/packages/server/dist/config.d.ts +166 -30
- package/packages/server/dist/config.js +106 -26
- package/packages/server/dist/herdctl-agent-config.d.ts +11 -1
- package/packages/server/dist/herdctl-agent-config.js +53 -7
- package/packages/server/dist/herdctl-agent-names.d.ts +11 -0
- package/packages/server/dist/herdctl-agent-names.js +31 -0
- package/packages/server/dist/herdctl.d.ts +69 -13
- package/packages/server/dist/herdctl.js +70 -18
- package/packages/server/dist/instance-config.js +36 -0
- package/packages/server/dist/mcp-servers.d.ts +168 -0
- package/packages/server/dist/mcp-servers.js +305 -0
- package/packages/server/dist/routes/chats.js +10 -4
- package/packages/server/dist/sweep.js +13 -1
- package/packages/server/dist/transcripts.d.ts +62 -24
- package/packages/server/dist/transcripts.js +141 -28
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-CzMqTdUU.js → InstanceConfigPage-Ca905oey.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-YSEZHWDe.js → MarkdownRenderer-ChlC1ezH.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-DLWxZbqK.js → ProjectRedirect-CslqbhUb.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-BFX8ff2n.js → ProjectView-DkNdilGo.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-DLOeY_ww.js → ProjectsGrid-BrlvLc-Y.js} +1 -1
- package/packages/web/dist/assets/{arc-BwC1ik-b.js → arc-CGqCI5nQ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CsVvXQWw.js → architectureDiagram-3BPJPVTR-Cp4KTxE2.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DXQk2bIa.js → blockDiagram-GPEHLZMM-yFxaTv04.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DXiA26It.js → c4Diagram-AAUBKEIU-59lhWjRt.js} +1 -1
- package/packages/web/dist/assets/channel-CZs6Z1i5.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-Dt5nC02A.js → chunk-2J33WTMH-BrpXGnJg.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DcjnWvl6.js → chunk-4BX2VUAB-UxBDb5pi.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CRa3LkU4.js → chunk-55IACEB6-D4ZJyUHW.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-H3V-11qu.js → chunk-727SXJPM-DWdPj_8E.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DPCrENYV.js → chunk-AQP2D5EJ-BIpNbflY.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-C4UO0ZTc.js → chunk-FMBD7UC4-f6iJDfdI.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-BtI37CnI.js → chunk-ND2GUHAM-Bz3TKIJk.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-C5bTaK5j.js → chunk-QZHKN3VN-buQmkafK.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CrbEUUCd.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CrbEUUCd.js +2 -0
- package/packages/web/dist/assets/{core-DB3cpm5N.js → core-TyFI6p0n.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-q-cjTZO4.js → cose-bilkent-S5V4N54A-CIkdi0XF.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DYEV5HFo.js → dagre-BM42HDAG-BnLJCfNs.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-D16XCw78.js → diagram-2AECGRRQ-D0OqfDB9.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-CeztryYE.js → diagram-5GNKFQAL-DQ2spGrK.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-D5SLIMDS.js → diagram-KO2AKTUF-RDIVQ_S3.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-Bw-sVPxM.js → diagram-LMA3HP47-DKoTfh2C.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DsRMITgx.js → diagram-OG6HWLK6-6k0OuSAL.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-T8Qqc_ku.js → erDiagram-TEJ5UH35-B-oQaYAC.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Cyx47UWU.js → flowDiagram-I6XJVG4X-B7qpYe9C.js} +1 -1
- package/packages/web/dist/assets/{format-D2I0duZa.js → format-PHdEBNOi.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DxYJmG9o.js → ganttDiagram-6RSMTGT7-SfBsqMmx.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-COK5YOTD.js → gitGraphDiagram-PVQCEYII-CuM4Rl_i.js} +1 -1
- package/packages/web/dist/assets/{index-wBXQMkGI.js → index-D_1ogxg1.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BamRN_Nn.js → infoDiagram-5YYISTIA-Dq-32Oj4.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DuOHdpEH.js → ishikawaDiagram-YF4QCWOH-BLFHCj_o.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Bri7GKTW.js → journeyDiagram-JHISSGLW-CHP-Yu63.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BdkgUnzI.js → kanban-definition-UN3LZRKU-DRuehSqn.js} +1 -1
- package/packages/web/dist/assets/{linear-DOcNPCpB.js → linear-Bwz2vnH7.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-D9SbVBpN.js → mermaid.core-CeMMnCip.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CVmLm7zn.js → mindmap-definition-RKZ34NQL-CEdqeoRW.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D0N10eVU.js → pieDiagram-4H26LBE5-BdDXBx87.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B5gnYdTa.js → quadrantDiagram-W4KKPZXB-Bovd1Zhx.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CwtxezNZ.js → requirementDiagram-4Y6WPE33-DiyrKnJ2.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D79O3cux.js → sankeyDiagram-5OEKKPKP-BuX7TRVw.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-xhKcI6tI.js → sequenceDiagram-3UESZ5HK-BjetZWJo.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DBYMAjcX.js → stateDiagram-AJRCARHV-4AUBV6dF.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Cq84MOtb.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-e-MYF4lm.js → timeline-definition-PNZ67QCA-BpQ8g8Bm.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B8UsvRDM.js → vennDiagram-CIIHVFJN-Cj99BWER.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-1ucACjTl.js → wardley-L42UT6IY-S7AGTOWw.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DJufLd_u.js → wardleyDiagram-YWT4CUSO-LNXkUA7S.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-qqaZNlu9.js → xychartDiagram-2RQKCTM6-DGdTh4Xk.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/channel-DDgTXrGP.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CZtr9Jvq.js +0 -2
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* READ-ONLY source. This module owns everything that follows from that:
|
|
16
16
|
*
|
|
17
17
|
* - **{@link ensureClaudeHome}** — create the home, bridge the user-level config
|
|
18
|
-
* that lives in `~/.claude`,
|
|
18
|
+
* that lives in `~/.claude`, put `claude.credentials` into the environment
|
|
19
|
+
* Claude Code runs in, and report anything an operator needs to know.
|
|
19
20
|
* - **{@link mirrorLegacyTranscriptFolders}** — keep adoption (#588) able to see
|
|
20
21
|
* the user's own CLI history, which by definition is in `~/.claude`.
|
|
21
22
|
*
|
|
@@ -30,42 +31,140 @@
|
|
|
30
31
|
* what a user wants; it is not paddock relocating their data behind their back.
|
|
31
32
|
* The thing #620 exists to stop — `ensureProjectChats` copying a user's
|
|
32
33
|
* transcripts out and then `fs.rm`-ing the originals, unprompted, inside a bare
|
|
33
|
-
* `catch {}` — is gone,
|
|
34
|
+
* `catch {}` — is gone: paddock's home is never the user's, so the directory it
|
|
35
|
+
* migrates can only be its own (#691, `resolveClaudeHome`).
|
|
36
|
+
*
|
|
37
|
+
* The one thing that DOES write under `~/.claude` is `claude.transcripts: host`,
|
|
38
|
+
* which creates the encoded folder its symlink points at. That is the user
|
|
39
|
+
* asking for their transcripts to be shared, by name, in a config file — see
|
|
40
|
+
* `ensureProjectChats`.
|
|
34
41
|
*/
|
|
35
42
|
import { promises as fs } from "node:fs";
|
|
43
|
+
import { createHash } from "node:crypto";
|
|
36
44
|
import path from "node:path";
|
|
37
45
|
import { encodePathForCli } from "@herdctl/core";
|
|
46
|
+
import { applyCredentialsMode, SECURE_STORAGE_DIR_VAR } from "./claude-credentials.js";
|
|
47
|
+
import { INSTRUCTION_ENTRIES } from "./claude-instructions.js";
|
|
48
|
+
import { planHostSettings, GENERATED_MARKER_FILE, SETTINGS_ENTRY, } from "./claude-settings.js";
|
|
38
49
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* Deliberately NOT bridged: `projects/` (that's the whole point), and everything
|
|
56
|
-
* that is per-instance runtime state — `todos/`, `shell-snapshots/`, `statsig/`,
|
|
57
|
-
* `sessions/`. Those are exactly what should now be per-instance.
|
|
50
|
+
* The user's file-based Claude Code login, bridged in only under
|
|
51
|
+
* `claude.credentials: host` (#691).
|
|
52
|
+
*
|
|
53
|
+
* This is what the lever means on Linux and in the Docker image, where `claude
|
|
54
|
+
* login` writes a file rather than using a keychain: on darwin `credentials: host`
|
|
55
|
+
* is the `CLAUDE_SECURESTORAGE_CONFIG_DIR` empty-string trick, here it is this
|
|
56
|
+
* symlink, and one config key has to mean the same thing on both.
|
|
57
|
+
*
|
|
58
|
+
* Symlinked rather than copied — a copy would duplicate a live secret onto disk
|
|
59
|
+
* AND diverge the moment Claude Code refreshed the token.
|
|
60
|
+
*
|
|
61
|
+
* It was bridged unconditionally before the lever existed, which is why
|
|
62
|
+
* `credentials` defaulting to `host` keeps every existing install working
|
|
63
|
+
* unchanged; `own` is the new behaviour, and it has to also REMOVE a link a
|
|
64
|
+
* previous boot planted, or "isolated" would be a claim that only holds for
|
|
65
|
+
* instances that were never anything else.
|
|
58
66
|
*/
|
|
59
|
-
export const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
export const CREDENTIAL_ENTRY = ".credentials.json";
|
|
68
|
+
/**
|
|
69
|
+
* Entries of the user's Claude home that paddock can bridge into its own — the
|
|
70
|
+
* complete list, for callers that want to reason about the surface rather than
|
|
71
|
+
* about one lever.
|
|
72
|
+
*
|
|
73
|
+
* There is no longer an "always bridged" set: since #691 step 4, every entry here
|
|
74
|
+
* belongs to exactly one key of the `claude:` block, and the mapping is the whole
|
|
75
|
+
* point of the block.
|
|
76
|
+
*
|
|
77
|
+
* | Entry | Lever |
|
|
78
|
+
* |---|---|
|
|
79
|
+
* | `CLAUDE.md`, `agents/`, `commands/`, `plugins/` | `claude.instructions` |
|
|
80
|
+
* | `settings.json` (its `hooks` key) | `claude.hooks` |
|
|
81
|
+
* | `.credentials.json` | `claude.credentials` |
|
|
82
|
+
*
|
|
83
|
+
* `settings.json` appears under `hooks` because that is the half of it a lever
|
|
84
|
+
* governs; its OTHER keys — permissions, model, statusline, `enabledPlugins` —
|
|
85
|
+
* are inherited in every mode, which is why `hooks: own` writes a filtered file
|
|
86
|
+
* rather than declining to bridge one. See `claude-settings.ts`.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately NOT bridged in any mode: `projects/` (that's the whole point), and
|
|
89
|
+
* everything that is per-instance runtime state — `todos/`, `shell-snapshots/`,
|
|
90
|
+
* `statsig/`, `sessions/`. Those are exactly what should be per-instance.
|
|
91
|
+
*
|
|
92
|
+
* The fifth lever, `claude.mcpServers`, has NO row here on purpose: MCP servers
|
|
93
|
+
* are declared in `~/.claude.json`, which is a sibling of the home rather than an
|
|
94
|
+
* entry inside it, so it is not bridgeable at all — paddock reads it and passes
|
|
95
|
+
* the servers programmatically (`claude-mcp.ts`). That asymmetry is the reason
|
|
96
|
+
* MCP inheritance broke separately from everything in this table.
|
|
97
|
+
*/
|
|
98
|
+
export const BRIDGEABLE_ENTRIES = [
|
|
99
|
+
SETTINGS_ENTRY,
|
|
100
|
+
...INSTRUCTION_ENTRIES,
|
|
101
|
+
CREDENTIAL_ENTRY,
|
|
66
102
|
];
|
|
67
103
|
/** Env vars that authenticate Claude Code without any on-disk credential. */
|
|
68
104
|
const TOKEN_ENV_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
|
|
105
|
+
/**
|
|
106
|
+
* Keychain service names Claude Code is known to store its login under.
|
|
107
|
+
*
|
|
108
|
+
* Only the unsuffixed form matters here: with `CLAUDE_CONFIG_DIR` set, Claude
|
|
109
|
+
* Code appends a path hash, and an entry under THAT name is one paddock's own
|
|
110
|
+
* home can already see. What we are looking for is the login made before paddock
|
|
111
|
+
* was involved.
|
|
112
|
+
*
|
|
113
|
+
* The first name is now **confirmed exact**, not guessed (#691): the SDK bundle
|
|
114
|
+
* builds it as `` `Claude Code${OAUTH_FILE_SUFFIX}${"-credentials"}${hash}` ``,
|
|
115
|
+
* with an empty production suffix and no hash when the config dir is unscoped —
|
|
116
|
+
* see `claude-credentials.ts`, which quotes the function. The second is kept as a
|
|
117
|
+
* cheap hedge against an older or future spelling; it costs one `security` call
|
|
118
|
+
* that fails fast, and {@link probeMacosKeychainLogin} is built so a miss is
|
|
119
|
+
* harmless either way.
|
|
120
|
+
*/
|
|
121
|
+
const KEYCHAIN_SERVICE_NAMES = ["Claude Code-credentials", "Claude Code"];
|
|
122
|
+
/**
|
|
123
|
+
* Ask the macOS Keychain whether a plain-name Claude Code login exists (#683).
|
|
124
|
+
*
|
|
125
|
+
* The whole value of this is turning "you appear to have no credentials" into
|
|
126
|
+
* "you ARE logged in; paddock cannot see it, and here is the one-liner" — a
|
|
127
|
+
* distinction paddock currently cannot make, and the difference between a
|
|
128
|
+
* message that describes a problem and one that fixes it.
|
|
129
|
+
*
|
|
130
|
+
* ## The asymmetry, which is deliberate
|
|
131
|
+
*
|
|
132
|
+
* A positive result is trustworthy: an entry under that service name is a login.
|
|
133
|
+
* A negative result is NOT — `security` can fail for a dozen reasons that have
|
|
134
|
+
* nothing to do with the user, and the service name, though now read straight out
|
|
135
|
+
* of the SDK bundle rather than guessed, is still Claude Code's internal detail
|
|
136
|
+
* and can change. So a miss returns `unknown`, and the caller falls back to the
|
|
137
|
+
* existing, correct, generic warning. Being wrong about the name therefore costs
|
|
138
|
+
* nothing beyond the improvement not happening.
|
|
139
|
+
*
|
|
140
|
+
* Never prompts and never reads a secret: `find-generic-password` WITHOUT `-w`
|
|
141
|
+
* reports existence only (attributes, which are discarded), so nothing sensitive
|
|
142
|
+
* reaches a log, a transcript, or this process's memory.
|
|
143
|
+
*
|
|
144
|
+
* One known imprecision, left in on purpose: Claude Code's own lookup also passes
|
|
145
|
+
* `-a` (`process.env.USER`, falling back to the OS username), and this does not.
|
|
146
|
+
* So an entry filed under a DIFFERENT account still reads as `found` here while
|
|
147
|
+
* Claude Code would miss it — a service running under a different `USER` than the
|
|
148
|
+
* terminal that logged in. Matching that exactly would trade a rare false
|
|
149
|
+
* positive for a rarer false negative, and a false negative is the one that costs
|
|
150
|
+
* something: it downgrades a specific instruction back to the generic warning.
|
|
151
|
+
*/
|
|
152
|
+
export async function probeMacosKeychainLogin(platform = process.platform) {
|
|
153
|
+
if (platform !== "darwin")
|
|
154
|
+
return "unknown";
|
|
155
|
+
const { execFile } = await import("node:child_process");
|
|
156
|
+
const { promisify } = await import("node:util");
|
|
157
|
+
const run = promisify(execFile);
|
|
158
|
+
for (const service of KEYCHAIN_SERVICE_NAMES) {
|
|
159
|
+
// No `-w`: existence only. Output is discarded either way.
|
|
160
|
+
const found = await run("security", ["find-generic-password", "-s", service], {
|
|
161
|
+
timeout: 5_000,
|
|
162
|
+
}).then(() => true, () => false);
|
|
163
|
+
if (found)
|
|
164
|
+
return "found";
|
|
165
|
+
}
|
|
166
|
+
return "unknown";
|
|
167
|
+
}
|
|
69
168
|
/** Does `p` exist (following symlinks)? */
|
|
70
169
|
async function exists(p) {
|
|
71
170
|
return fs
|
|
@@ -74,18 +173,251 @@ async function exists(p) {
|
|
|
74
173
|
.catch(() => false);
|
|
75
174
|
}
|
|
76
175
|
/**
|
|
77
|
-
*
|
|
176
|
+
* Symlink one entry of the user's home into paddock's, if paddock's does not
|
|
177
|
+
* already have that name. Returns whether a link was planted THIS call.
|
|
78
178
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
179
|
+
* Non-clobbering by `lstat`, not `stat`: a bridge symlink planted on a previous
|
|
180
|
+
* boot whose target has since been removed still occupies the name, and
|
|
181
|
+
* re-linking it would be a no-op churn at best. Never throws — a race with
|
|
182
|
+
* another instance, or a read-only home, is not a reason to fail a boot.
|
|
183
|
+
*/
|
|
184
|
+
async function bridgeEntry(cfg, entry) {
|
|
185
|
+
const target = path.join(cfg.legacyClaudeHome, entry);
|
|
186
|
+
const link = path.join(cfg.claudeHome, entry);
|
|
187
|
+
const occupied = await fs
|
|
188
|
+
.lstat(link)
|
|
189
|
+
.then(() => true)
|
|
190
|
+
.catch(() => false);
|
|
191
|
+
if (occupied)
|
|
192
|
+
return false;
|
|
193
|
+
if (!(await exists(target)))
|
|
194
|
+
return false;
|
|
195
|
+
return fs.symlink(target, link).then(() => true, () => false);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Undo {@link bridgeEntry} for one entry: remove it IF it is a symlink pointing
|
|
199
|
+
* into the user's home. Returns whether anything was removed.
|
|
200
|
+
*
|
|
201
|
+
* Deliberately narrow. A real file at that name is this instance's own — an
|
|
202
|
+
* operator's, or the product of a `CLAUDE_CONFIG_DIR=<own home> claude login` —
|
|
203
|
+
* and removing it would be paddock deleting a credential nobody asked it to
|
|
204
|
+
* touch. A symlink pointing somewhere OTHER than the user's home is not ours
|
|
205
|
+
* either. Only a link paddock itself would have planted is withdrawn, and the
|
|
206
|
+
* withdrawal happens inside paddock's own home, so the "never write to
|
|
207
|
+
* `~/.claude`" invariant is untouched.
|
|
208
|
+
*/
|
|
209
|
+
async function unbridgeEntry(cfg, entry) {
|
|
210
|
+
const link = path.join(cfg.claudeHome, entry);
|
|
211
|
+
const target = await fs.readlink(link).catch(() => null);
|
|
212
|
+
if (target === null)
|
|
213
|
+
return false; // absent, or a real file: not ours
|
|
214
|
+
const resolved = path.resolve(path.dirname(link), target);
|
|
215
|
+
if (resolved !== path.resolve(cfg.legacyClaudeHome, entry))
|
|
216
|
+
return false;
|
|
217
|
+
return fs.unlink(link).then(() => true, () => false);
|
|
218
|
+
}
|
|
219
|
+
const sha256 = (content) => createHash("sha256").update(content, "utf8").digest("hex");
|
|
220
|
+
/** Read the entry → sha256 map of files paddock generated. Never throws. */
|
|
221
|
+
async function readGeneratedHashes(home) {
|
|
222
|
+
const raw = await fs.readFile(path.join(home, GENERATED_MARKER_FILE), "utf8").catch(() => null);
|
|
223
|
+
if (raw === null)
|
|
224
|
+
return {};
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(raw);
|
|
227
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
228
|
+
return {};
|
|
229
|
+
return parsed;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Record (or, with `null`, forget) the hash of a file paddock generated. */
|
|
236
|
+
async function recordGenerated(home, entry, hash) {
|
|
237
|
+
const hashes = await readGeneratedHashes(home);
|
|
238
|
+
if (hash === null) {
|
|
239
|
+
if (!(entry in hashes))
|
|
240
|
+
return;
|
|
241
|
+
delete hashes[entry];
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
if (hashes[entry] === hash)
|
|
245
|
+
return;
|
|
246
|
+
hashes[entry] = hash;
|
|
247
|
+
}
|
|
248
|
+
const marker = path.join(home, GENERATED_MARKER_FILE);
|
|
249
|
+
await fs.writeFile(marker, `${JSON.stringify(hashes, null, 2)}\n`, "utf8").catch(() => { });
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Decide whether paddock may replace `<ownHome>/<entry>`.
|
|
253
|
+
*
|
|
254
|
+
* The `generated` case is the one that is new (#691 step 4) and the one the
|
|
255
|
+
* whole copy-and-filter mechanism rests on. A symlink needs no such question —
|
|
256
|
+
* paddock can always recognise its own by where it points — but a real file it
|
|
257
|
+
* wrote is indistinguishable from a real file a human wrote, and getting that
|
|
258
|
+
* wrong in either direction is bad: refuse to regenerate and the filtered copy
|
|
259
|
+
* is stale forever, regenerate anyway and paddock silently overwrites an
|
|
260
|
+
* operator's settings. Hashing what we wrote and comparing on the next boot
|
|
261
|
+
* answers it exactly, and answers `foreign` — the safe way — for any file we
|
|
262
|
+
* did not write or that has been edited since.
|
|
263
|
+
*/
|
|
264
|
+
async function classifyOwnEntry(cfg, entry) {
|
|
265
|
+
const own = path.join(cfg.claudeHome, entry);
|
|
266
|
+
const st = await fs.lstat(own).catch(() => null);
|
|
267
|
+
if (st === null)
|
|
268
|
+
return "absent";
|
|
269
|
+
if (st.isSymbolicLink()) {
|
|
270
|
+
const raw = await fs.readlink(own).catch(() => null);
|
|
271
|
+
if (raw === null)
|
|
272
|
+
return "foreign";
|
|
273
|
+
const resolved = path.resolve(path.dirname(own), raw);
|
|
274
|
+
return resolved === path.resolve(cfg.legacyClaudeHome, entry) ? "bridge" : "foreign";
|
|
275
|
+
}
|
|
276
|
+
const content = await fs.readFile(own, "utf8").catch(() => null);
|
|
277
|
+
if (content === null)
|
|
278
|
+
return "foreign";
|
|
279
|
+
const recorded = (await readGeneratedHashes(cfg.claudeHome))[entry];
|
|
280
|
+
return recorded !== undefined && recorded === sha256(content) ? "generated" : "foreign";
|
|
281
|
+
}
|
|
282
|
+
/** Remove a file paddock generated, and forget it. Returns whether it went. */
|
|
283
|
+
async function removeGenerated(home, entry) {
|
|
284
|
+
const ok = await fs.rm(path.join(home, entry), { force: true }).then(() => true, () => false);
|
|
285
|
+
if (ok)
|
|
286
|
+
await recordGenerated(home, entry, null);
|
|
287
|
+
return ok;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Put the right `settings.json` in paddock's home for the `claude.hooks` mode
|
|
291
|
+
* (#691 step 4).
|
|
292
|
+
*
|
|
293
|
+
* Four states have to compose, and all four are reachable by editing one config
|
|
294
|
+
* key on a running instance:
|
|
295
|
+
*
|
|
296
|
+
* | own home has | `hooks: host` | `hooks: own` |
|
|
297
|
+
* |---|---|---|
|
|
298
|
+
* | nothing | symlink the user's | symlink, or write the filtered copy |
|
|
299
|
+
* | paddock's bridge symlink | leave it | replace with the filtered copy |
|
|
300
|
+
* | paddock's generated file | delete it, symlink | regenerate from the user's |
|
|
301
|
+
* | a human's file | leave it | leave it, and say so if hooks were in play |
|
|
302
|
+
*
|
|
303
|
+
* The last row is the existing non-clobbering bridge behaviour, extended to a
|
|
304
|
+
* file rather than a name — with one addition, because silence there is
|
|
305
|
+
* dangerous: if the user's settings define hooks and paddock is not allowed to
|
|
306
|
+
* write the filtered copy, `hooks: own` is NOT in force for whatever that file
|
|
307
|
+
* says, so it warns rather than leaving an operator to believe a guarantee that
|
|
308
|
+
* is not holding.
|
|
309
|
+
*
|
|
310
|
+
* Never throws; a home it cannot write just keeps whatever it had.
|
|
311
|
+
*/
|
|
312
|
+
async function materializeSettings(cfg, hooks, report) {
|
|
313
|
+
const entry = SETTINGS_ENTRY;
|
|
314
|
+
const own = path.join(cfg.claudeHome, entry);
|
|
315
|
+
const state = await classifyOwnEntry(cfg, entry);
|
|
316
|
+
const raw = await fs.readFile(path.join(cfg.legacyClaudeHome, entry), "utf8").catch(() => null);
|
|
317
|
+
if (raw === null) {
|
|
318
|
+
// The user has no settings.json at all — including the case where they
|
|
319
|
+
// deleted one paddock had already bridged or filtered. Withdraw ours.
|
|
320
|
+
if (state === "bridge" && (await unbridgeEntry(cfg, entry)))
|
|
321
|
+
report.withdrawn.push(entry);
|
|
322
|
+
else if (state === "generated" && (await removeGenerated(cfg.claudeHome, entry)))
|
|
323
|
+
report.withdrawn.push(entry);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const plan = planHostSettings(hooks, raw);
|
|
327
|
+
if (state === "foreign") {
|
|
328
|
+
if (plan.action === "write") {
|
|
329
|
+
report.notices.push({
|
|
330
|
+
level: "warn",
|
|
331
|
+
message: `\`claude.hooks: own\` cannot take effect: ${own} is a file paddock did not write, ` +
|
|
332
|
+
`so it is left alone (as any entry of your own always is). Your ` +
|
|
333
|
+
`~/.claude/${entry} defines hooks, and whatever THAT file says is what runs. ` +
|
|
334
|
+
`Move or delete it to let paddock generate the filtered copy.`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (plan.action === "skip") {
|
|
340
|
+
// Fail closed: a settings.json that will not parse cannot be filtered, and
|
|
341
|
+
// linking it instead would hand over exactly the hooks `own` withholds.
|
|
342
|
+
if (state === "bridge" && (await unbridgeEntry(cfg, entry)))
|
|
343
|
+
report.withdrawn.push(entry);
|
|
344
|
+
else if (state === "generated" && (await removeGenerated(cfg.claudeHome, entry)))
|
|
345
|
+
report.withdrawn.push(entry);
|
|
346
|
+
report.notices.push({
|
|
347
|
+
level: "warn",
|
|
348
|
+
message: `not using your ~/.claude/${entry}: ${plan.reason}. Paddock could not filter it, and ` +
|
|
349
|
+
`\`claude.hooks: own\` means it must not be used unfiltered — so this instance runs ` +
|
|
350
|
+
`with no user-level settings at all. Fix the JSON and restart.`,
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (plan.action === "link") {
|
|
355
|
+
// Nothing to filter (no hooks, or `hooks: host`): the symlink is strictly
|
|
356
|
+
// better than a copy, so prefer it even under `own`. Only users who actually
|
|
357
|
+
// have hooks pay the staleness of a copy.
|
|
358
|
+
if (state === "generated")
|
|
359
|
+
await removeGenerated(cfg.claudeHome, entry);
|
|
360
|
+
if (await bridgeEntry(cfg, entry))
|
|
361
|
+
report.bridged.push(entry);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (state === "bridge")
|
|
365
|
+
await fs.unlink(own).catch(() => { });
|
|
366
|
+
const existing = state === "generated" ? await fs.readFile(own, "utf8").catch(() => null) : null;
|
|
367
|
+
if (existing !== plan.content) {
|
|
368
|
+
const ok = await fs.writeFile(own, plan.content, "utf8").then(() => true, () => false);
|
|
369
|
+
if (!ok) {
|
|
370
|
+
report.notices.push({
|
|
371
|
+
level: "warn",
|
|
372
|
+
message: `could not write ${own}, so \`claude.hooks: own\` is not in force — this instance ` +
|
|
373
|
+
`has no user-level settings rather than filtered ones.`,
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
report.generated.push(entry);
|
|
378
|
+
}
|
|
379
|
+
await recordGenerated(cfg.claudeHome, entry, sha256(plan.content));
|
|
380
|
+
report.notices.push({
|
|
381
|
+
level: "info",
|
|
382
|
+
message: `Claude hooks: your ~/.claude/${entry} defines ${plan.dropped.join(", ")}, which this ` +
|
|
383
|
+
`instance does NOT run (\`claude.hooks: own\`). Paddock writes its own ${entry} carrying ` +
|
|
384
|
+
`every other key of yours — permissions, model, statusline — and regenerates it at ` +
|
|
385
|
+
`startup, so restart paddock after editing yours. Set \`claude.hooks: host\` to run them.`,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Create paddock's Claude home and put the user-level config into it that this
|
|
390
|
+
* instance's `claude:` levers say it should have (#691).
|
|
391
|
+
*
|
|
392
|
+
* Idempotent and non-clobbering: a real file the user put in paddock's home is
|
|
393
|
+
* left completely alone, so this is safe to run on every start. What it DOES
|
|
394
|
+
* maintain is what paddock itself planted — bridge symlinks, withdrawn when a
|
|
395
|
+
* lever flips to `own`, and the generated `settings.json`, regenerated from the
|
|
396
|
+
* user's current one. Ownership of a generated file is decided by hash
|
|
397
|
+
* ({@link classifyOwnEntry}), never by "it is at a name we use".
|
|
82
398
|
*
|
|
83
399
|
* Never throws. A home that cannot be created is not a reason to refuse to boot
|
|
84
400
|
* — Claude Code falls back to its own default and chat still works, which is the
|
|
85
401
|
* same failure mode `ensureProjectChats` has always had.
|
|
402
|
+
*
|
|
403
|
+
* **Mutates `env`** (`process.env` by default) to carry `claude.credentials` into
|
|
404
|
+
* the environment Claude Code runs in — see {@link applyCredentialsMode} for why
|
|
405
|
+
* that is the only seam available and why it is safe process-wide. Done here
|
|
406
|
+
* because this is already the one call that runs before the fleet starts, and
|
|
407
|
+
* because the credential warning below has to describe the environment it just
|
|
408
|
+
* set rather than the one it found.
|
|
86
409
|
*/
|
|
87
|
-
export async function ensureClaudeHome(cfg, env = process.env) {
|
|
88
|
-
const report = { bridged: [], notices: [] };
|
|
410
|
+
export async function ensureClaudeHome(cfg, env = process.env, probeKeychain = probeMacosKeychainLogin) {
|
|
411
|
+
const report = { bridged: [], withdrawn: [], generated: [], notices: [] };
|
|
412
|
+
const credentials = cfg.claude.credentials;
|
|
413
|
+
const decision = applyCredentialsMode(credentials, env);
|
|
414
|
+
if (decision.action === "deferred") {
|
|
415
|
+
report.notices.push({
|
|
416
|
+
level: "info",
|
|
417
|
+
message: `${SECURE_STORAGE_DIR_VAR} is already set to "${decision.value}"; honouring it and ` +
|
|
418
|
+
`ignoring \`claude.credentials: ${credentials}\`. Unset it to let paddock choose.`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
89
421
|
try {
|
|
90
422
|
await fs.mkdir(path.join(cfg.claudeHome, "projects"), { recursive: true });
|
|
91
423
|
}
|
|
@@ -98,63 +430,122 @@ export async function ensureClaudeHome(cfg, env = process.env) {
|
|
|
98
430
|
});
|
|
99
431
|
return report;
|
|
100
432
|
}
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
433
|
+
// No "is this home ours?" branch any more (#691): it always is, and a config
|
|
434
|
+
// that resolves the home to `~/.claude` never gets this far — `resolveClaudeHome`
|
|
435
|
+
// refuses to start. What remains is one lever per entry.
|
|
436
|
+
// The login — `claude.credentials`, step 3. Its file half on Linux/Docker.
|
|
437
|
+
if (credentials === "host") {
|
|
438
|
+
if (await bridgeEntry(cfg, CREDENTIAL_ENTRY))
|
|
439
|
+
report.bridged.push(CREDENTIAL_ENTRY);
|
|
440
|
+
}
|
|
441
|
+
else if (await unbridgeEntry(cfg, CREDENTIAL_ENTRY)) {
|
|
442
|
+
report.withdrawn.push(CREDENTIAL_ENTRY);
|
|
104
443
|
report.notices.push({
|
|
105
444
|
level: "info",
|
|
106
|
-
message: `
|
|
445
|
+
message: `removed the bridged ${CREDENTIAL_ENTRY} symlink into ${cfg.legacyClaudeHome}: ` +
|
|
446
|
+
`\`claude.credentials: own\` means this instance uses only its own login. A real ` +
|
|
447
|
+
`${CREDENTIAL_ENTRY} in ${cfg.claudeHome} is left alone.`,
|
|
107
448
|
});
|
|
108
|
-
return report;
|
|
109
449
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const occupied = await fs
|
|
117
|
-
.lstat(link)
|
|
118
|
-
.then(() => true)
|
|
119
|
-
.catch(() => false);
|
|
120
|
-
if (occupied)
|
|
121
|
-
continue;
|
|
122
|
-
if (!(await exists(target)))
|
|
123
|
-
continue;
|
|
124
|
-
try {
|
|
125
|
-
await fs.symlink(target, link);
|
|
126
|
-
report.bridged.push(entry);
|
|
450
|
+
// CLAUDE.md, agents, commands, plugins — `claude.instructions`, step 4.
|
|
451
|
+
const instructions = cfg.claude.instructions;
|
|
452
|
+
for (const entry of INSTRUCTION_ENTRIES) {
|
|
453
|
+
if (instructions === "host") {
|
|
454
|
+
if (await bridgeEntry(cfg, entry))
|
|
455
|
+
report.bridged.push(entry);
|
|
127
456
|
}
|
|
128
|
-
|
|
129
|
-
|
|
457
|
+
else if (await unbridgeEntry(cfg, entry)) {
|
|
458
|
+
report.withdrawn.push(entry);
|
|
130
459
|
}
|
|
131
460
|
}
|
|
461
|
+
if (instructions === "own") {
|
|
462
|
+
// Named rather than left to be discovered. `own` is the default and it is a
|
|
463
|
+
// real behaviour change for anyone with a curated `~/.claude/CLAUDE.md`:
|
|
464
|
+
// their agents quietly stop knowing things, with no error to search for. So
|
|
465
|
+
// paddock says which of their files it is not using, and which key turns
|
|
466
|
+
// them back on. Only when they HAVE some — otherwise it is noise.
|
|
467
|
+
const present = [];
|
|
468
|
+
for (const entry of INSTRUCTION_ENTRIES) {
|
|
469
|
+
if (await exists(path.join(cfg.legacyClaudeHome, entry)))
|
|
470
|
+
present.push(entry);
|
|
471
|
+
}
|
|
472
|
+
if (present.length > 0) {
|
|
473
|
+
report.notices.push({
|
|
474
|
+
level: "info",
|
|
475
|
+
message: `Claude instructions: this instance's own only (\`claude.instructions: own\`). Your ` +
|
|
476
|
+
`~/.claude ${present.join(", ")} ${present.length === 1 ? "is" : "are"} NOT loaded — ` +
|
|
477
|
+
`set \`claude.instructions: host\` (or PADDOCK_CLAUDE_INSTRUCTIONS=host) to share ` +
|
|
478
|
+
`them. Each project's own CLAUDE.md is unaffected either way.`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// settings.json — `claude.hooks`, step 4. Not a bridge decision: the file is a
|
|
483
|
+
// mixed bag, so `own` writes a filtered copy. See `materializeSettings`.
|
|
484
|
+
await materializeSettings(cfg, cfg.claude.hooks, report);
|
|
132
485
|
report.notices.push({
|
|
133
486
|
level: "info",
|
|
134
487
|
message: `Claude home: ${cfg.claudeHome} (paddock-owned; ~/.claude is read-only)` +
|
|
135
|
-
(report.bridged.length > 0 ? ` — bridged from ~/.claude: ${report.bridged.join(", ")}` : "")
|
|
488
|
+
(report.bridged.length > 0 ? ` — bridged from ~/.claude: ${report.bridged.join(", ")}` : "") +
|
|
489
|
+
(report.generated.length > 0 ? ` — generated: ${report.generated.join(", ")}` : "") +
|
|
490
|
+
(report.withdrawn.length > 0 ? ` — withdrawn: ${report.withdrawn.join(", ")}` : ""),
|
|
136
491
|
});
|
|
137
|
-
// The credential question (#620). Claude Code derives its secure-storage
|
|
492
|
+
// The credential question (#620, #683). Claude Code derives its secure-storage
|
|
138
493
|
// service name from whether `CLAUDE_CONFIG_DIR` is SET AT ALL — unset gets the
|
|
139
|
-
// plain name, set gets a path-hash suffix.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// file-based login is bridged
|
|
143
|
-
// credential at all, so say so BEFORE the first turn
|
|
144
|
-
// rather than leaving the operator to work it out
|
|
145
|
-
// replies.
|
|
494
|
+
// plain name, set gets a path-hash suffix. Paddock always sets it, so a login
|
|
495
|
+
// held in the macOS Keychain under the plain name is invisible unless
|
|
496
|
+
// `credentials: host` un-scopes it above. A token in the environment is
|
|
497
|
+
// unaffected, and a file-based login is bridged (under `host`); if none of that
|
|
498
|
+
// applies we cannot see a credential at all, so say so BEFORE the first turn
|
|
499
|
+
// fails with "Not logged in" rather than leaving the operator to work it out
|
|
500
|
+
// from a chat that never replies.
|
|
146
501
|
const hasTokenEnv = TOKEN_ENV_VARS.some((v) => (env[v] ?? "").trim().length > 0);
|
|
147
|
-
const hasCredentialFile = await exists(path.join(cfg.claudeHome,
|
|
502
|
+
const hasCredentialFile = await exists(path.join(cfg.claudeHome, CREDENTIAL_ENTRY));
|
|
148
503
|
if (!hasTokenEnv && !hasCredentialFile) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
504
|
+
// #683: "you have no login" and "your login exists but I changed the service
|
|
505
|
+
// name it is filed under" were the same message, and only one of them is the
|
|
506
|
+
// user's problem to solve. On darwin we can tell them apart, and a hit either
|
|
507
|
+
// resolves the question outright (`host`) or upgrades a paragraph of
|
|
508
|
+
// explanation into an instruction (`own`). A miss is inconclusive by design
|
|
509
|
+
// (see probeMacosKeychainLogin), so it falls through to the generic wording
|
|
510
|
+
// rather than asserting anything.
|
|
511
|
+
const keychain = await probeKeychain();
|
|
512
|
+
if (keychain === "found" && credentials === "host") {
|
|
513
|
+
// Not a problem: this is the lever working. Said out loud anyway, because
|
|
514
|
+
// "paddock found no credentials of its own and is using yours" is exactly
|
|
515
|
+
// the kind of thing an operator should not have to deduce.
|
|
516
|
+
report.notices.push({
|
|
517
|
+
level: "info",
|
|
518
|
+
message: `Claude login: this machine's own, from the macOS Keychain ` +
|
|
519
|
+
`(\`claude.credentials: host\`). Paddock sets ${SECURE_STORAGE_DIR_VAR}="" so Claude ` +
|
|
520
|
+
`Code reads the same unsuffixed entry your own \`claude\` does, while its config dir ` +
|
|
521
|
+
`stays at ${cfg.claudeHome}. Set \`claude.credentials: own\` to use only a login of ` +
|
|
522
|
+
`this instance's.`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
report.notices.push({
|
|
527
|
+
level: "warn",
|
|
528
|
+
message: keychain === "found"
|
|
529
|
+
? `you ARE logged in to Claude Code, but paddock cannot see it. The login is in the ` +
|
|
530
|
+
`macOS Keychain, and Claude Code files it under a service name derived from ` +
|
|
531
|
+
`whether CLAUDE_CONFIG_DIR is set — paddock sets it, to ${cfg.claudeHome}, so it ` +
|
|
532
|
+
`looks under a different name. \`claude.credentials: own\` is what keeps it that ` +
|
|
533
|
+
`way. Fix it either way: drop that key (the default is \`host\`, which shares this ` +
|
|
534
|
+
`machine's login and writes nothing), or log in for this home too with ` +
|
|
535
|
+
`\`CLAUDE_CONFIG_DIR=${cfg.claudeHome} claude login\`.`
|
|
536
|
+
: `no Claude credentials found for ${cfg.claudeHome}: no ${CREDENTIAL_ENTRY} and no ` +
|
|
537
|
+
`${TOKEN_ENV_VARS.join("/")} in the environment` +
|
|
538
|
+
(credentials === "host"
|
|
539
|
+
? ` — and \`claude.credentials: host\` found nothing on this machine to share ` +
|
|
540
|
+
`either (no ~/.claude/${CREDENTIAL_ENTRY}, and no macOS Keychain login under ` +
|
|
541
|
+
`the name Claude Code uses). `
|
|
542
|
+
: `, and \`claude.credentials: own\` means this instance shares none of yours. ` +
|
|
543
|
+
`Claude Code scopes its credential store to CLAUDE_CONFIG_DIR, so a login in ` +
|
|
544
|
+
`the OS keychain against the default home will NOT be found here. `) +
|
|
545
|
+
`If turns fail with "Not logged in", re-run \`claude login\` with ` +
|
|
546
|
+
`CLAUDE_CONFIG_DIR=${cfg.claudeHome}, or put a token in the environment.`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
158
549
|
}
|
|
159
550
|
return report;
|
|
160
551
|
}
|
|
@@ -209,6 +600,14 @@ export function legacySourcePath(legacyHome, folderName) {
|
|
|
209
600
|
* mirroring them would re-offer paddock's own chats as "adoptable". That also
|
|
210
601
|
* makes the leftovers from before this change inert rather than confusing.
|
|
211
602
|
*
|
|
603
|
+
* A folder paddock's own home ALREADY links to directly is skipped for the same
|
|
604
|
+
* reason. That is the `claude.transcripts: host` case (#691): there the folder in
|
|
605
|
+
* the user's home is a real directory that paddock itself writes through, so
|
|
606
|
+
* mirroring it would offer every project's own live chats back as importable —
|
|
607
|
+
* the #658 mistake with the arrow reversed. (Adoption de-duplicates sources by
|
|
608
|
+
* resolved real path, so this was noise rather than breakage; it is skipped here
|
|
609
|
+
* because a link paddock plants and then mirrors is confusing to find on disk.)
|
|
610
|
+
*
|
|
212
611
|
* Idempotent and never throws. Returns every mirror it maintains, not only the
|
|
213
612
|
* ones planted this call, so a caller can rely on the mapping after any call.
|
|
214
613
|
*/
|
|
@@ -228,6 +627,12 @@ export async function mirrorLegacyTranscriptFolders(claudeHome, legacyHome) {
|
|
|
228
627
|
const st = await fs.lstat(legacyDir).catch(() => null);
|
|
229
628
|
if (!st?.isDirectory())
|
|
230
629
|
continue; // skips files AND paddock's old symlinks
|
|
630
|
+
// Already reachable directly from paddock's own home (`transcripts: host`)?
|
|
631
|
+
// Then it is not an outside source to import; it is where this instance's
|
|
632
|
+
// own chats live.
|
|
633
|
+
const direct = await fs.readlink(path.join(to, folderName)).catch(() => null);
|
|
634
|
+
if (direct !== null && path.resolve(to, direct) === path.resolve(legacyDir))
|
|
635
|
+
continue;
|
|
231
636
|
const engineCwd = legacySourcePath(legacyHome, folderName);
|
|
232
637
|
// encodePathForCli, not a local encoder: this name must be byte-identical
|
|
233
638
|
// to what the engine derives from `engineCwd`, including the truncate+hash
|
|
@@ -274,3 +679,49 @@ export async function countLegacyTranscriptLinks(legacyHome, dataDir) {
|
|
|
274
679
|
}
|
|
275
680
|
return count;
|
|
276
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* Find transcript symlinks planted in the user's home that point at a `.chats/`
|
|
684
|
+
* store OUTSIDE `dataDir` (#682).
|
|
685
|
+
*
|
|
686
|
+
* The instance's own leftovers — links into this data dir — are already reported
|
|
687
|
+
* by {@link countLegacyTranscriptLinks} as harmless pre-#620 residue, and they
|
|
688
|
+
* are: they were planted for a project whose transcripts paddock does own.
|
|
689
|
+
*
|
|
690
|
+
* The ones this finds are the poisoned kind. Under `--here` the workspace's
|
|
691
|
+
* `.chats/` sits at `<dir>/.chats` while the data dir is `<dir>/.paddock`, so a
|
|
692
|
+
* link planted by an affected build points somewhere no `dataDir` prefix match
|
|
693
|
+
* will ever catch. While it exists, every `claude` session the user starts in
|
|
694
|
+
* that directory writes into paddock's store instead of their own history, and
|
|
695
|
+
* anything that expects `~/.claude/projects/<enc>` to be a real directory
|
|
696
|
+
* (`unzip -d ~/.claude` being the observed one) fails with an error impossible
|
|
697
|
+
* to trace back here.
|
|
698
|
+
*
|
|
699
|
+
* Reported, never removed. Paddock not writing to `~/.claude` is the entire
|
|
700
|
+
* point of #620, and that has to include cleaning up after itself — a boot that
|
|
701
|
+
* silently deleted something under there would be the same class of surprise in
|
|
702
|
+
* the opposite direction. Naming the exact path is enough: `rm` is one command
|
|
703
|
+
* and the user can see what they are removing first.
|
|
704
|
+
*
|
|
705
|
+
* Never throws; an unreadable home just yields no findings.
|
|
706
|
+
*/
|
|
707
|
+
export async function findPlantedChatsLinks(legacyHome, dataDir) {
|
|
708
|
+
const root = path.join(legacyHome, "projects");
|
|
709
|
+
const names = await fs.readdir(root).catch(() => []);
|
|
710
|
+
const ownPrefix = path.resolve(dataDir) + path.sep;
|
|
711
|
+
const found = [];
|
|
712
|
+
for (const name of names) {
|
|
713
|
+
const link = path.join(root, name);
|
|
714
|
+
const raw = await fs.readlink(link).catch(() => null);
|
|
715
|
+
if (raw === null)
|
|
716
|
+
continue;
|
|
717
|
+
const target = path.resolve(path.dirname(link), raw);
|
|
718
|
+
// `.chats` is paddock's store name and nothing else's — a precise enough
|
|
719
|
+
// signature that a link the user made themselves won't be misreported.
|
|
720
|
+
if (path.basename(target) !== ".chats")
|
|
721
|
+
continue;
|
|
722
|
+
if (target.startsWith(ownPrefix))
|
|
723
|
+
continue; // this instance's own residue
|
|
724
|
+
found.push({ link, target, dangling: !(await exists(target)) });
|
|
725
|
+
}
|
|
726
|
+
return found;
|
|
727
|
+
}
|