@commonlyai/cli 0.1.58 → 0.1.61
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/package.json +1 -1
- package/src/lib/adapters/pi-mcp-client.mjs +168 -3
- package/src/lib/environment.js +41 -0
package/package.json
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { spawn } from 'node:child_process';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
closeSync, readFileSync, existsSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* The grant broker's path. wren's ruling for the daemon-side half of TASK-063:
|
|
@@ -86,10 +89,172 @@ export const connectMcp = (server, opts = {}) => (typeof server?.url === 'string
|
|
|
86
89
|
? connectHttpMcp(server, opts)
|
|
87
90
|
: connectStdioMcp(server, opts));
|
|
88
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The credential channel (TASK-078, ruled 2026-09-19: "take the token out of
|
|
94
|
+
* the environment", inherited pipe).
|
|
95
|
+
*
|
|
96
|
+
* `connectStdioMcp` spawns each declared stdio server with
|
|
97
|
+
* `{...process.env, ...env}`, and the default declaration puts the seat's
|
|
98
|
+
* runtime token in that `env` map. So the token sat in the MCP child's
|
|
99
|
+
* environment, where any same-user process could read it back with
|
|
100
|
+
* `ps eww <pid>` or `/proc/<pid>/environ` — including, on a shared host, a
|
|
101
|
+
* process the seat is not allowed to talk to.
|
|
102
|
+
*
|
|
103
|
+
* Now the token rides an inherited pipe on fd 3 and the child's environment
|
|
104
|
+
* carries only a pointer to it (`COMMONLY_TOKEN_FD=3`) — not a secret. The
|
|
105
|
+
* child end of that pipe is read to EOF.
|
|
106
|
+
*
|
|
107
|
+
* THE PIPE IS ONLY AVAILABLE WHERE WE SPAWN. This is the pi path; claude and
|
|
108
|
+
* codex let their own CLI start the server (claude expands `${VAR}` in its own
|
|
109
|
+
* process env, codex rides `mcp_servers.*.env_vars`), so a pipe opened here
|
|
110
|
+
* never reaches that grandchild. Those two still hand the token over in their
|
|
111
|
+
* runtime's environment, which is a separate, still-open half of the same row.
|
|
112
|
+
*
|
|
113
|
+
* THE OLD SERVER STILL WORKS. `@commonlyai/mcp` only learned to read the pipe in
|
|
114
|
+
* 0.3.11, and a seat may pin an older one — the sprint seats run a staging
|
|
115
|
+
* checkout of 0.3.4 — so a declaration whose command names an older
|
|
116
|
+
* `@commonlyai/mcp` keeps the environment variable AS WELL, with a warning that
|
|
117
|
+
* names the pin. An operator can also opt out explicitly with
|
|
118
|
+
* `COMMONLY_TOKEN_CHANNEL=env` in the entry's own env. Otherwise the token is
|
|
119
|
+
* piped and the environment is left clean.
|
|
120
|
+
*/
|
|
121
|
+
export const CREDENTIAL_KEY = 'COMMONLY_AGENT_TOKEN';
|
|
122
|
+
export const CREDENTIAL_FD_VAR = 'COMMONLY_TOKEN_FD';
|
|
123
|
+
export const CREDENTIAL_CHANNEL_VAR = 'COMMONLY_TOKEN_CHANNEL';
|
|
124
|
+
export const CREDENTIAL_FD = 3;
|
|
125
|
+
|
|
126
|
+
/** The `@commonlyai/mcp` release whose `loadConfig` reads the pipe channel. */
|
|
127
|
+
export const PIPE_READER_VERSION = [0, 3, 11];
|
|
128
|
+
|
|
129
|
+
const MCP_PACKAGE = '@commonlyai/mcp';
|
|
130
|
+
|
|
131
|
+
const parseVersion = (spec) => {
|
|
132
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(spec || '').trim());
|
|
133
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const olderThanPipeReader = (version) => {
|
|
137
|
+
if (!version) return null;
|
|
138
|
+
for (let i = 0; i < 3; i += 1) {
|
|
139
|
+
if (version[i] !== PIPE_READER_VERSION[i]) return version[i] < PIPE_READER_VERSION[i];
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What an `@commonlyai/mcp` command would run, or null when the command cannot
|
|
146
|
+
* be identified as that package at all.
|
|
147
|
+
*
|
|
148
|
+
* Two shapes matter: `npx [-y] @commonlyai/mcp@<spec>` (a spec is a version, or
|
|
149
|
+
* `latest`/absent, which resolves to whatever is published — never treated as
|
|
150
|
+
* old), and a local checkout, `node <path>/src/index.js`, which is what the
|
|
151
|
+
* staging seats run; for that one the package.json beside it is the only honest
|
|
152
|
+
* answer, and a package.json naming something else means this is not our server.
|
|
153
|
+
*
|
|
154
|
+
* `{ isCommonly: true, version: null }` means "our server, version unknown" —
|
|
155
|
+
* an unpinned npx spec, whose whole point is that it tracks the published one.
|
|
156
|
+
* `null` as the return value means "not identifiable as our server", which is a
|
|
157
|
+
* different answer and takes a different branch: a stranger's server gets its
|
|
158
|
+
* declaration honoured unchanged.
|
|
159
|
+
*/
|
|
160
|
+
export const describeMcpCommand = (command, { readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null) } = {}) => {
|
|
161
|
+
if (!Array.isArray(command) || command.length === 0) return null;
|
|
162
|
+
const parts = command.map(String);
|
|
163
|
+
const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
|
|
164
|
+
if (pkgArg) {
|
|
165
|
+
const at = pkgArg.lastIndexOf('@');
|
|
166
|
+
if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
|
|
167
|
+
return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
|
|
168
|
+
}
|
|
169
|
+
const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
|
|
170
|
+
if (!scriptPath) return null;
|
|
171
|
+
// `src/index.js` → `../package.json`; also try one level further up, because a
|
|
172
|
+
// bin shim can live in `bin/` beside `src/`.
|
|
173
|
+
for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
|
|
174
|
+
let raw;
|
|
175
|
+
try {
|
|
176
|
+
raw = readTextFile(candidate);
|
|
177
|
+
} catch {
|
|
178
|
+
raw = null;
|
|
179
|
+
}
|
|
180
|
+
if (!raw) continue;
|
|
181
|
+
try {
|
|
182
|
+
const pkg = JSON.parse(raw);
|
|
183
|
+
if (!pkg || typeof pkg !== 'object') continue;
|
|
184
|
+
if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
|
|
185
|
+
// A package.json that names another package settles it: not ours, so its
|
|
186
|
+
// declaration is none of this function's business.
|
|
187
|
+
return null;
|
|
188
|
+
} catch {
|
|
189
|
+
// A malformed package.json is not an answer; keep looking.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Split a declared env map into the child's environment and the credential to
|
|
197
|
+
* hand over the pipe.
|
|
198
|
+
*
|
|
199
|
+
* Returns `{ env, credential, keepInEnv }`. `keepInEnv` is true only when the
|
|
200
|
+
* server cannot read the pipe — it predates the reader, it is somebody else's
|
|
201
|
+
* server, or the declaration opted out explicitly. Everything else gets the
|
|
202
|
+
* pointer variable and no secret.
|
|
203
|
+
*/
|
|
204
|
+
export const splitCredential = (env, command, { onWarn = (m) => process.stderr.write(`${m}\n`) } = {}) => {
|
|
205
|
+
const declared = { ...(env || {}) };
|
|
206
|
+
const credential = declared[CREDENTIAL_KEY];
|
|
207
|
+
const requested = String(declared[CREDENTIAL_CHANNEL_VAR] || '').trim().toLowerCase();
|
|
208
|
+
delete declared[CREDENTIAL_CHANNEL_VAR];
|
|
209
|
+
if (!credential) {
|
|
210
|
+
delete declared[CREDENTIAL_KEY];
|
|
211
|
+
return { env: declared, credential: null, keepInEnv: false };
|
|
212
|
+
}
|
|
213
|
+
if (requested === 'env') {
|
|
214
|
+
return { env: declared, credential: null, keepInEnv: true };
|
|
215
|
+
}
|
|
216
|
+
const server = describeMcpCommand(command);
|
|
217
|
+
if (!server) {
|
|
218
|
+
// Not identifiable as @commonlyai/mcp. A declaration that put this key in a
|
|
219
|
+
// stranger's environment asked for it to be there, and that server has no
|
|
220
|
+
// reason to know about a pipe; changing its contract is not this change's
|
|
221
|
+
// business.
|
|
222
|
+
return { env: declared, credential: null, keepInEnv: true };
|
|
223
|
+
}
|
|
224
|
+
if (server.version && olderThanPipeReader(server.version) === true) {
|
|
225
|
+
onWarn(`[pi-mcp-client] ${command[0]} runs ${MCP_PACKAGE} ${server.version.join('.')}, which predates the pipe channel (0.3.11): keeping the token in the child environment. Unpin it, or set ${CREDENTIAL_CHANNEL_VAR}=env to say so on purpose.`);
|
|
226
|
+
return { env: declared, credential: null, keepInEnv: true };
|
|
227
|
+
}
|
|
228
|
+
delete declared[CREDENTIAL_KEY];
|
|
229
|
+
declared[CREDENTIAL_FD_VAR] = String(CREDENTIAL_FD);
|
|
230
|
+
return { env: declared, credential, keepInEnv: false };
|
|
231
|
+
};
|
|
232
|
+
|
|
89
233
|
/** A minimal MCP stdio client: initialize, tools/list, tools/call. */
|
|
90
|
-
export const connectStdioMcp = ({
|
|
234
|
+
export const connectStdioMcp = ({
|
|
235
|
+
name, command, env,
|
|
236
|
+
}, {
|
|
237
|
+
spawnImpl = spawn, timeoutMs = 60_000, onWarn,
|
|
238
|
+
} = {}) => {
|
|
91
239
|
const [cmd, ...args] = command;
|
|
92
|
-
const
|
|
240
|
+
const { env: declaredEnv, credential, keepInEnv } = splitCredential(env, command, onWarn ? { onWarn } : {});
|
|
241
|
+
// The inherited environment is stripped of the key unless an old server has to
|
|
242
|
+
// read it there: the daemon's own environment is not a channel into a child,
|
|
243
|
+
// and `...process.env` used to make it one.
|
|
244
|
+
const childEnv = { ...process.env, ...declaredEnv };
|
|
245
|
+
if (!keepInEnv) delete childEnv[CREDENTIAL_KEY];
|
|
246
|
+
const stdio = credential ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'];
|
|
247
|
+
const proc = spawnImpl(cmd, args, { env: childEnv, stdio });
|
|
248
|
+
if (credential) {
|
|
249
|
+
const channel = proc.stdio && proc.stdio[CREDENTIAL_FD];
|
|
250
|
+
if (!channel) {
|
|
251
|
+
// Fail loudly rather than fall back: the environment it would fall back to
|
|
252
|
+
// is the thing this change exists to empty.
|
|
253
|
+
throw new Error(`${name}: no fd ${CREDENTIAL_FD} pipe to carry the runtime token`);
|
|
254
|
+
}
|
|
255
|
+
channel.on('error', () => {});
|
|
256
|
+
channel.end(credential);
|
|
257
|
+
}
|
|
93
258
|
const pending = new Map();
|
|
94
259
|
let nextId = 1;
|
|
95
260
|
let buffer = '';
|
package/src/lib/environment.js
CHANGED
|
@@ -246,6 +246,22 @@ export const validateEnvironmentSpec = (spec) => {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
// A TRANSPORT DECIDES THE ENTRY, so the fields must agree with it (TASK-071,
|
|
250
|
+
// Vera's ruling 2026-09-19). This block is MIRRORED, not shared: the other
|
|
251
|
+
// writer-side check is the backend's, in
|
|
252
|
+
// backend/utils/environmentSpecValidation.ts, called from the
|
|
253
|
+
// `PATCH /api/registry/pods/:podId/agents/:name` handler that stores
|
|
254
|
+
// `config.environment` for the owner's daemon. The two cannot share code at
|
|
255
|
+
// runtime — this is a published ESM package, that is the CJS backend — so
|
|
256
|
+
// the wording here and there is kept parallel deliberately and the two must
|
|
257
|
+
// move together. This one refuses a hand-written `--environment <file>`; the
|
|
258
|
+
// backend's refuses a stored row.
|
|
259
|
+
//
|
|
260
|
+
// The shape rule is not decoration: every adapter branches on it
|
|
261
|
+
// (isStdioServer/isHttpServer in adapters/pi-mcp-client.mjs), and `command`
|
|
262
|
+
// is an argv ARRAY — `connectStdioMcp` destructures `const [cmd, ...args] =
|
|
263
|
+
// command`. An entry whose fields contradict its transport is a record whose
|
|
264
|
+
// reader has to pick a winner, and the readers do not agree on which.
|
|
249
265
|
if (spec.mcp !== undefined) {
|
|
250
266
|
if (!Array.isArray(spec.mcp)) {
|
|
251
267
|
errors.push('mcp must be an array');
|
|
@@ -261,6 +277,31 @@ export const validateEnvironmentSpec = (spec) => {
|
|
|
261
277
|
if (server.transport !== undefined
|
|
262
278
|
&& !['http', 'stdio', 'sse'].includes(server.transport)) {
|
|
263
279
|
errors.push(`mcp[${i}].transport must be one of: http, stdio, sse`);
|
|
280
|
+
// The agreement rule below is defined in terms of a transport this
|
|
281
|
+
// entry does not have; judging it here would mean inventing the
|
|
282
|
+
// second definition that rule exists to avoid.
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const argv = Array.isArray(server.command) && server.command.length > 0
|
|
286
|
+
&& server.command.every((part) => typeof part === 'string' && part.trim().length > 0);
|
|
287
|
+
const hasUrl = typeof server.url === 'string' && server.url.trim().length > 0;
|
|
288
|
+
if (server.transport === 'http' || server.transport === 'sse') {
|
|
289
|
+
if (!hasUrl) {
|
|
290
|
+
errors.push(`mcp[${i}].url is required when transport is ${server.transport}: a ${server.transport} server is reached by URL, and this entry declares no URL to reach`);
|
|
291
|
+
}
|
|
292
|
+
if (server.command !== undefined) {
|
|
293
|
+
errors.push(`mcp[${i}].command must not be set when transport is ${server.transport}: with a url and a command in one entry each reader picks a different winner, so the record does not say what runs`);
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
// stdio, or absent transport — the historical default.
|
|
297
|
+
if (!argv) {
|
|
298
|
+
errors.push(server.command === undefined
|
|
299
|
+
? `mcp[${i}].command is required for a stdio entry (and for an entry that declares no transport): with no command and no url there is nothing to run`
|
|
300
|
+
: `mcp[${i}].command must be a non-empty array of strings (argv, e.g. ["npx", "-y", "@commonlyai/mcp@latest"]); a string command is not a command line any reader in this repo executes`);
|
|
301
|
+
}
|
|
302
|
+
if (hasUrl) {
|
|
303
|
+
errors.push(`mcp[${i}].url must not be set for a stdio entry (and for an entry that declares no transport): a url here is a second, contradictory way to reach the server`);
|
|
304
|
+
}
|
|
264
305
|
}
|
|
265
306
|
});
|
|
266
307
|
}
|