-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcompose.go
More file actions
321 lines (298 loc) · 10.4 KB
/
Copy pathcompose.go
File metadata and controls
321 lines (298 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
)
// baseComposeArgs returns the docker compose flags shared by every
// oss-back2base subcommand. extraFiles, if any, are added as additional `-f`
// flags AFTER the base compose file so their values override the base
// (compose's documented merge order).
func baseComposeArgs(cfg cbConfig, extraFiles ...string) []string {
args := []string{
"compose",
"-f", filepath.Join(cfg.Home, "docker-compose.yml"),
}
for _, f := range extraFiles {
args = append(args, "-f", f)
}
args = append(args,
"--env-file", cfg.EnvFile,
"--project-directory", cfg.Home,
)
return args
}
// hostCredsOverridePath is where writeHostCredsOverride stages its
// generated compose override fragment.
func hostCredsOverridePath(cfg cbConfig) string {
return filepath.Join(cfg.StateDir, "run", "host-creds-override.yml")
}
// writeHostCredsOverride generates a docker-compose override that
// bind-mounts the host's per-tool config dirs (~/.aws, ~/.kube,
// ~/.config/gh) at sidecar paths inside the container, but only for
// dirs that actually exist on the host.
//
// The base docker-compose.yml does NOT declare these mounts because
// docker-compose's auto-create-host-path behavior would otherwise leave
// empty config dirs in $HOME for users who don't have those tools
// installed. Generating the override per-run also means a tool the user
// installs between runs (e.g. `aws configure` after first launch) is
// picked up on the next start without any oss-back2base-side reconfig.
//
// Returns the override path on success, "" if no host creds were found
// (no override written), or "" if writing failed (caller falls through
// to no override; container starts without those tools' creds staged).
func writeHostCredsOverride(cfg cbConfig) string {
home := os.Getenv("HOME")
if home == "" {
return ""
}
type mount struct{ src, dst string }
candidates := []mount{
{filepath.Join(home, ".aws"), "/home/node/.aws-host"},
{filepath.Join(home, ".kube"), "/home/node/.kube-host"},
{filepath.Join(home, ".config", "gh"), "/home/node/.config/gh-host"},
}
var lines []string
for _, m := range candidates {
fi, err := os.Stat(m.src)
if err != nil || !fi.IsDir() {
continue
}
lines = append(lines, fmt.Sprintf(" - %s:%s:ro", m.src, m.dst))
}
if len(lines) == 0 {
return ""
}
body := "services:\n claude:\n volumes:\n" + strings.Join(lines, "\n") + "\n"
path := hostCredsOverridePath(cfg)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
fmt.Fprintf(os.Stderr, ":: warn: could not stage compose override dir (%v)\n", err)
return ""
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
fmt.Fprintf(os.Stderr, ":: warn: could not write compose override (%v)\n", err)
return ""
}
return path
}
// managedSettingsHostDir returns the platform-specific directory Claude
// Code reads enterprise / managed-policy files from on the host, or ""
// when the platform isn't supported. BACK2BASE_MANAGED_SETTINGS_DIR
// overrides the probe (useful for sandboxed environments, dev setups,
// or admins who deploy policy at a non-standard path).
//
// References (docs.claude.com):
// - macOS: /Library/Application Support/ClaudeCode/
// - Linux: /etc/claude-code/
//
// Within that directory we look for three artifacts:
// - managed-settings.json — top-tier policy file
// - managed-settings.d/ — drop-in directory for modular policies
// - CLAUDE.md — admin-deployed context prepended to memory
func managedSettingsHostDir() string {
if v := strings.TrimSpace(os.Getenv("BACK2BASE_MANAGED_SETTINGS_DIR")); v != "" {
return v
}
switch runtime.GOOS {
case "darwin":
return "/Library/Application Support/ClaudeCode"
case "linux":
return "/etc/claude-code"
default:
return ""
}
}
// managedSettingsOverridePath is where writeManagedSettingsOverride stages
// its generated compose override fragment.
func managedSettingsOverridePath(cfg cbConfig) string {
return filepath.Join(cfg.StateDir, "run", "managed-settings-override.yml")
}
// writeManagedSettingsOverride generates a docker-compose override that
// bind-mounts the host's Claude Code managed-policy artifacts at the
// canonical Linux paths inside the container. Claude Code reads these
// paths unconditionally (no env-var or flag exists to redirect them), so
// a bind mount is the only way enterprise policy gets honored when the
// CLI runs in a sandbox.
//
// All mounts are read-only and only emitted for artifacts that actually
// exist on the host. Returns the override path on success or "" if no
// managed artifacts were found.
func writeManagedSettingsOverride(cfg cbConfig) string {
hostDir := managedSettingsHostDir()
if hostDir == "" {
return ""
}
type mount struct{ src, dst string }
candidates := []mount{
{filepath.Join(hostDir, "managed-settings.json"), "/etc/claude-code/managed-settings.json"},
{filepath.Join(hostDir, "managed-settings.d"), "/etc/claude-code/managed-settings.d"},
{filepath.Join(hostDir, "CLAUDE.md"), "/etc/claude-code/CLAUDE.md"},
}
var lines []string
for _, m := range candidates {
if _, err := os.Stat(m.src); err != nil {
continue
}
lines = append(lines, fmt.Sprintf(" - %q", m.src+":"+m.dst+":ro"))
}
if len(lines) == 0 {
return ""
}
body := "services:\n claude:\n volumes:\n" + strings.Join(lines, "\n") + "\n"
path := managedSettingsOverridePath(cfg)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
fmt.Fprintf(os.Stderr, ":: warn: could not stage managed-settings override dir (%v)\n", err)
return ""
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
fmt.Fprintf(os.Stderr, ":: warn: could not write managed-settings override (%v)\n", err)
return ""
}
return path
}
// dataDirOverridePath is where writeDataDirOverride stages its generated
// compose override fragment.
func dataDirOverridePath(cfg cbConfig) string {
return filepath.Join(cfg.StateDir, "run", "data-dir-override.yml")
}
// resolveDataDir returns the host dir for the plans + memories mount, or ""
// when unset. Process env wins over the BACK2BASE_DATA_DIR key in the env file;
// a leading "~/" is expanded to $HOME.
func resolveDataDir(cfg cbConfig) string {
v := strings.TrimSpace(os.Getenv("BACK2BASE_DATA_DIR"))
if v == "" {
v = strings.TrimSpace(readEnvFile(cfg.EnvFile)["BACK2BASE_DATA_DIR"])
}
if v == "" {
return ""
}
if strings.HasPrefix(v, "~/") {
if home := os.Getenv("HOME"); home != "" {
v = filepath.Join(home, v[2:])
}
}
return v
}
// writeDataDirOverride generates a compose override that bind-mounts
// <BACK2BASE_DATA_DIR>/plans and /memories (created if absent) at
// ~/.back2base/plans and ~/.back2base/memories. Returns "" when the var is
// unset or the dir is missing (warned to stderr). OSS has no cloud sync, so
// it's volumes-only.
func writeDataDirOverride(cfg cbConfig) string {
dir := resolveDataDir(cfg)
if dir == "" {
return ""
}
// A newline in the path would break the generated override YAML (or let a
// crafted value inject extra keys), so reject it rather than emit corrupt
// YAML. Unix dir names can legally contain newlines, so the IsDir check
// below isn't enough on its own.
if strings.ContainsAny(dir, "\r\n") {
fmt.Fprintf(os.Stderr, ":: warn: BACK2BASE_DATA_DIR contains a newline — skipping plans/memories mount\n")
return ""
}
fi, err := os.Stat(dir)
if err != nil || !fi.IsDir() {
fmt.Fprintf(os.Stderr, ":: warn: BACK2BASE_DATA_DIR %q is not an existing directory — skipping plans/memories mount\n", dir)
return ""
}
plansHost := filepath.Join(dir, "plans")
memHost := filepath.Join(dir, "memories")
for _, d := range []string{plansHost, memHost} {
if err := os.MkdirAll(d, 0o700); err != nil {
fmt.Fprintf(os.Stderr, ":: warn: could not create %q (%v) — skipping plans/memories mount\n", d, err)
return ""
}
}
body := "services:\n claude:\n volumes:\n" +
fmt.Sprintf(" - %s:%s\n", memHost, "/home/node/.back2base/memories") +
fmt.Sprintf(" - %s:%s\n", plansHost, "/home/node/.back2base/plans")
path := dataDirOverridePath(cfg)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
fmt.Fprintf(os.Stderr, ":: warn: could not stage data-dir override dir (%v)\n", err)
return ""
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
fmt.Fprintf(os.Stderr, ":: warn: could not write data-dir override (%v)\n", err)
return ""
}
fmt.Fprintf(os.Stderr, ":: Mounting data dir: %s → ~/.back2base/{plans,memories}\n", dir)
return path
}
type runOpts struct {
extraDirs []string
prompt string
model string
resumeID string
}
func buildRunArgs(cfg cbConfig, opts runOpts) []string {
var overrides []string
if p := writeHostCredsOverride(cfg); p != "" {
overrides = append(overrides, p)
}
if p := writeManagedSettingsOverride(cfg); p != "" {
overrides = append(overrides, p)
}
if p := writeDataDirOverride(cfg); p != "" {
overrides = append(overrides, p)
}
args := baseComposeArgs(cfg, overrides...)
args = append(args, "run", "--rm")
var addDirs []string
for _, d := range opts.extraDirs {
name := filepath.Base(d)
args = append(args, "-v", d+":/repos/"+name)
addDirs = append(addDirs, "/repos/"+name)
}
args = append(args,
"claude",
"claude",
"--permission-mode", "bypassPermissions",
"--dangerously-skip-permissions",
"--mcp-config", "/home/node/.claude/.mcp.json",
)
if opts.resumeID != "" {
args = append(args, "--resume", opts.resumeID)
}
if opts.model != "" {
args = append(args, "--model", opts.model)
}
for _, d := range addDirs {
args = append(args, "--add-dir", d)
}
if opts.prompt != "" {
args = append(args, "-p", opts.prompt)
}
return args
}
func composeExec(args []string) error {
dockerPath, err := exec.LookPath("docker")
if err != nil {
return fmt.Errorf("docker not found in PATH: %w", err)
}
env := composeEnv()
return syscall.Exec(dockerPath, append([]string{"docker"}, args...), env)
}
func composeRun(args []string) error {
cmd := exec.Command("docker", args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = composeEnv()
return cmd.Run()
}
// composeEnv augments the caller's environment with values needed by
// docker-compose interpolation. The OSS build has no auth/proxy/cloud
// integrations, so this just pins the base image.
func composeEnv() []string {
env := os.Environ()
if os.Getenv("BACK2BASE_BASE_IMAGE") == "" {
env = append(env, "BACK2BASE_BASE_IMAGE="+resolveBaseImage())
}
return env
}