yolocage 0.1.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/LICENSE +21 -0
- package/README.md +168 -0
- package/lib/bind-spec.js +104 -0
- package/lib/config.js +158 -0
- package/lib/docker.js +170 -0
- package/lib/types.js +64 -0
- package/lib/update.js +194 -0
- package/lib/ycrc.js +98 -0
- package/package.json +57 -0
- package/yc.js +361 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yolocage contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# yolocage
|
|
2
|
+
|
|
3
|
+
Sandboxed claude-code / codex in a container, with a built-in egress credential scrubber as the safety net. Run your AI coding agent in `--dangerously-skip-permissions` mode without worrying about accidentally leaking the `.env` file you just `cat`'d into context.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cd ~/dev/myproject
|
|
7
|
+
npx yolocage claude # or: yc claude
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
That's it. Claude starts in your project directory, using your existing claude.ai login, with HTTP egress to `api.anthropic.com` routed through an in-cage scrubber that catches credential-shaped strings before they reach the LLM.
|
|
11
|
+
|
|
12
|
+
## What yolocage actually does
|
|
13
|
+
|
|
14
|
+
When you accidentally `Read .env` or `git config --list` into an agent's context, that secret enters the conversation and gets re-sent to the LLM on every subsequent turn. Once it's in context, hooks can't reach it. Yolocage runs a mitmproxy-based scrubber inside the container that intercepts HTTPS to the LLM provider and same-length-redacts credential-shaped strings out of the request body before they leave the cage.
|
|
15
|
+
|
|
16
|
+
Yolocage is the opinionated container + CLI wrapper. The scrubber library is published separately as [ssproxy](https://github.com/jlamendo/ssproxy).
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- docker (CLI access; yolocage will sudo automatically if you're not in the docker group)
|
|
21
|
+
- node 16+ (for npm/npx)
|
|
22
|
+
|
|
23
|
+
That's it. Mac (Intel + Apple Silicon) and Debian/Ubuntu Linux supported.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# One-shot: no install, just run
|
|
29
|
+
npx yolocage claude
|
|
30
|
+
|
|
31
|
+
# Or install globally
|
|
32
|
+
npm i -g yolocage
|
|
33
|
+
yc claude
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> The global install registers both `yolocage` and `yc` as commands. Yandex Cloud's CLI also uses `yc` — if you have both, PATH order decides which wins. Invoke `yolocage` to disambiguate, or alias one of them. The conflict is rare enough that the short alias is worth shipping; document over remove.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Shortcut form (per-directory, persistent)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
yc # defaults to claude in $(pwd)
|
|
44
|
+
yc claude # explicit
|
|
45
|
+
yc codex # codex instead of claude
|
|
46
|
+
yc claude -- --resume # pass-through args after --
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Each project directory gets its own cage. The cage name is derived deterministically from the directory path (`yc-<type>-<basename>-<8hex>`), so re-running `yc claude` in the same dir either **resumes** the stopped cage or **attaches** to the running one — never spawns a duplicate. Claude is run with `--continue` by default so the prior in-cwd conversation is restored on re-attach.
|
|
50
|
+
|
|
51
|
+
Three outcomes from a single `yc claude` invocation, picked automatically:
|
|
52
|
+
|
|
53
|
+
- **No cage for this dir yet** → create, start, attach. Cage persists between sessions.
|
|
54
|
+
- **Cage exists but is stopped** → `docker start -ai` (restart and reattach). Claude `--continue` resumes the prior session.
|
|
55
|
+
- **Cage is already running** → `docker attach` to the live session. Press `Ctrl-P Ctrl-Q` to detach without stopping the cage; `Ctrl-C` exits claude (and stops the cage).
|
|
56
|
+
|
|
57
|
+
Cages accumulate as you move between projects. Use `yc list` to see them and `yc rm <name>` to clean up. Volumes for the per-cage mitmproxy CA and any state live alongside the container — `yc rm` removes both.
|
|
58
|
+
|
|
59
|
+
### Named cages (persistent)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
yc create projectbox --type=claude --bind-workspace=./src
|
|
63
|
+
yc run projectbox # attach
|
|
64
|
+
yc list # show all cages
|
|
65
|
+
yc rm projectbox # destroy
|
|
66
|
+
yc logs projectbox # tail
|
|
67
|
+
yc pull # refresh yolocage images
|
|
68
|
+
yc update # update yolocage CLI + images in one shot
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Updating yolocage
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
yc update # binary + images
|
|
75
|
+
yc update --check # just report the version delta
|
|
76
|
+
yc update --no-pull # skip the docker pull
|
|
77
|
+
yc update --force # reinstall current version
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`yc update` runs `npm install -g yolocage@latest` (with sudo if the global prefix needs root) and then re-pulls the cage images. If you installed via `npx` instead of a global install, `yc update` will tell you so and only refresh the images — the next `npx yolocage` invocation fetches the latest binary on its own.
|
|
81
|
+
|
|
82
|
+
Named cages persist between runs. Use them when you want to maintain in-container state across sessions (with `--tmux`) or want explicit naming for the cage holding your active claude session.
|
|
83
|
+
|
|
84
|
+
### Configuration
|
|
85
|
+
|
|
86
|
+
Precedence (lowest to highest):
|
|
87
|
+
1. Type defaults (workspace = cwd, config dir = `~/.claude` for claude, `~/.codex` for codex)
|
|
88
|
+
2. `~/.ycrc`
|
|
89
|
+
3. `./.ycrc` (project-local)
|
|
90
|
+
4. CLI flags
|
|
91
|
+
|
|
92
|
+
#### `.ycrc` syntax
|
|
93
|
+
|
|
94
|
+
Simple key=value, comments via `#`. Path values expand leading `~` and literal `$HOME` (no other shell expansion):
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
# ~/.ycrc — defaults applied to every cage
|
|
98
|
+
type = claude
|
|
99
|
+
memory = 4g
|
|
100
|
+
tmux = true
|
|
101
|
+
ssproxy_extensions = ~/.yolocage/scrubbers.json
|
|
102
|
+
|
|
103
|
+
# Extra binds appended across layers
|
|
104
|
+
extra_bind_dirs = ~/.aws:/home/agent/.aws:ro
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
# ./.ycrc — project-local overrides
|
|
109
|
+
image = yolocage/claude:1.4.2
|
|
110
|
+
extra_bind_dirs = ./secrets:/etc/secrets:ro
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### Flags
|
|
114
|
+
|
|
115
|
+
| flag | meaning |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `--type=claude\|codex` | which agent (shortcut form embeds this in the verb) |
|
|
118
|
+
| `--bind-workspace=PATH` | host path mounted at `/workspace` (default: cwd) |
|
|
119
|
+
| `--config-dir=PATH` | host path for the type config dir |
|
|
120
|
+
| `--bind-dirs=H:C[:M]` | replaces type defaults (repeatable) |
|
|
121
|
+
| `--extra-bind-dirs=H:C[:M]` | appends extra binds (repeatable) |
|
|
122
|
+
| `--ssproxy-extensions=PATH` | custom scrub pattern file |
|
|
123
|
+
| `--tmux` / `--no-tmux` | run agent inside tmux (default: false) |
|
|
124
|
+
| `--memory=2g`, `--cpus=2` | docker resource limits |
|
|
125
|
+
| `--image=REPO:TAG` | override default image |
|
|
126
|
+
|
|
127
|
+
### Cascade semantics
|
|
128
|
+
|
|
129
|
+
| key | append or replace across layers |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `type`, `image`, `workspace`, `config_dir`, `memory`, `cpus`, `tmux` | replace (later wins entirely) |
|
|
132
|
+
| `bind_dirs` | replace (later layer wins entirely, including its absence) |
|
|
133
|
+
| `extra_bind_dirs` | append (union of all layers, dedupe by `host:container`) |
|
|
134
|
+
| `ssproxy_extensions` | replace |
|
|
135
|
+
|
|
136
|
+
## Custom scrub patterns
|
|
137
|
+
|
|
138
|
+
Companies can extend the scrubber with their own credential patterns:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
yc claude --ssproxy-extensions=./.yolocage/scrubbers.json
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
[
|
|
146
|
+
{ "id": "acme-internal-token", "regex": "\\bACME_[A-Z0-9]{32}\\b" },
|
|
147
|
+
{ "id": "acme-deploy-key", "regex": "\\b(acme_deploy_[a-f0-9]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)" }
|
|
148
|
+
]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Patterns are byte-stream regex over the request body. **Use the boundary-anchor convention** shown in the second example — the scrubber does same-length replacement, and a regex without a trailing anchor can overrun the secret boundary into a JSON-structural byte and break the upstream LLM's request parsing. See `docs/writing-scrub-extensions.md`.
|
|
152
|
+
|
|
153
|
+
## Security model
|
|
154
|
+
|
|
155
|
+
Yolocage is a **safety net for accidental leakage**, not a defense against a hostile agent.
|
|
156
|
+
|
|
157
|
+
- The scrubber catches credential-shaped strings on egress to known LLM API hosts. It does not prevent an agent that's already been prompt-injected from exfiltrating data through other channels.
|
|
158
|
+
- The cage runs `--dangerously-skip-permissions`. The agent CAN modify any file in your mounted workspace. Don't mount paths you don't want the agent to touch.
|
|
159
|
+
- The mitmproxy CA cert lives in a per-cage docker volume; it signs intercepts only of LLM API hosts originating from inside that cage. Blast radius of a leaked CA is "someone with the same yolocage image already on your machine," which means they have bigger problems.
|
|
160
|
+
|
|
161
|
+
## Open source
|
|
162
|
+
|
|
163
|
+
- yolocage (this repo): MIT, the opinionated container + CLI
|
|
164
|
+
- [ssproxy](https://github.com/jlamendo/ssproxy): MIT, the scrubber library
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
package/lib/bind-spec.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Bind-spec parser.
|
|
2
|
+
//
|
|
3
|
+
// Accepted shapes:
|
|
4
|
+
// host:container
|
|
5
|
+
// host:container:ro
|
|
6
|
+
// host:container:rw
|
|
7
|
+
//
|
|
8
|
+
// Trickiness: Mac paths can contain colons in volume names (rare) and
|
|
9
|
+
// Windows-style C:\… isn't supported, but the parser must not choke on
|
|
10
|
+
// "host paths that have colons" if the third-from-end token isn't a
|
|
11
|
+
// recognized mode. The algorithm:
|
|
12
|
+
//
|
|
13
|
+
// - Split on `:`.
|
|
14
|
+
// - If 2 tokens: host=tokens[0], container=tokens[1], mode=default.
|
|
15
|
+
// - If 3+ tokens: check if the LAST token is `ro`/`rw`. If yes:
|
|
16
|
+
// mode = last; container = second-to-last; host = everything else
|
|
17
|
+
// joined by `:`.
|
|
18
|
+
// Otherwise:
|
|
19
|
+
// container = last; host = everything else joined by `:`;
|
|
20
|
+
// mode = default.
|
|
21
|
+
//
|
|
22
|
+
// `~`-expansion happens on the host side (same rules as .ycrc).
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const os = require('os');
|
|
27
|
+
|
|
28
|
+
const VALID_MODES = new Set(['ro', 'rw']);
|
|
29
|
+
const DEFAULT_MODE = 'rw';
|
|
30
|
+
|
|
31
|
+
function expandHomeOnHost(p) {
|
|
32
|
+
if (typeof p !== 'string') return p;
|
|
33
|
+
if (p.startsWith('~/')) return os.homedir() + p.slice(1);
|
|
34
|
+
if (p === '~') return os.homedir();
|
|
35
|
+
return p.replace(/\$HOME(?![A-Za-z0-9_])/g, os.homedir());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseBindSpec(spec) {
|
|
39
|
+
if (typeof spec !== 'string' || spec.length === 0) {
|
|
40
|
+
throw new Error(`bind-spec: empty or non-string spec`);
|
|
41
|
+
}
|
|
42
|
+
const tokens = spec.split(':');
|
|
43
|
+
if (tokens.length < 2) {
|
|
44
|
+
throw new Error(`bind-spec: invalid spec ${JSON.stringify(spec)} (need at least host:container)`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let host, container, mode;
|
|
48
|
+
if (tokens.length === 2) {
|
|
49
|
+
[host, container] = tokens;
|
|
50
|
+
mode = DEFAULT_MODE;
|
|
51
|
+
} else {
|
|
52
|
+
const last = tokens[tokens.length - 1];
|
|
53
|
+
if (VALID_MODES.has(last)) {
|
|
54
|
+
mode = last;
|
|
55
|
+
container = tokens[tokens.length - 2];
|
|
56
|
+
host = tokens.slice(0, tokens.length - 2).join(':');
|
|
57
|
+
} else {
|
|
58
|
+
mode = DEFAULT_MODE;
|
|
59
|
+
container = last;
|
|
60
|
+
host = tokens.slice(0, tokens.length - 1).join(':');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!host) throw new Error(`bind-spec: empty host path in ${JSON.stringify(spec)}`);
|
|
65
|
+
if (!container) throw new Error(`bind-spec: empty container path in ${JSON.stringify(spec)}`);
|
|
66
|
+
if (!container.startsWith('/')) {
|
|
67
|
+
throw new Error(`bind-spec: container path must be absolute, got ${JSON.stringify(container)}`);
|
|
68
|
+
}
|
|
69
|
+
host = expandHomeOnHost(host);
|
|
70
|
+
|
|
71
|
+
return { host, container, mode };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatBindSpec(b) {
|
|
75
|
+
return `${b.host}:${b.container}:${b.mode}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Dedupe a list of bind specs by their host:container pair (mode is
|
|
79
|
+
// not part of the dedupe key — later mode wins). Preserves input
|
|
80
|
+
// order, dropping subsequent duplicates.
|
|
81
|
+
function dedupeBindList(list) {
|
|
82
|
+
const seen = new Map(); // "host:container" -> index in out
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const b of list) {
|
|
85
|
+
const key = `${b.host}:${b.container}`;
|
|
86
|
+
if (seen.has(key)) {
|
|
87
|
+
// Later layer's mode wins.
|
|
88
|
+
out[seen.get(key)] = b;
|
|
89
|
+
} else {
|
|
90
|
+
seen.set(key, out.length);
|
|
91
|
+
out.push(b);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
parseBindSpec,
|
|
99
|
+
formatBindSpec,
|
|
100
|
+
dedupeBindList,
|
|
101
|
+
expandHomeOnHost,
|
|
102
|
+
VALID_MODES,
|
|
103
|
+
DEFAULT_MODE,
|
|
104
|
+
};
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Configuration cascade.
|
|
2
|
+
//
|
|
3
|
+
// Precedence (lowest → highest):
|
|
4
|
+
// 1. type defaults (claude / codex)
|
|
5
|
+
// 2. ~/.ycrc
|
|
6
|
+
// 3. ./.ycrc
|
|
7
|
+
// 4. CLI flags
|
|
8
|
+
//
|
|
9
|
+
// Per-key cascade rule (matches the table in README.md §"Cascade semantics"):
|
|
10
|
+
//
|
|
11
|
+
// type, image, workspace, config_dir, memory, cpus, tmux,
|
|
12
|
+
// ssproxy_extensions → replace (later wins entirely)
|
|
13
|
+
// bind_dirs → replace (later layer wins entirely)
|
|
14
|
+
// extra_bind_dirs → append + dedupe by host:container
|
|
15
|
+
//
|
|
16
|
+
// The output is a fully-resolved plain-object cage spec ready for
|
|
17
|
+
// lib/docker.js to translate into a `docker run` argv.
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
|
|
24
|
+
const { getType } = require('./types');
|
|
25
|
+
const { readYcrc } = require('./ycrc');
|
|
26
|
+
const { parseBindSpec, dedupeBindList } = require('./bind-spec');
|
|
27
|
+
|
|
28
|
+
const REPLACE_KEYS = new Set([
|
|
29
|
+
'type',
|
|
30
|
+
'image',
|
|
31
|
+
'workspace',
|
|
32
|
+
'config_dir',
|
|
33
|
+
'memory',
|
|
34
|
+
'cpus',
|
|
35
|
+
'tmux',
|
|
36
|
+
'ssproxy_extensions',
|
|
37
|
+
'bind_dirs', // replace, not append
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const APPEND_KEYS = new Set(['extra_bind_dirs']);
|
|
41
|
+
|
|
42
|
+
// Resolve a raw cascade-merged spec into a normalized cage spec. The
|
|
43
|
+
// raw spec carries .ycrc-shaped strings; the normalized spec carries
|
|
44
|
+
// the values we'll hand to docker.
|
|
45
|
+
function normalize(raw) {
|
|
46
|
+
const type = raw.type || 'claude';
|
|
47
|
+
const td = getType(type);
|
|
48
|
+
|
|
49
|
+
const workspace = raw.workspace
|
|
50
|
+
? path.resolve(raw.workspace)
|
|
51
|
+
: path.resolve(process.cwd());
|
|
52
|
+
|
|
53
|
+
const configDirHost = raw.config_dir
|
|
54
|
+
? path.resolve(raw.config_dir)
|
|
55
|
+
: td.configDirHost;
|
|
56
|
+
|
|
57
|
+
// bind_dirs (replace): if the user set it, use ONLY those + the
|
|
58
|
+
// workspace + the config_dir. If unset, generate the type defaults.
|
|
59
|
+
let bindDirs;
|
|
60
|
+
if (Array.isArray(raw.bind_dirs) && raw.bind_dirs.length > 0) {
|
|
61
|
+
bindDirs = raw.bind_dirs.map(parseBindSpec);
|
|
62
|
+
} else {
|
|
63
|
+
bindDirs = [
|
|
64
|
+
{ host: workspace, container: '/workspace', mode: 'rw' },
|
|
65
|
+
{ host: configDirHost, container: td.configDirContainer, mode: 'rw' },
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const extraBindDirs = (raw.extra_bind_dirs || []).map(parseBindSpec);
|
|
70
|
+
const allBinds = dedupeBindList([...bindDirs, ...extraBindDirs]);
|
|
71
|
+
|
|
72
|
+
const tmux = parseBool(raw.tmux);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
type,
|
|
76
|
+
image: raw.image || td.image,
|
|
77
|
+
workspace,
|
|
78
|
+
configDirHost,
|
|
79
|
+
configDirContainer: td.configDirContainer,
|
|
80
|
+
bindDirs: allBinds,
|
|
81
|
+
memory: raw.memory || null,
|
|
82
|
+
cpus: raw.cpus || null,
|
|
83
|
+
tmux,
|
|
84
|
+
ssproxyExtensions: raw.ssproxy_extensions || null,
|
|
85
|
+
cmd: td.cmd.slice(),
|
|
86
|
+
passthrough: Array.isArray(raw.passthrough) ? raw.passthrough.slice() : [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseBool(v) {
|
|
91
|
+
if (typeof v === 'boolean') return v;
|
|
92
|
+
if (v === undefined || v === null || v === '') return false;
|
|
93
|
+
const s = String(v).toLowerCase();
|
|
94
|
+
return s === 'true' || s === '1' || s === 'yes' || s === 'on';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Merge `incoming` into `acc`. Replace keys clobber; append keys union.
|
|
98
|
+
function mergeLayer(acc, incoming) {
|
|
99
|
+
if (!incoming) return acc;
|
|
100
|
+
for (const k of Object.keys(incoming)) {
|
|
101
|
+
if (APPEND_KEYS.has(k)) {
|
|
102
|
+
const prev = Array.isArray(acc[k]) ? acc[k] : [];
|
|
103
|
+
const next = Array.isArray(incoming[k]) ? incoming[k] : [incoming[k]];
|
|
104
|
+
acc[k] = [...prev, ...next];
|
|
105
|
+
} else if (REPLACE_KEYS.has(k)) {
|
|
106
|
+
if (incoming[k] !== undefined) acc[k] = incoming[k];
|
|
107
|
+
} else {
|
|
108
|
+
// Unknown key — preserve last-wins like REPLACE for forward compat.
|
|
109
|
+
if (incoming[k] !== undefined) acc[k] = incoming[k];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return acc;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Build the cascade.
|
|
116
|
+
// opts.type — explicit type from argv ('claude'|'codex'|...)
|
|
117
|
+
// opts.homeYcrcPath — typically ~/.ycrc
|
|
118
|
+
// opts.projectYcrcPath — typically ./.ycrc (cwd)
|
|
119
|
+
// opts.cliLayer — already-parsed object from commander
|
|
120
|
+
function resolveCascade(opts) {
|
|
121
|
+
const acc = {};
|
|
122
|
+
|
|
123
|
+
// Layer 1: type baseline — sets `type` only. Defaults for workspace
|
|
124
|
+
// / config_dir flow through normalize() based on cwd + $HOME.
|
|
125
|
+
if (opts.type) mergeLayer(acc, { type: opts.type });
|
|
126
|
+
|
|
127
|
+
// Layer 2: ~/.ycrc
|
|
128
|
+
let home = null;
|
|
129
|
+
try {
|
|
130
|
+
home = readYcrc(opts.homeYcrcPath);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
throw new Error(`error reading ${opts.homeYcrcPath}: ${e.message}`);
|
|
133
|
+
}
|
|
134
|
+
if (home) mergeLayer(acc, home);
|
|
135
|
+
|
|
136
|
+
// Layer 3: ./.ycrc
|
|
137
|
+
let project = null;
|
|
138
|
+
try {
|
|
139
|
+
project = readYcrc(opts.projectYcrcPath);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
throw new Error(`error reading ${opts.projectYcrcPath}: ${e.message}`);
|
|
142
|
+
}
|
|
143
|
+
if (project) mergeLayer(acc, project);
|
|
144
|
+
|
|
145
|
+
// Layer 4: CLI flags
|
|
146
|
+
if (opts.cliLayer) mergeLayer(acc, opts.cliLayer);
|
|
147
|
+
|
|
148
|
+
return normalize(acc);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
resolveCascade,
|
|
153
|
+
normalize,
|
|
154
|
+
mergeLayer,
|
|
155
|
+
parseBool,
|
|
156
|
+
REPLACE_KEYS,
|
|
157
|
+
APPEND_KEYS,
|
|
158
|
+
};
|
package/lib/docker.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Docker shell-out helpers.
|
|
2
|
+
//
|
|
3
|
+
// All docker invocations route through here. The module probes once on
|
|
4
|
+
// first use for whether the current user can talk to the docker daemon
|
|
5
|
+
// without sudo; if not, every command is prefixed with `sudo`. The
|
|
6
|
+
// fix-it message ("add yourself to the docker group with …") is printed
|
|
7
|
+
// at most once per process.
|
|
8
|
+
//
|
|
9
|
+
// Tests mock `child_process` to drive the probe branches.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { spawnSync, execFileSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
let _sudoChecked = false;
|
|
16
|
+
let _useSudo = false;
|
|
17
|
+
let _noticePrinted = false;
|
|
18
|
+
|
|
19
|
+
function _probe(env) {
|
|
20
|
+
if (_sudoChecked) return _useSudo;
|
|
21
|
+
_sudoChecked = true;
|
|
22
|
+
|
|
23
|
+
// DOCKER_HOST suggests the user is talking to a non-local daemon
|
|
24
|
+
// (rootless, ssh:// tunnel, etc.) — don't try sudo in that case;
|
|
25
|
+
// the env tells us they've already wired it up themselves.
|
|
26
|
+
if (env.DOCKER_HOST) {
|
|
27
|
+
_useSudo = false;
|
|
28
|
+
return _useSudo;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// First try docker info as the calling user.
|
|
32
|
+
const direct = spawnSync('docker', ['info'], { stdio: 'ignore', env });
|
|
33
|
+
if (direct.status === 0) {
|
|
34
|
+
_useSudo = false;
|
|
35
|
+
return _useSudo;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Try sudo. If sudo isn't installed or password-prompts, this fails
|
|
39
|
+
// and we fall through to "use direct + let the error tell the user".
|
|
40
|
+
const sudo = spawnSync('sudo', ['-n', 'docker', 'info'], { stdio: 'ignore', env });
|
|
41
|
+
if (sudo.status === 0) {
|
|
42
|
+
_useSudo = true;
|
|
43
|
+
return _useSudo;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Couldn't reach docker either way. Don't trip sudo — let the next
|
|
47
|
+
// docker call surface the real error to the user.
|
|
48
|
+
_useSudo = false;
|
|
49
|
+
return _useSudo;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function maybeSudo(env) {
|
|
53
|
+
return _probe(env || process.env);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Reset the module-level cache. For tests and re-init flows; not part
|
|
57
|
+
// of the public CLI surface.
|
|
58
|
+
function _resetProbeCache() {
|
|
59
|
+
_sudoChecked = false;
|
|
60
|
+
_useSudo = false;
|
|
61
|
+
_noticePrinted = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _printNoticeOnce(stderr) {
|
|
65
|
+
if (_noticePrinted) return;
|
|
66
|
+
_noticePrinted = true;
|
|
67
|
+
stderr.write(
|
|
68
|
+
'yolocage: docker requires sudo on this system. To skip this, add ' +
|
|
69
|
+
'yourself to the docker group:\n' +
|
|
70
|
+
' sudo usermod -aG docker $USER && newgrp docker\n'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Build the docker argv for a given cage spec. Returns:
|
|
75
|
+
// { argv: [...], cmd: [...] } for piping into spawnSync(argv[0], argv.slice(1).concat(cmd))
|
|
76
|
+
function buildRunArgs(spec, opts) {
|
|
77
|
+
opts = opts || {};
|
|
78
|
+
const args = ['run'];
|
|
79
|
+
if (opts.rm !== false) args.push('--rm');
|
|
80
|
+
if (opts.interactive !== false) args.push('-it');
|
|
81
|
+
if (opts.detach) args.push('-d');
|
|
82
|
+
if (opts.name) args.push('--name', opts.name);
|
|
83
|
+
|
|
84
|
+
// Per-cage docker volume for mitmproxy CA + state. Shared across
|
|
85
|
+
// create/run for named cages so the CA persists.
|
|
86
|
+
const volName = opts.volName || (opts.name ? `${opts.name}-mitmproxy` : 'yolocage-mitmproxy-ephemeral');
|
|
87
|
+
args.push('-v', `${volName}:/home/agent/.mitmproxy`);
|
|
88
|
+
|
|
89
|
+
for (const b of spec.bindDirs) {
|
|
90
|
+
args.push('-v', `${b.host}:${b.container}:${b.mode}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (spec.memory) args.push('--memory', spec.memory);
|
|
94
|
+
if (spec.cpus) args.push('--cpus', String(spec.cpus));
|
|
95
|
+
|
|
96
|
+
// Workdir defaults to /workspace.
|
|
97
|
+
args.push('-w', '/workspace');
|
|
98
|
+
|
|
99
|
+
// Env: tmux toggle + extensions path.
|
|
100
|
+
args.push('-e', `YC_TMUX=${spec.tmux ? '1' : '0'}`);
|
|
101
|
+
if (spec.ssproxyExtensions) {
|
|
102
|
+
// The host file is mounted RO into a stable container location.
|
|
103
|
+
args.push('-v', `${spec.ssproxyExtensions}:/etc/yolocage/scrub-extensions.json:ro`);
|
|
104
|
+
args.push('-e', 'SSPROXY_EXTENSIONS=/etc/yolocage/scrub-extensions.json');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
args.push(spec.image);
|
|
108
|
+
const cmd = [...spec.cmd, ...(spec.passthrough || [])];
|
|
109
|
+
return { argv: args, cmd };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Compose the final argv with sudo prefix as needed.
|
|
113
|
+
function dockerArgv(args, env) {
|
|
114
|
+
env = env || process.env;
|
|
115
|
+
if (maybeSudo(env)) return ['sudo', '-n', 'docker', ...args];
|
|
116
|
+
return ['docker', ...args];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Does a container with this name exist (running OR stopped)?
|
|
120
|
+
// Uses `docker container inspect` which exits 0 for any state.
|
|
121
|
+
function cageExists(name, opts) {
|
|
122
|
+
opts = opts || {};
|
|
123
|
+
const env = opts.env || process.env;
|
|
124
|
+
const argv = dockerArgv(['container', 'inspect', name], env);
|
|
125
|
+
const res = spawnSync(argv[0], argv.slice(1), { stdio: 'ignore', env });
|
|
126
|
+
return res.status === 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Is the container with this name in the Running state? Returns false
|
|
130
|
+
// for non-existent OR stopped containers.
|
|
131
|
+
function cageRunning(name, opts) {
|
|
132
|
+
opts = opts || {};
|
|
133
|
+
const env = opts.env || process.env;
|
|
134
|
+
const argv = dockerArgv(
|
|
135
|
+
['container', 'inspect', '-f', '{{.State.Running}}', name],
|
|
136
|
+
env
|
|
137
|
+
);
|
|
138
|
+
const res = spawnSync(argv[0], argv.slice(1), {
|
|
139
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
140
|
+
env,
|
|
141
|
+
encoding: 'utf8',
|
|
142
|
+
});
|
|
143
|
+
if (res.status !== 0) return false;
|
|
144
|
+
return String(res.stdout || '').trim() === 'true';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Run a docker command synchronously, inheriting stdio. Returns the
|
|
148
|
+
// exit status; callers may pass {stdio: 'pipe'} for captured output.
|
|
149
|
+
function runDocker(args, opts) {
|
|
150
|
+
opts = opts || {};
|
|
151
|
+
const env = opts.env || process.env;
|
|
152
|
+
const stderr = opts.stderr || process.stderr;
|
|
153
|
+
if (maybeSudo(env)) _printNoticeOnce(stderr);
|
|
154
|
+
const argv = dockerArgv(args, env);
|
|
155
|
+
const res = spawnSync(argv[0], argv.slice(1), {
|
|
156
|
+
stdio: opts.stdio || 'inherit',
|
|
157
|
+
env,
|
|
158
|
+
});
|
|
159
|
+
return res;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
maybeSudo,
|
|
164
|
+
buildRunArgs,
|
|
165
|
+
dockerArgv,
|
|
166
|
+
runDocker,
|
|
167
|
+
cageExists,
|
|
168
|
+
cageRunning,
|
|
169
|
+
_resetProbeCache,
|
|
170
|
+
};
|
package/lib/types.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Type defaults table. The shortcut form (`yc claude`) and named-cage
|
|
2
|
+
// `--type=…` flag pick from these. v0 ships claude + codex enabled; opencode
|
|
3
|
+
// is sketched in but `v0:false` so the CLI errors cleanly if the operator
|
|
4
|
+
// asks for it.
|
|
5
|
+
//
|
|
6
|
+
// Each entry's `bindDirs` describes the default mounts for a fresh cage:
|
|
7
|
+
// workspace: a host path mounted at /workspace (default = cwd)
|
|
8
|
+
// configDir: a host path for the agent's per-user config (claude.ai login
|
|
9
|
+
// cache + codex auth). Replaceable via --config-dir.
|
|
10
|
+
//
|
|
11
|
+
// `cmd` is the in-container command line the entrypoint exec()s by default.
|
|
12
|
+
// CLI pass-through args (`yc claude -- --resume`) are appended after.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
|
|
19
|
+
const HOME = os.homedir();
|
|
20
|
+
|
|
21
|
+
const TYPE_DEFAULTS = {
|
|
22
|
+
claude: {
|
|
23
|
+
v0: true,
|
|
24
|
+
image: 'yolocage/claude:dev',
|
|
25
|
+
configDirHost: path.join(HOME, '.claude'),
|
|
26
|
+
configDirContainer: '/home/agent/.claude',
|
|
27
|
+
// --continue auto-resumes the most recent in-cwd conversation; harmless on
|
|
28
|
+
// first run (claude falls back to a fresh session). With per-cwd persistent
|
|
29
|
+
// cages, this is what the operator wants by default — `yc claude` always
|
|
30
|
+
// either resumes or starts fresh, never re-introduces itself mid-project.
|
|
31
|
+
cmd: ['claude', '--continue', '--dangerously-skip-permissions'],
|
|
32
|
+
},
|
|
33
|
+
codex: {
|
|
34
|
+
v0: true,
|
|
35
|
+
image: 'yolocage/codex:dev',
|
|
36
|
+
configDirHost: path.join(HOME, '.codex'),
|
|
37
|
+
configDirContainer: '/home/agent/.codex',
|
|
38
|
+
cmd: ['codex', '--full-auto'],
|
|
39
|
+
},
|
|
40
|
+
opencode: {
|
|
41
|
+
v0: false,
|
|
42
|
+
image: 'yolocage/opencode:dev',
|
|
43
|
+
configDirHost: path.join(HOME, '.config', 'opencode'),
|
|
44
|
+
configDirContainer: '/home/agent/.config/opencode',
|
|
45
|
+
cmd: ['opencode'],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function isKnownType(type) {
|
|
50
|
+
return Object.prototype.hasOwnProperty.call(TYPE_DEFAULTS, type);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getType(type) {
|
|
54
|
+
if (!isKnownType(type)) {
|
|
55
|
+
throw new Error(`unknown --type=${type} (known: ${Object.keys(TYPE_DEFAULTS).join(', ')})`);
|
|
56
|
+
}
|
|
57
|
+
const t = TYPE_DEFAULTS[type];
|
|
58
|
+
if (!t.v0) {
|
|
59
|
+
throw new Error(`--type=${type} support coming in v2 (v0 ships claude + codex only)`);
|
|
60
|
+
}
|
|
61
|
+
return t;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { TYPE_DEFAULTS, getType, isKnownType };
|