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/lib/update.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// `yc update` — refresh the yolocage CLI itself + the cage images.
|
|
2
|
+
//
|
|
3
|
+
// Two halves:
|
|
4
|
+
// 1. CLI binary: re-install yolocage via npm from the global registry.
|
|
5
|
+
// We probe how the user is invoking yc (global npm install vs npx
|
|
6
|
+
// run vs unknown) and either install/upgrade or print a helpful
|
|
7
|
+
// no-op message.
|
|
8
|
+
// 2. Cage images: re-pull yolocage/claude:latest + yolocage/codex:latest
|
|
9
|
+
// so the next cage launch starts from current bytes.
|
|
10
|
+
//
|
|
11
|
+
// Sudo handling mirrors lib/docker.js — npm global installs typically
|
|
12
|
+
// land in a path that requires root (/usr/local/lib/node_modules on
|
|
13
|
+
// many distros). We try without sudo first; on permission failure we
|
|
14
|
+
// retry with `sudo -n` (non-interactive) and fall through to a clear
|
|
15
|
+
// "please re-run with sudo" message if even that fails.
|
|
16
|
+
//
|
|
17
|
+
// Tests in test/update.test.js mock child_process to drive each branch.
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { spawnSync, execFileSync } = require('child_process');
|
|
23
|
+
const { runDocker } = require('./docker');
|
|
24
|
+
const { TYPE_DEFAULTS } = require('./types');
|
|
25
|
+
|
|
26
|
+
const PKG_NAME = 'yolocage';
|
|
27
|
+
|
|
28
|
+
// Identify how the current process was launched. Three classes matter:
|
|
29
|
+
// - 'npx' — running via `npx yolocage …`; the binary lives
|
|
30
|
+
// under a per-invocation cache dir. Re-installing is
|
|
31
|
+
// a no-op for the user; the next npx call fetches
|
|
32
|
+
// whatever's current.
|
|
33
|
+
// - 'npm-global' — installed via `npm install -g yolocage`. This is
|
|
34
|
+
// the normal case; `npm install -g yolocage@latest`
|
|
35
|
+
// upgrades in place.
|
|
36
|
+
// - 'unknown' — running from source / linked checkout / unusual
|
|
37
|
+
// layout. Refuse to mutate; print a hint.
|
|
38
|
+
function detectInstallType(scriptPath) {
|
|
39
|
+
const p = scriptPath || (require.main && require.main.filename) || __filename;
|
|
40
|
+
if (p.includes(`${path.sep}_npx${path.sep}`)) return 'npx';
|
|
41
|
+
if (p.includes(`${path.sep}node_modules${path.sep}${PKG_NAME}${path.sep}`)) {
|
|
42
|
+
return 'npm-global';
|
|
43
|
+
}
|
|
44
|
+
return 'unknown';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getCurrentVersion() {
|
|
48
|
+
// Read our own package.json. Resolved relative to this file so the
|
|
49
|
+
// function works equally for tests, npx, and global installs.
|
|
50
|
+
return require(path.join(__dirname, '..', 'package.json')).version;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fetch the latest published version from npm. Throws on network or
|
|
54
|
+
// registry errors so the caller can present a clean diagnostic.
|
|
55
|
+
function getLatestVersion(deps) {
|
|
56
|
+
deps = deps || {};
|
|
57
|
+
const exec = deps.execFileSync || execFileSync;
|
|
58
|
+
try {
|
|
59
|
+
const out = exec('npm', ['view', PKG_NAME, 'version'], {
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
+
});
|
|
63
|
+
return String(out).trim();
|
|
64
|
+
} catch (e) {
|
|
65
|
+
const reason = (e.stderr && String(e.stderr).trim()) || e.message;
|
|
66
|
+
throw new Error(`failed to fetch latest version from npm registry: ${reason}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Install yolocage@<version> globally via npm. Returns { ok, sudoUsed }.
|
|
71
|
+
// On EACCES (npm global path needs root) we retry once with `sudo -n`.
|
|
72
|
+
function runNpmInstall(version, deps) {
|
|
73
|
+
deps = deps || {};
|
|
74
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
75
|
+
const stdout = deps.stdout || process.stdout;
|
|
76
|
+
const stderr = deps.stderr || process.stderr;
|
|
77
|
+
const target = `${PKG_NAME}@${version}`;
|
|
78
|
+
|
|
79
|
+
stdout.write(`yolocage: installing ${target} via npm...\n`);
|
|
80
|
+
const direct = spawn('npm', ['install', '-g', target], { stdio: 'inherit' });
|
|
81
|
+
if (direct.status === 0) return { ok: true, sudoUsed: false };
|
|
82
|
+
|
|
83
|
+
// Inspect the error. If it smells like a permission problem, retry
|
|
84
|
+
// with sudo non-interactively. If sudo would prompt or doesn't help,
|
|
85
|
+
// print actionable guidance and return failure.
|
|
86
|
+
const stderrBuf = direct.stderr ? String(direct.stderr) : '';
|
|
87
|
+
const looksLikePermission =
|
|
88
|
+
stderrBuf.includes('EACCES') ||
|
|
89
|
+
stderrBuf.includes('permission denied') ||
|
|
90
|
+
stderrBuf.includes('EPERM') ||
|
|
91
|
+
direct.status === 243;
|
|
92
|
+
|
|
93
|
+
if (!looksLikePermission) return { ok: false, sudoUsed: false };
|
|
94
|
+
|
|
95
|
+
stderr.write(
|
|
96
|
+
'yolocage: npm install needs root for the global prefix; retrying with sudo\n'
|
|
97
|
+
);
|
|
98
|
+
const sudo = spawn('sudo', ['-n', 'npm', 'install', '-g', target], {
|
|
99
|
+
stdio: 'inherit',
|
|
100
|
+
});
|
|
101
|
+
if (sudo.status === 0) return { ok: true, sudoUsed: true };
|
|
102
|
+
|
|
103
|
+
stderr.write(
|
|
104
|
+
'yolocage: passwordless sudo unavailable. Re-run with sudo:\n' +
|
|
105
|
+
` sudo npm install -g ${target}\n`
|
|
106
|
+
);
|
|
107
|
+
return { ok: false, sudoUsed: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pullImages(deps) {
|
|
111
|
+
deps = deps || {};
|
|
112
|
+
const stdout = deps.stdout || process.stdout;
|
|
113
|
+
const runner = deps.runDocker || runDocker;
|
|
114
|
+
let worstStatus = 0;
|
|
115
|
+
for (const t of Object.keys(TYPE_DEFAULTS)) {
|
|
116
|
+
const def = TYPE_DEFAULTS[t];
|
|
117
|
+
if (!def.v0) continue; // skip the opencode stub
|
|
118
|
+
stdout.write(`yolocage: pulling ${def.image}\n`);
|
|
119
|
+
const res = runner(['pull', def.image]);
|
|
120
|
+
if (res.status !== 0) worstStatus = res.status || 1;
|
|
121
|
+
}
|
|
122
|
+
return worstStatus;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The exported entry. Accepts an `opts` bag for testing:
|
|
126
|
+
// - check — only report the version delta, don't install
|
|
127
|
+
// - pull — also refresh cage images (default true)
|
|
128
|
+
// - force — install even if already on latest
|
|
129
|
+
// - deps — DI bag for tests (execFileSync, spawnSync, runDocker,
|
|
130
|
+
// stdout, stderr, scriptPath)
|
|
131
|
+
async function update(opts) {
|
|
132
|
+
opts = opts || {};
|
|
133
|
+
const stdout = (opts.deps && opts.deps.stdout) || process.stdout;
|
|
134
|
+
const stderr = (opts.deps && opts.deps.stderr) || process.stderr;
|
|
135
|
+
|
|
136
|
+
const current = getCurrentVersion();
|
|
137
|
+
stdout.write(`yolocage: current version ${current}\n`);
|
|
138
|
+
|
|
139
|
+
let latest;
|
|
140
|
+
try {
|
|
141
|
+
latest = getLatestVersion(opts.deps);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
stderr.write(`yolocage: ${e.message}\n`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
stdout.write(`yolocage: latest version ${latest}\n`);
|
|
147
|
+
|
|
148
|
+
if (opts.check) return current === latest ? 0 : 1;
|
|
149
|
+
|
|
150
|
+
if (current === latest && !opts.force) {
|
|
151
|
+
stdout.write(`yolocage: already on latest (${latest})\n`);
|
|
152
|
+
if (opts.pull !== false) pullImages(opts.deps);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const installType = detectInstallType(opts.deps && opts.deps.scriptPath);
|
|
157
|
+
|
|
158
|
+
if (installType === 'npx') {
|
|
159
|
+
stdout.write(
|
|
160
|
+
'yolocage: running via npx — no install to update; the next ' +
|
|
161
|
+
'`npx yolocage` fetches the latest from the registry\n'
|
|
162
|
+
);
|
|
163
|
+
if (opts.pull !== false) pullImages(opts.deps);
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (installType === 'unknown') {
|
|
168
|
+
stderr.write(
|
|
169
|
+
'yolocage: cannot auto-update — running from a source checkout or ' +
|
|
170
|
+
'a non-standard install layout. Update yolocage manually (git ' +
|
|
171
|
+
'pull, npm link, or whatever your workflow uses).\n'
|
|
172
|
+
);
|
|
173
|
+
if (opts.pull !== false) pullImages(opts.deps);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// npm-global: do the install.
|
|
178
|
+
const result = runNpmInstall(latest, opts.deps);
|
|
179
|
+
if (!result.ok) return 1;
|
|
180
|
+
stdout.write(`yolocage: updated to ${latest}\n`);
|
|
181
|
+
|
|
182
|
+
if (opts.pull !== false) pullImages(opts.deps);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
update,
|
|
188
|
+
// exposed for tests
|
|
189
|
+
detectInstallType,
|
|
190
|
+
getCurrentVersion,
|
|
191
|
+
getLatestVersion,
|
|
192
|
+
runNpmInstall,
|
|
193
|
+
pullImages,
|
|
194
|
+
};
|
package/lib/ycrc.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// .ycrc parser.
|
|
2
|
+
//
|
|
3
|
+
// Strict key=value with `#` comments. ONLY `~` (leading) and literal
|
|
4
|
+
// `$HOME` are expanded in values — no shell, no eval, no `$(…)` /
|
|
5
|
+
// backtick substitution. The threat model is hostile project-local
|
|
6
|
+
// `.ycrc` files: a malicious repo MUST NOT be able to execute
|
|
7
|
+
// `$(rm -rf ~)` just by being cloned. See README §"Security model".
|
|
8
|
+
//
|
|
9
|
+
// Multi-value keys (extra_bind_dirs) appear once per value; the cascade
|
|
10
|
+
// in lib/config.js unions them across layers.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
// Keys whose value may legitimately appear multiple times in a single
|
|
18
|
+
// .ycrc. Anything not listed here: last occurrence wins.
|
|
19
|
+
const MULTI_VALUE_KEYS = new Set(['extra_bind_dirs', 'bind_dirs']);
|
|
20
|
+
|
|
21
|
+
// Path-shaped keys get ~/$HOME expansion applied to their values.
|
|
22
|
+
// Non-path values (memory=4g, type=claude) pass through untouched.
|
|
23
|
+
const PATH_KEYS = new Set([
|
|
24
|
+
'workspace',
|
|
25
|
+
'config_dir',
|
|
26
|
+
'ssproxy_extensions',
|
|
27
|
+
'bind_dirs',
|
|
28
|
+
'extra_bind_dirs',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function expandPathish(value) {
|
|
32
|
+
if (typeof value !== 'string') return value;
|
|
33
|
+
let v = value;
|
|
34
|
+
// Leading `~` → $HOME. Only at start; `foo~bar` is left alone.
|
|
35
|
+
if (v.startsWith('~/')) {
|
|
36
|
+
v = os.homedir() + v.slice(1);
|
|
37
|
+
} else if (v === '~') {
|
|
38
|
+
v = os.homedir();
|
|
39
|
+
}
|
|
40
|
+
// Literal `$HOME` → $HOME. Not `${HOME}`, not `$HOME_DIR`, not any
|
|
41
|
+
// other shell variable — that's the design. A hostile .ycrc can write
|
|
42
|
+
// `$(rm -rf ~)` and it stays the literal 11-character string.
|
|
43
|
+
v = v.replace(/\$HOME(?![A-Za-z0-9_])/g, os.homedir());
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseYcrc(text) {
|
|
48
|
+
const out = {};
|
|
49
|
+
// Track multi-value-key accumulators.
|
|
50
|
+
for (const k of MULTI_VALUE_KEYS) out[k] = [];
|
|
51
|
+
|
|
52
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
53
|
+
for (let lineno = 0; lineno < lines.length; lineno++) {
|
|
54
|
+
let line = lines[lineno];
|
|
55
|
+
// Strip comments: anything from `#` to EOL. We don't honour
|
|
56
|
+
// escaped `#` — the .ycrc format is intentionally minimal.
|
|
57
|
+
const hashIdx = line.indexOf('#');
|
|
58
|
+
if (hashIdx >= 0) line = line.slice(0, hashIdx);
|
|
59
|
+
line = line.trim();
|
|
60
|
+
if (!line) continue;
|
|
61
|
+
const eq = line.indexOf('=');
|
|
62
|
+
if (eq < 0) {
|
|
63
|
+
throw new Error(`.ycrc: malformed line ${lineno + 1}: ${JSON.stringify(line)} (expected key=value)`);
|
|
64
|
+
}
|
|
65
|
+
const key = line.slice(0, eq).trim();
|
|
66
|
+
let value = line.slice(eq + 1).trim();
|
|
67
|
+
if (!key) {
|
|
68
|
+
throw new Error(`.ycrc: empty key at line ${lineno + 1}`);
|
|
69
|
+
}
|
|
70
|
+
if (PATH_KEYS.has(key)) {
|
|
71
|
+
value = expandPathish(value);
|
|
72
|
+
}
|
|
73
|
+
if (MULTI_VALUE_KEYS.has(key)) {
|
|
74
|
+
out[key].push(value);
|
|
75
|
+
} else {
|
|
76
|
+
out[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Strip empty multi-value arrays so the cascade can treat "absent"
|
|
80
|
+
// and "empty" identically.
|
|
81
|
+
for (const k of MULTI_VALUE_KEYS) {
|
|
82
|
+
if (out[k].length === 0) delete out[k];
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readYcrc(filepath) {
|
|
88
|
+
let text;
|
|
89
|
+
try {
|
|
90
|
+
text = fs.readFileSync(filepath, 'utf8');
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (e.code === 'ENOENT') return null;
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
return parseYcrc(text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { parseYcrc, readYcrc, expandPathish, MULTI_VALUE_KEYS, PATH_KEYS };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yolocage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sandboxed claude-code / codex container with built-in egress credential scrubber. Run AI coding agents in --dangerously-skip-permissions mode with a safety net.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "yolocage contributors",
|
|
7
|
+
"homepage": "https://github.com/jlamendo/yolocage",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/jlamendo/yolocage.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"claude-code",
|
|
14
|
+
"codex",
|
|
15
|
+
"docker",
|
|
16
|
+
"sandbox",
|
|
17
|
+
"secrets",
|
|
18
|
+
"credential-scrubber",
|
|
19
|
+
"ai-agent"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=16"
|
|
23
|
+
},
|
|
24
|
+
"main": "yc.js",
|
|
25
|
+
"bin": {
|
|
26
|
+
"yolocage": "./yc.js",
|
|
27
|
+
"yc": "./yc.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"yc.js",
|
|
31
|
+
"lib/",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "jest --colors",
|
|
37
|
+
"test:watch": "jest --watch",
|
|
38
|
+
"test:coverage": "jest --coverage",
|
|
39
|
+
"lint": "node --check yc.js && find lib -name '*.js' -exec node --check {} \\;"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"commander": "^12.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"jest": "^29.7.0"
|
|
46
|
+
},
|
|
47
|
+
"jest": {
|
|
48
|
+
"testEnvironment": "node",
|
|
49
|
+
"testMatch": [
|
|
50
|
+
"**/test/**/*.test.js"
|
|
51
|
+
],
|
|
52
|
+
"collectCoverageFrom": [
|
|
53
|
+
"yc.js",
|
|
54
|
+
"lib/**/*.js"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|