@0xgeorgemathew/zcode 0.3.1 → 0.4.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 +179 -130
- package/bin/zcode.js +181 -140
- package/lib/appserver.js +190 -135
- package/lib/bootstrap.js +39 -60
- package/lib/checks.js +103 -0
- package/lib/commands/doctor.js +43 -64
- package/lib/commands/exec.js +327 -0
- package/lib/commands/login.js +2 -9
- package/lib/commands/mcp-list.js +6 -14
- package/lib/commands/sessions.js +181 -0
- package/lib/contract.js +301 -0
- package/lib/executor.js +617 -0
- package/lib/isolation.js +253 -193
- package/lib/key.js +6 -9
- package/lib/store.js +276 -0
- package/lib/ui.js +200 -71
- package/lib/workspace.js +336 -0
- package/package.json +9 -5
- package/skills/README.md +32 -0
- package/skills/claude-code/zcode/SKILL.md +112 -0
- package/skills/codex/zcode/SKILL.md +114 -0
- package/skills/codex/zcode/agents/openai.yaml +4 -0
- package/lib/commands/agent.js +0 -203
- package/lib/commands/run.js +0 -126
- package/lib/overlay.js +0 -138
package/lib/isolation.js
CHANGED
|
@@ -1,93 +1,71 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Scratch-HOME isolation (DESIGN §6): the engine runs with
|
|
3
|
-
//
|
|
2
|
+
// Scratch-HOME isolation (DESIGN §6, reworked in 0.4): the engine runs with
|
|
3
|
+
// HOME pointed at a directory we own so the live ~/.zcode — which the
|
|
4
4
|
// desktop app is actively using — is never written by us, and the user's
|
|
5
5
|
// OAuth credentials.json is never copied.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// 0.4: one engine HOME per zcode session (<root>/sessions/<id>/home) instead
|
|
8
|
+
// of one shared HOME. The shared HOME was rm -rf'd and rebuilt whenever a
|
|
9
|
+
// volatile desktop file (agents-state.json, bot-config.v3.json, …) changed
|
|
10
|
+
// mtime — which deleted the engine's session database under every other
|
|
11
|
+
// session and made --session resume 404 — and per-run MCP/skills overlays
|
|
12
|
+
// rewrote files that concurrent runs were reading. Per-session HOMEs give
|
|
13
|
+
// each session its own engine DB, config and skills; config files are
|
|
14
|
+
// refreshed in place on every invocation and engine state (cli/db,
|
|
15
|
+
// cli/agents, rollout, logs) is never deleted.
|
|
16
|
+
//
|
|
17
|
+
// Invariant (§6.4): zcode performs no writes to the live ~/.zcode, and
|
|
18
|
+
// credentials.json is never copied.
|
|
9
19
|
const fs = require('node:fs');
|
|
10
20
|
const path = require('node:path');
|
|
11
21
|
const os = require('node:os');
|
|
12
|
-
const crypto = require('node:crypto');
|
|
13
22
|
|
|
14
|
-
|
|
23
|
+
// The desktop app's live directory. ZAI_CODE_LIVE_DIR points elsewhere
|
|
24
|
+
// (e.g. a second desktop profile, or a fixture in tests).
|
|
25
|
+
function liveDir(env = process.env) {
|
|
26
|
+
return env.ZAI_CODE_LIVE_DIR || path.join(os.homedir(), '.zcode');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LIVE_ZCODE = liveDir({});
|
|
15
30
|
const LIVE_V2 = path.join(LIVE_ZCODE, 'v2');
|
|
16
31
|
|
|
17
32
|
// v2 top-level entries we deliberately never copy.
|
|
18
33
|
const V2_EXCLUDE_FILES = new Set(['credentials.json', '.DS_Store']);
|
|
19
34
|
function isV2Excluded(name) {
|
|
20
|
-
return (
|
|
21
|
-
V2_EXCLUDE_FILES.has(name) || name.startsWith('tasks-index.sqlite')
|
|
22
|
-
);
|
|
35
|
+
return V2_EXCLUDE_FILES.has(name) || name.startsWith('tasks-index.sqlite');
|
|
23
36
|
}
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
// Root of zcode's own state. MUST NOT be ~/.zcode: that is the desktop
|
|
39
|
+
// app's live directory (0.3.0/0.3.1 regression — the package rename made
|
|
40
|
+
// rootDir collide with it). Ours is ~/.zcode-cli.
|
|
41
|
+
function rootDir(env = process.env) {
|
|
42
|
+
return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode-cli');
|
|
43
|
+
}
|
|
27
44
|
|
|
28
|
-
// ---
|
|
45
|
+
// --- small fs helpers ---------------------------------------------------------
|
|
29
46
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
sources.push({ file: abs, mtimeMs: fs.statSync(abs).mtimeMs });
|
|
37
|
-
} catch {
|
|
38
|
-
/* source vanished — not copied */
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
try {
|
|
42
|
-
for (const entry of fs.readdirSync(LIVE_V2, { withFileTypes: true })) {
|
|
43
|
-
if (!entry.isFile() || isV2Excluded(entry.name)) continue;
|
|
44
|
-
pushFile(path.join(LIVE_V2, entry.name));
|
|
45
|
-
}
|
|
46
|
-
} catch {
|
|
47
|
-
/* no live v2 — synthesized path */
|
|
48
|
-
}
|
|
49
|
-
pushFile(path.join(LIVE_ZCODE, 'cli', 'config.json'));
|
|
50
|
-
pushFile(path.join(LIVE_ZCODE, 'AGENTS.md'));
|
|
47
|
+
function writeJson600(file, value) {
|
|
48
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
49
|
+
fs.chmodSync(file, 0o600);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readJson(file) {
|
|
51
53
|
try {
|
|
52
|
-
|
|
53
|
-
withFileTypes: true,
|
|
54
|
-
})) {
|
|
55
|
-
if (entry.isDirectory()) {
|
|
56
|
-
pushFile(path.join(LIVE_ZCODE, 'skills', entry.name, 'SKILL.md'));
|
|
57
|
-
}
|
|
58
|
-
}
|
|
54
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
59
55
|
} catch {
|
|
60
|
-
|
|
56
|
+
return null;
|
|
61
57
|
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function listDir(dir) {
|
|
62
61
|
try {
|
|
63
|
-
|
|
64
|
-
withFileTypes: true,
|
|
65
|
-
})) {
|
|
66
|
-
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
67
|
-
pushFile(path.join(LIVE_ZCODE, 'agents', entry.name));
|
|
68
|
-
}
|
|
69
|
-
}
|
|
62
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
70
63
|
} catch {
|
|
71
|
-
|
|
64
|
+
return [];
|
|
72
65
|
}
|
|
73
|
-
return sources;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// --- build ------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
function writeJson600(file, value) {
|
|
79
|
-
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', {
|
|
80
|
-
mode: 0o600,
|
|
81
|
-
});
|
|
82
|
-
fs.chmodSync(file, 0o600);
|
|
83
66
|
}
|
|
84
67
|
|
|
85
|
-
|
|
86
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
87
|
-
const dest = path.join(destDir, path.basename(src));
|
|
88
|
-
fs.copyFileSync(src, dest);
|
|
89
|
-
return dest;
|
|
90
|
-
}
|
|
68
|
+
// --- provider/key rewriting -----------------------------------------------------
|
|
91
69
|
|
|
92
70
|
// Prune to exactly the zai-api rule and swap in the package key (§6.2 item 2).
|
|
93
71
|
function rewriteProviderConfig(target, key) {
|
|
@@ -101,32 +79,16 @@ function rewriteProviderConfig(target, key) {
|
|
|
101
79
|
}
|
|
102
80
|
const zaiRules = rules.filter((r) => r && r.providerId === 'zai-api');
|
|
103
81
|
if (zaiRules.length === 0) {
|
|
104
|
-
throw new Error(
|
|
105
|
-
`no zai-api rule found in provider_config.json at ${target}`
|
|
106
|
-
);
|
|
82
|
+
throw new Error(`no zai-api rule found in provider_config.json at ${target}`);
|
|
107
83
|
}
|
|
108
84
|
const rule = zaiRules[0];
|
|
109
85
|
rule.config = rule.config || {};
|
|
110
86
|
rule.config.group = rule.config.group || 'standard-personal';
|
|
111
|
-
rule.config.access = {
|
|
112
|
-
type: 'api-key',
|
|
113
|
-
apiKey: key,
|
|
114
|
-
};
|
|
87
|
+
rule.config.access = { type: 'api-key', apiKey: key };
|
|
115
88
|
parsed.config.providerConfigRules.providerRules = [rule];
|
|
116
89
|
writeJson600(target, parsed);
|
|
117
90
|
}
|
|
118
91
|
|
|
119
|
-
// Overwrite builtin:zai apiKey with the package key (§6.2 item 3).
|
|
120
|
-
function rewriteCliConfig(target, key) {
|
|
121
|
-
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
122
|
-
const zai = parsed.provider && parsed.provider['builtin:zai'];
|
|
123
|
-
if (zai) {
|
|
124
|
-
zai.options = zai.options || {};
|
|
125
|
-
zai.options.apiKey = key;
|
|
126
|
-
}
|
|
127
|
-
writeJson600(target, parsed);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
92
|
function synthesizedProviderConfig(key) {
|
|
131
93
|
return {
|
|
132
94
|
schemaVersion: 1,
|
|
@@ -147,146 +109,240 @@ function synthesizedProviderConfig(key) {
|
|
|
147
109
|
};
|
|
148
110
|
}
|
|
149
111
|
|
|
150
|
-
//
|
|
151
|
-
// credentials.json, no sqlite. Fresh machines (no live v2) synthesize instead.
|
|
152
|
-
function buildScratch(home, key) {
|
|
153
|
-
fs.rmSync(home, { recursive: true, force: true });
|
|
154
|
-
const scratchV2 = path.join(home, '.zcode', 'v2');
|
|
155
|
-
fs.mkdirSync(scratchV2, { recursive: true });
|
|
112
|
+
// --- skills -------------------------------------------------------------------
|
|
156
113
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
114
|
+
// A --skills dir is either one skill (<dir>/SKILL.md) or a skills root
|
|
115
|
+
// (<dir>/<name>/SKILL.md). Returns [{name, dir}] of copyable skills.
|
|
116
|
+
function discoverSkillDirs(dir) {
|
|
117
|
+
if (fs.existsSync(path.join(dir, 'SKILL.md'))) {
|
|
118
|
+
return [{ name: path.basename(path.resolve(dir)), dir }];
|
|
119
|
+
}
|
|
120
|
+
return listDir(dir)
|
|
121
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
122
|
+
.map((e) => ({ name: e.name, dir: path.join(dir, e.name) }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- MCP ----------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
// The live desktop MCP servers, read-only, honoring `enabled`.
|
|
128
|
+
function liveMcpServers(live = LIVE_ZCODE) {
|
|
129
|
+
const cfg = readJson(path.join(live, 'cli', 'config.json'));
|
|
130
|
+
const servers = cfg && cfg.mcp && cfg.mcp.servers;
|
|
131
|
+
if (!servers || typeof servers !== 'object') return {};
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
134
|
+
if (!def || typeof def !== 'object' || def.enabled === false) continue;
|
|
135
|
+
out[name] = def;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Effective set for one invocation. Hermetic by default: ONLY the caller's
|
|
141
|
+
// --mcp servers. --mcp-inherit pulls in the desktop's enabled servers as a
|
|
142
|
+
// base (specs still win by name). Hermetic default is deliberate: the
|
|
143
|
+
// desktop set includes OAuth servers that can never complete headless and
|
|
144
|
+
// stall the engine's connect pool.
|
|
145
|
+
function effectiveMcpServers({ inherit = false, mcpSpecs = [], live = LIVE_ZCODE } = {}) {
|
|
146
|
+
const servers = inherit ? liveMcpServers(live) : {};
|
|
147
|
+
for (const spec of mcpSpecs) {
|
|
148
|
+
for (const [name, def] of Object.entries(spec)) servers[name] = def;
|
|
149
|
+
}
|
|
150
|
+
return servers;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- shared caches ------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
// Engine-populated caches that are expensive to recreate per session (the
|
|
156
|
+
// plugin marketplace cache is ~30 MB) are shared through symlinks into
|
|
157
|
+
// <root>/shared. A pre-0.4 shared HOME donates its caches once.
|
|
158
|
+
const SHARED_CACHES = [
|
|
159
|
+
{ name: 'plugins', rel: path.join('.zcode', 'cli', 'plugins') },
|
|
160
|
+
{ name: 'runtime', rel: path.join('.zcode', 'v2', 'runtime') },
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
function linkSharedCaches(home, root) {
|
|
164
|
+
for (const cache of SHARED_CACHES) {
|
|
165
|
+
const shared = path.join(root, 'shared', cache.name);
|
|
166
|
+
if (!fs.existsSync(shared)) {
|
|
167
|
+
const legacy = path.join(root, 'home', cache.rel);
|
|
168
|
+
fs.mkdirSync(path.dirname(shared), { recursive: true, mode: 0o700 });
|
|
169
|
+
try {
|
|
170
|
+
if (fs.existsSync(legacy) && !fs.lstatSync(legacy).isSymbolicLink()) {
|
|
171
|
+
fs.renameSync(legacy, shared);
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
/* fall through to an empty cache */
|
|
171
175
|
}
|
|
172
|
-
|
|
176
|
+
fs.mkdirSync(shared, { recursive: true, mode: 0o700 });
|
|
177
|
+
}
|
|
178
|
+
const target = path.join(home, cache.rel);
|
|
179
|
+
if (fs.existsSync(target) || isDanglingLink(target)) continue;
|
|
180
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
181
|
+
try {
|
|
182
|
+
fs.symlinkSync(shared, target, 'dir');
|
|
183
|
+
} catch {
|
|
184
|
+
/* engine recreates the cache itself */
|
|
173
185
|
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isDanglingLink(p) {
|
|
190
|
+
try {
|
|
191
|
+
return fs.lstatSync(p).isSymbolicLink() && !fs.existsSync(p);
|
|
174
192
|
} catch {
|
|
175
|
-
|
|
193
|
+
return false;
|
|
176
194
|
}
|
|
195
|
+
}
|
|
177
196
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
// --- build ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
// Creates or refreshes one engine HOME. Config is (re)written from the live
|
|
200
|
+
// sources + this invocation's flags; engine state under the HOME (cli/db,
|
|
201
|
+
// cli/agents, cli/rollout, logs, memories) is never touched, so a session
|
|
202
|
+
// stays resumable across invocations. Returns facts for reporting.
|
|
203
|
+
function buildEngineHome({
|
|
204
|
+
home,
|
|
205
|
+
root,
|
|
206
|
+
key,
|
|
207
|
+
live = LIVE_ZCODE,
|
|
208
|
+
mcpSpecs = [],
|
|
209
|
+
mcpInherit = false,
|
|
210
|
+
skillsDirs = [],
|
|
211
|
+
noSkills = false,
|
|
212
|
+
}) {
|
|
213
|
+
const zdir = path.join(home, '.zcode');
|
|
214
|
+
const v2 = path.join(zdir, 'v2');
|
|
215
|
+
const cliDir = path.join(zdir, 'cli');
|
|
216
|
+
fs.mkdirSync(v2, { recursive: true, mode: 0o700 });
|
|
217
|
+
fs.mkdirSync(cliDir, { recursive: true, mode: 0o700 });
|
|
218
|
+
fs.chmodSync(home, 0o700);
|
|
219
|
+
|
|
220
|
+
// v2: top-level files only, no credentials.json, no sqlite (§6.2 item 1).
|
|
221
|
+
const liveV2 = path.join(live, 'v2');
|
|
222
|
+
const v2Files = listDir(liveV2).filter((e) => e.isFile() && !isV2Excluded(e.name));
|
|
223
|
+
for (const entry of v2Files) {
|
|
224
|
+
fs.copyFileSync(path.join(liveV2, entry.name), path.join(v2, entry.name));
|
|
184
225
|
}
|
|
226
|
+
const providerConfig = path.join(v2, 'provider_config.json');
|
|
227
|
+
const mode = v2Files.some((e) => e.name === 'provider_config.json')
|
|
228
|
+
? 'copied-v2-top-level'
|
|
229
|
+
: 'synthesized';
|
|
230
|
+
if (mode === 'synthesized') writeJson600(providerConfig, synthesizedProviderConfig(key));
|
|
231
|
+
else rewriteProviderConfig(providerConfig, key);
|
|
185
232
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
233
|
+
// cli/config.json: live copy (builtin:zai key swapped) + this run's MCP set.
|
|
234
|
+
const cfg = readJson(path.join(live, 'cli', 'config.json')) || {};
|
|
235
|
+
const zai = cfg.provider && cfg.provider['builtin:zai'];
|
|
236
|
+
if (zai) {
|
|
237
|
+
zai.options = zai.options || {};
|
|
238
|
+
zai.options.apiKey = key;
|
|
190
239
|
}
|
|
240
|
+
const servers = effectiveMcpServers({ inherit: mcpInherit, mcpSpecs, live });
|
|
241
|
+
cfg.mcp = { ...(cfg.mcp || {}), servers };
|
|
242
|
+
writeJson600(path.join(cliDir, 'config.json'), cfg);
|
|
191
243
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
244
|
+
// Agent profiles + AGENTS.md: mirror the live set exactly.
|
|
245
|
+
const agentsDir = path.join(zdir, 'agents');
|
|
246
|
+
fs.rmSync(agentsDir, { recursive: true, force: true });
|
|
247
|
+
for (const entry of listDir(path.join(live, 'agents'))) {
|
|
248
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
249
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
250
|
+
fs.copyFileSync(path.join(live, 'agents', entry.name), path.join(agentsDir, entry.name));
|
|
199
251
|
}
|
|
200
|
-
const
|
|
201
|
-
if (fs.existsSync(
|
|
202
|
-
|
|
252
|
+
const agentsMd = path.join(zdir, 'AGENTS.md');
|
|
253
|
+
if (fs.existsSync(path.join(live, 'AGENTS.md'))) {
|
|
254
|
+
fs.copyFileSync(path.join(live, 'AGENTS.md'), agentsMd);
|
|
255
|
+
} else {
|
|
256
|
+
fs.rmSync(agentsMd, { force: true });
|
|
203
257
|
}
|
|
204
258
|
|
|
205
|
-
// Skills:
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
259
|
+
// Skills: live user skills (unless --no-skills) + this run's --skills.
|
|
260
|
+
// Project skills (<cwd>/.zcode/skills) need no copy — the engine reads
|
|
261
|
+
// them from the real workspace.
|
|
262
|
+
const skillsDir = path.join(zdir, 'skills');
|
|
263
|
+
fs.rmSync(skillsDir, { recursive: true, force: true });
|
|
264
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
209
265
|
let skillsCopied = 0;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
for (const entry of
|
|
266
|
+
if (!noSkills) {
|
|
267
|
+
const liveSkills = path.join(live, 'skills');
|
|
268
|
+
for (const entry of listDir(liveSkills)) {
|
|
213
269
|
if (!entry.isDirectory()) continue;
|
|
214
270
|
if (!fs.existsSync(path.join(liveSkills, entry.name, 'SKILL.md'))) continue;
|
|
215
|
-
fs.cpSync(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
{ recursive: true }
|
|
219
|
-
);
|
|
271
|
+
fs.cpSync(path.join(liveSkills, entry.name), path.join(skillsDir, entry.name), {
|
|
272
|
+
recursive: true,
|
|
273
|
+
});
|
|
220
274
|
skillsCopied++;
|
|
221
275
|
}
|
|
222
276
|
}
|
|
277
|
+
const skillsInjected = [];
|
|
278
|
+
if (!noSkills) {
|
|
279
|
+
for (const dir of skillsDirs) {
|
|
280
|
+
for (const { name, dir: skillDir } of discoverSkillDirs(dir)) {
|
|
281
|
+
fs.rmSync(path.join(skillsDir, name), { recursive: true, force: true });
|
|
282
|
+
fs.cpSync(skillDir, path.join(skillsDir, name), { recursive: true });
|
|
283
|
+
skillsInjected.push(name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
223
287
|
|
|
224
|
-
|
|
288
|
+
if (root) linkSharedCaches(home, root);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
home,
|
|
292
|
+
mode,
|
|
293
|
+
copiedFiles: v2Files.length,
|
|
294
|
+
skillsCopied,
|
|
295
|
+
skillsInjected,
|
|
296
|
+
mcp: {
|
|
297
|
+
servers,
|
|
298
|
+
// Names the caller explicitly added — the CLI waits for these to
|
|
299
|
+
// settle before sending the prompt. Inherited servers are best-effort.
|
|
300
|
+
added: mcpSpecs.flatMap((spec) => Object.keys(spec)),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
225
303
|
}
|
|
226
304
|
|
|
227
|
-
// ---
|
|
305
|
+
// --- migration ----------------------------------------------------------------
|
|
228
306
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
307
|
+
// The only names pre-0.4 versions created at a root. migrateRoot moves
|
|
308
|
+
// exactly these out of legacy locations and never touches anything else.
|
|
309
|
+
const OUR_ARTIFACTS = ['credentials.json', 'build-stamp.json', 'injected-skills.json', 'home'];
|
|
232
310
|
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
311
|
+
// 0.3.2 migration: vacate our artifacts from (a) the live ~/.zcode, where
|
|
312
|
+
// 0.3.0/0.3.1 wrongly wrote them, and (b) the pre-rename ~/.zai-code root,
|
|
313
|
+
// into the ~/.zcode-cli root. Idempotent; never writes or deletes anything
|
|
314
|
+
// else in the live directory.
|
|
315
|
+
function migrateRoot(env = process.env) {
|
|
236
316
|
const root = rootDir(env);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const sources =
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
version: 1,
|
|
246
|
-
keySha256: fingerprint,
|
|
247
|
-
sources,
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
if (!fresh && fs.existsSync(home) && fs.existsSync(stampFile)) {
|
|
317
|
+
try {
|
|
318
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
319
|
+
} catch {
|
|
320
|
+
return; // unwritable home — callers surface it
|
|
321
|
+
}
|
|
322
|
+
const sources = [liveDir(env), path.join(os.homedir(), '.zai-code')];
|
|
323
|
+
for (const dir of sources) {
|
|
324
|
+
let entries;
|
|
251
325
|
try {
|
|
252
|
-
|
|
253
|
-
const sameSources =
|
|
254
|
-
Array.isArray(stamp.sources) &&
|
|
255
|
-
stamp.sources.length === desired.sources.length &&
|
|
256
|
-
stamp.sources.every(
|
|
257
|
-
(s, i) =>
|
|
258
|
-
s.file === desired.sources[i].file && s.mtimeMs === desired.sources[i].mtimeMs
|
|
259
|
-
);
|
|
260
|
-
if (stamp.keySha256 === fingerprint && sameSources) {
|
|
261
|
-
return {
|
|
262
|
-
root,
|
|
263
|
-
home,
|
|
264
|
-
mode: stamp.mode,
|
|
265
|
-
built: false,
|
|
266
|
-
stamp,
|
|
267
|
-
};
|
|
268
|
-
}
|
|
326
|
+
entries = new Set(fs.readdirSync(dir));
|
|
269
327
|
} catch {
|
|
270
|
-
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
for (const name of OUR_ARTIFACTS) {
|
|
331
|
+
if (!entries.has(name)) continue;
|
|
332
|
+
const src = path.join(dir, name);
|
|
333
|
+
const dest = path.join(root, name);
|
|
334
|
+
try {
|
|
335
|
+
if (fs.existsSync(dest)) fs.rmSync(src, { recursive: true, force: true });
|
|
336
|
+
else fs.renameSync(src, dest);
|
|
337
|
+
} catch {
|
|
338
|
+
/* keep going; a missing artifact just rebuilds */
|
|
339
|
+
}
|
|
271
340
|
}
|
|
272
341
|
}
|
|
273
|
-
|
|
274
|
-
const { mode, copied, skillsCopied } = buildScratch(home, key);
|
|
275
|
-
fs.chmodSync(home, 0o700);
|
|
276
|
-
const stamp = {
|
|
277
|
-
...desired,
|
|
278
|
-
mode,
|
|
279
|
-
copiedFiles: copied,
|
|
280
|
-
skillsCopied,
|
|
281
|
-
builtAt: new Date().toISOString(),
|
|
282
|
-
};
|
|
283
|
-
writeJson600(stampFile, stamp);
|
|
284
|
-
return { root, home, mode, built: true, stamp };
|
|
285
342
|
}
|
|
286
343
|
|
|
287
344
|
// Env for the engine child process (§6.3): HOME=scratch, pinned builtin
|
|
288
|
-
// config, and the uncontrolled overrides deleted. cwd is
|
|
289
|
-
// the caller sets it to the user's real workspace.
|
|
345
|
+
// config, and the uncontrolled overrides deleted. cwd is set by the caller.
|
|
290
346
|
function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
|
|
291
347
|
const childEnv = { ...parentEnv };
|
|
292
348
|
childEnv.HOME = home;
|
|
@@ -304,10 +360,14 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
|
|
|
304
360
|
}
|
|
305
361
|
|
|
306
362
|
module.exports = {
|
|
307
|
-
|
|
363
|
+
buildEngineHome,
|
|
308
364
|
engineEnv,
|
|
309
365
|
rootDir,
|
|
310
|
-
|
|
366
|
+
liveDir,
|
|
367
|
+
migrateRoot,
|
|
368
|
+
effectiveMcpServers,
|
|
369
|
+
liveMcpServers,
|
|
370
|
+
discoverSkillDirs,
|
|
311
371
|
LIVE_ZCODE,
|
|
312
372
|
LIVE_V2,
|
|
313
373
|
};
|
package/lib/key.js
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// API key resolution (DESIGN §5): ZAI_API_KEY env →
|
|
3
|
-
// ({"apiKey":"…"}, mode 600, written by
|
|
4
|
-
// printed, logged, or echoed by anything
|
|
2
|
+
// API key resolution (DESIGN §5): ZAI_API_KEY env →
|
|
3
|
+
// ~/.zcode-cli/credentials.json ({"apiKey":"…"}, mode 600, written by
|
|
4
|
+
// `zcode login`). The key is never printed, logged, or echoed by anything
|
|
5
|
+
// in this package.
|
|
5
6
|
const fs = require('node:fs');
|
|
6
7
|
const path = require('node:path');
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
// Root of zcode's own state (scratch HOME lives under <root>/home).
|
|
10
|
-
function rootDir(env = process.env) {
|
|
11
|
-
return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode');
|
|
12
|
-
}
|
|
8
|
+
const { migrateRoot, rootDir } = require('./isolation.js');
|
|
13
9
|
|
|
14
10
|
function credentialsPath(env = process.env) {
|
|
15
11
|
return path.join(rootDir(env), 'credentials.json');
|
|
@@ -41,6 +37,7 @@ function readCredentialsKey(file) {
|
|
|
41
37
|
// Sources, in order: ZAI_API_KEY (non-empty) → credentials.json.
|
|
42
38
|
// Throws NoKeyError when neither holds a key.
|
|
43
39
|
function resolveKey(env = process.env) {
|
|
40
|
+
migrateRoot(env); // 0.3.2: vacate our artifacts out of the live ~/.zcode first
|
|
44
41
|
const fromEnv = (env.ZAI_API_KEY || '').trim();
|
|
45
42
|
if (fromEnv) return fromEnv;
|
|
46
43
|
const fromFile = readCredentialsKey(credentialsPath(env));
|