severally-mcp 2.3.1 → 2.4.2
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.ja.md +52 -17
- package/README.md +52 -16
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +15 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +76 -68
- package/scripts/doctor.mjs +66 -20
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +327 -90
- package/scripts/live-check.mjs +62 -23
- package/scripts/live-mcp-check.mjs +52 -16
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +62 -28
- package/src/adapters/antigravity.mjs +111 -30
- package/src/adapters/claude-code.mjs +121 -28
- package/src/adapters/codex.mjs +125 -33
- package/src/adapters/opencode-sandbox.mjs +59 -22
- package/src/adapters/opencode.mjs +144 -30
- package/src/brief.mjs +148 -47
- package/src/consultants.mjs +255 -48
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +99 -18
- package/src/expose-paths.mjs +310 -50
- package/src/failures.mjs +31 -14
- package/src/jobs.mjs +863 -249
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +172 -40
- package/src/platform.mjs +56 -8
- package/src/policy.mjs +245 -52
- package/src/redact.mjs +59 -16
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +225 -30
- package/src/schema.mjs +132 -27
- package/src/server.mjs +178 -81
- package/src/status.mjs +94 -13
- package/src/store.mjs +418 -35
package/src/adapters/codex.mjs
CHANGED
|
@@ -56,6 +56,13 @@ import path from 'node:path';
|
|
|
56
56
|
import { POLICY, protectedRoots } from '../policy.mjs';
|
|
57
57
|
import { classifyMessage } from '../failures.mjs';
|
|
58
58
|
|
|
59
|
+
/** @typedef {import('../run.mjs').BuildRequest} BuildRequest */
|
|
60
|
+
/** @typedef {import('../run.mjs').ChildResult} ChildResult */
|
|
61
|
+
/** @typedef {import('../run.mjs').Invocation} Invocation */
|
|
62
|
+
/** @typedef {import('../run.mjs').Interpreted} Interpreted */
|
|
63
|
+
/** @typedef {import('../run.mjs').UsageRecord} UsageRecord */
|
|
64
|
+
/** @typedef {import('../run.mjs').Boundary} Boundary */
|
|
65
|
+
|
|
59
66
|
export const FORBIDDEN_FLAGS = [
|
|
60
67
|
'--dangerously-bypass-approvals-and-sandbox',
|
|
61
68
|
'--dangerously-bypass-hook-trust',
|
|
@@ -69,13 +76,30 @@ export const FORBIDDEN_FLAGS = [
|
|
|
69
76
|
// feature switch that could re-enable what --ignore-user-config drops, and
|
|
70
77
|
// the modes that leave the read-only sandbox.
|
|
71
78
|
export const RESERVED_FLAGS = [
|
|
72
|
-
'-s',
|
|
73
|
-
'--
|
|
79
|
+
'-s',
|
|
80
|
+
'--sandbox',
|
|
81
|
+
'--config',
|
|
82
|
+
'--cd',
|
|
83
|
+
'--model',
|
|
84
|
+
'--output-last-message',
|
|
85
|
+
'--profile',
|
|
86
|
+
'-p',
|
|
87
|
+
'--enable',
|
|
88
|
+
'--full-auto',
|
|
89
|
+
'--worktree',
|
|
90
|
+
'--cyber-access-program',
|
|
74
91
|
];
|
|
75
92
|
|
|
76
93
|
const PROFILE = 'severally';
|
|
77
94
|
|
|
78
|
-
|
|
95
|
+
/** @param {string} dir @returns {string[]} */
|
|
96
|
+
const listDir = (dir) => {
|
|
97
|
+
try {
|
|
98
|
+
return fs.readdirSync(dir);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
};
|
|
79
103
|
|
|
80
104
|
/**
|
|
81
105
|
* The profile's filesystem table, as TOML: everything readable, the protected
|
|
@@ -90,15 +114,24 @@ export function filesystemPermissions() {
|
|
|
90
114
|
fs.mkdirSync(path.join(POLICY.home, 'homes'), { recursive: true, mode: 0o700 });
|
|
91
115
|
const codexHome = path.join(os.homedir(), '.codex').replaceAll('\\', '/');
|
|
92
116
|
const denied = protectedRoots().flatMap((p) => {
|
|
93
|
-
if (p === codexHome)
|
|
117
|
+
if (p === codexHome)
|
|
118
|
+
return listDir(codexHome)
|
|
119
|
+
.filter((n) => n !== 'packages')
|
|
120
|
+
.map((n) => `${codexHome}/${n}`);
|
|
94
121
|
const star = p.indexOf('/*/');
|
|
95
122
|
if (star === -1) return [p];
|
|
96
123
|
const [parent, rest] = [p.slice(0, star), p.slice(star + 3)];
|
|
97
|
-
return listDir(parent)
|
|
124
|
+
return listDir(parent)
|
|
125
|
+
.map((n) => `${parent}/${n}/${rest}`)
|
|
126
|
+
.filter((q) => fs.existsSync(q));
|
|
98
127
|
});
|
|
99
128
|
return `{ ":root" = "read", ${denied.map((p) => `${JSON.stringify(p)} = "none"`).join(', ')} }`;
|
|
100
129
|
}
|
|
101
130
|
|
|
131
|
+
/**
|
|
132
|
+
* @param {BuildRequest} req
|
|
133
|
+
* @returns {Invocation}
|
|
134
|
+
*/
|
|
102
135
|
export function buildInvocation({ workdir, schemaPath, consultant, network = 'on' }) {
|
|
103
136
|
const t = POLICY.clis.codex;
|
|
104
137
|
const chosen = consultant.model;
|
|
@@ -106,29 +139,58 @@ export function buildInvocation({ workdir, schemaPath, consultant, network = 'on
|
|
|
106
139
|
const args = [
|
|
107
140
|
'exec',
|
|
108
141
|
'--json',
|
|
109
|
-
'--color',
|
|
110
|
-
'
|
|
111
|
-
'-
|
|
112
|
-
|
|
142
|
+
'--color',
|
|
143
|
+
'never',
|
|
144
|
+
'-m',
|
|
145
|
+
chosen,
|
|
146
|
+
'-c',
|
|
147
|
+
`model_reasoning_effort="${consultant.effort ?? 'high'}"`,
|
|
148
|
+
'-c',
|
|
149
|
+
`tools.web_search=${network !== 'off'}`,
|
|
113
150
|
...(network === 'off' ? ['-c', 'web_search="disabled"'] : []),
|
|
114
|
-
'-c',
|
|
115
|
-
'
|
|
116
|
-
'-c',
|
|
117
|
-
'
|
|
151
|
+
'-c',
|
|
152
|
+
'hooks.enabled=false',
|
|
153
|
+
'-c',
|
|
154
|
+
'agents.enabled=false',
|
|
155
|
+
'-c',
|
|
156
|
+
'shell_environment_policy.inherit="core"',
|
|
157
|
+
'-c',
|
|
158
|
+
'shell_environment_policy.set={SEVERALLY_ACTIVE="1"}',
|
|
118
159
|
'--ignore-user-config',
|
|
119
160
|
'--ignore-rules',
|
|
120
161
|
'--skip-git-repo-check',
|
|
121
162
|
'--ephemeral',
|
|
122
|
-
'-c',
|
|
123
|
-
|
|
124
|
-
'-
|
|
125
|
-
|
|
126
|
-
'-
|
|
163
|
+
'-c',
|
|
164
|
+
`default_permissions="${PROFILE}"`,
|
|
165
|
+
'-c',
|
|
166
|
+
`permissions.${PROFILE}.filesystem=${filesystemPermissions()}`,
|
|
167
|
+
'-C',
|
|
168
|
+
workdir,
|
|
169
|
+
'--output-schema',
|
|
170
|
+
schemaPath,
|
|
171
|
+
'-o',
|
|
172
|
+
lastMessagePath,
|
|
127
173
|
'-',
|
|
128
174
|
];
|
|
129
175
|
return { command: t.cli, args, lastMessagePath, model: chosen };
|
|
130
176
|
}
|
|
131
177
|
|
|
178
|
+
/**
|
|
179
|
+
* One JSONL event from `codex exec --json`, or the item inside it; the CLI
|
|
180
|
+
* controls both, so every field is optional.
|
|
181
|
+
* @typedef {object} CodexItem
|
|
182
|
+
* @property {unknown} [type]
|
|
183
|
+
* @property {unknown} [text]
|
|
184
|
+
* @property {unknown} [message]
|
|
185
|
+
* @property {unknown} [usage]
|
|
186
|
+
* @typedef {CodexItem & { error?: { message?: unknown }, item?: CodexItem }} CodexEvent
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {CodexItem | null | undefined} node
|
|
191
|
+
* @param {Record<string, number>} acc
|
|
192
|
+
* @returns {void}
|
|
193
|
+
*/
|
|
132
194
|
function walkUsage(node, acc) {
|
|
133
195
|
if (!node || typeof node !== 'object') return;
|
|
134
196
|
if (node.usage && typeof node.usage === 'object') {
|
|
@@ -140,24 +202,34 @@ function walkUsage(node, acc) {
|
|
|
140
202
|
for (const v of Object.values(node)) if (v && typeof v === 'object') walkUsage(v, acc);
|
|
141
203
|
}
|
|
142
204
|
|
|
143
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Parse the JSONL event stream `codex exec --json` writes to stdout.
|
|
207
|
+
* @param {Partial<ChildResult> & { lastMessageText?: string }} run
|
|
208
|
+
* @returns {Interpreted}
|
|
209
|
+
*/
|
|
144
210
|
export function interpret({ stdout, stderr, code, lastMessageText }) {
|
|
211
|
+
/** @type {CodexEvent[]} */
|
|
145
212
|
const events = [];
|
|
146
213
|
for (const line of (stdout || '').split('\n')) {
|
|
147
214
|
const t = line.trim();
|
|
148
215
|
if (!t.startsWith('{')) continue;
|
|
149
|
-
try {
|
|
216
|
+
try {
|
|
217
|
+
events.push(JSON.parse(t));
|
|
218
|
+
} catch {
|
|
219
|
+
/* partial line */
|
|
220
|
+
}
|
|
150
221
|
}
|
|
151
222
|
|
|
152
223
|
const errorEvents = events.filter((e) => e.type === 'error' || e.type === 'turn.failed');
|
|
224
|
+
/** @type {Record<string, number>} */
|
|
153
225
|
const usage = {};
|
|
154
226
|
for (const e of events) walkUsage(e, usage);
|
|
155
227
|
|
|
156
228
|
let text = (lastMessageText || '').trim();
|
|
157
229
|
if (!text) {
|
|
158
230
|
// Fall back to the last assistant message carried in the event stream.
|
|
159
|
-
for (
|
|
160
|
-
|
|
231
|
+
for (const e of events.toReversed()) {
|
|
232
|
+
if (text) break;
|
|
161
233
|
const item = e.item ?? e;
|
|
162
234
|
const candidate =
|
|
163
235
|
(item && item.type && String(item.type).includes('agent_message') && (item.text ?? item.message)) ||
|
|
@@ -167,9 +239,7 @@ export function interpret({ stdout, stderr, code, lastMessageText }) {
|
|
|
167
239
|
}
|
|
168
240
|
}
|
|
169
241
|
|
|
170
|
-
const errorMsg = errorEvents
|
|
171
|
-
.map((e) => e.message ?? e.error?.message ?? JSON.stringify(e))
|
|
172
|
-
.join(' | ');
|
|
242
|
+
const errorMsg = errorEvents.map((e) => e.message ?? e.error?.message ?? JSON.stringify(e)).join(' | ');
|
|
173
243
|
// An error the turn recovered from (the stream still ends in
|
|
174
244
|
// turn.completed, with an answer) does not throw the answer away; the lead
|
|
175
245
|
// is told about it instead. A turn that ends in turn.failed has failed,
|
|
@@ -201,6 +271,8 @@ export function interpret({ stdout, stderr, code, lastMessageText }) {
|
|
|
201
271
|
* What this consultation has done so far. Codex already runs with `exec --json`,
|
|
202
272
|
* so its event stream is there for the reading -- no flag change and no parser
|
|
203
273
|
* change, only a use for events we were otherwise discarding.
|
|
274
|
+
* @param {{ stdout?: string }} run
|
|
275
|
+
* @returns {string | null}
|
|
204
276
|
*/
|
|
205
277
|
export function progress({ stdout }) {
|
|
206
278
|
const items = [];
|
|
@@ -210,7 +282,9 @@ export function progress({ stdout }) {
|
|
|
210
282
|
try {
|
|
211
283
|
const e = JSON.parse(t);
|
|
212
284
|
if (typeof e?.type === 'string' && e.type.startsWith('item.')) items.push(e);
|
|
213
|
-
} catch {
|
|
285
|
+
} catch {
|
|
286
|
+
/* a partial line, e.g. when the child was killed mid-write */
|
|
287
|
+
}
|
|
214
288
|
}
|
|
215
289
|
if (!items.length) return null;
|
|
216
290
|
const last = items[items.length - 1];
|
|
@@ -222,14 +296,21 @@ export function progress({ stdout }) {
|
|
|
222
296
|
* Tool calls seen in the event stream, counted by item type (and by tool for
|
|
223
297
|
* MCP and collaboration calls, which is where a sub-agent spawn shows up);
|
|
224
298
|
* `:error` when the item ended failed or declined.
|
|
299
|
+
* @param {{ stdout?: string }} run
|
|
300
|
+
* @returns {Record<string, number>}
|
|
225
301
|
*/
|
|
226
302
|
export function toolCalls({ stdout }) {
|
|
303
|
+
/** @type {Map<unknown, string>} */
|
|
227
304
|
const seen = new Map();
|
|
228
305
|
for (const line of (stdout || '').split('\n')) {
|
|
229
306
|
const t = line.trim();
|
|
230
307
|
if (!t.startsWith('{')) continue;
|
|
231
308
|
let e;
|
|
232
|
-
try {
|
|
309
|
+
try {
|
|
310
|
+
e = JSON.parse(t);
|
|
311
|
+
} catch {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
233
314
|
const item = e?.item;
|
|
234
315
|
if (!item?.type || !['item.started', 'item.completed'].includes(e.type)) continue;
|
|
235
316
|
if (['agent_message', 'reasoning', 'error'].includes(item.type)) continue;
|
|
@@ -237,13 +318,22 @@ export function toolCalls({ stdout }) {
|
|
|
237
318
|
const name = item.status === 'failed' || item.status === 'declined' ? `${base}:error` : base;
|
|
238
319
|
seen.set(item.id ?? `${name}#${seen.size}`, name);
|
|
239
320
|
}
|
|
240
|
-
return [...seen.values()].reduce((o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
|
|
321
|
+
return [...seen.values()].reduce((/** @type {Record<string, number>} */ o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
|
|
241
322
|
}
|
|
242
323
|
|
|
324
|
+
/**
|
|
325
|
+
* @param {unknown} raw the usage figures interpret collected from the event stream
|
|
326
|
+
* @returns {UsageRecord}
|
|
327
|
+
*/
|
|
243
328
|
export function usageRecord(raw) {
|
|
244
|
-
|
|
329
|
+
// interpret's usageRaw is the Record<string, number> walkUsage filled (or null).
|
|
330
|
+
const u = /** @type {Record<string, number>} */ (raw ?? {});
|
|
331
|
+
/** @param {...string} names */
|
|
245
332
|
const pick = (...names) => {
|
|
246
|
-
for (const n of names)
|
|
333
|
+
for (const n of names) {
|
|
334
|
+
const v = u[n];
|
|
335
|
+
if (typeof v === 'number') return v;
|
|
336
|
+
}
|
|
247
337
|
return null;
|
|
248
338
|
};
|
|
249
339
|
return {
|
|
@@ -260,6 +350,8 @@ export function usageRecord(raw) {
|
|
|
260
350
|
* What this consultant was launched with, read off the invocation: the
|
|
261
351
|
* boundary record kept beside each answer. Facts about the launch, not a
|
|
262
352
|
* claim about what the consultant did.
|
|
353
|
+
* @param {{ args: string[] }} launch
|
|
354
|
+
* @returns {Boundary}
|
|
263
355
|
*/
|
|
264
356
|
export function boundary({ args }) {
|
|
265
357
|
return {
|
|
@@ -269,9 +361,9 @@ export function boundary({ args }) {
|
|
|
269
361
|
writes: false,
|
|
270
362
|
mcp: false,
|
|
271
363
|
protected_paths: args.some((a) => a.startsWith(`permissions.${PROFILE}.filesystem=`))
|
|
272
|
-
? 'denied by path (permission profile), as the paths stood at launch: ~/.codex entries other than packages/, '
|
|
273
|
-
|
|
274
|
-
|
|
364
|
+
? 'denied by path (permission profile), as the paths stood at launch: ~/.codex entries other than packages/, ' +
|
|
365
|
+
'and legacy job homes, created later are not covered; file names such as .env anywhere are not; on Linux a ' +
|
|
366
|
+
'protected file replaced by rename during the run is readable'
|
|
275
367
|
: 'not enforced',
|
|
276
368
|
tools: null,
|
|
277
369
|
};
|
|
@@ -48,8 +48,13 @@ import { sandboxHome } from '../store.mjs';
|
|
|
48
48
|
// node:sqlite ships unflagged from Node 23 (and late 22.x); the engines field
|
|
49
49
|
// allows older runtimes, where credential seeding degrades to a no-op and the
|
|
50
50
|
// job fails with the CLI's own auth error instead.
|
|
51
|
+
/** @type {typeof import('node:sqlite').DatabaseSync | null} */
|
|
51
52
|
let DatabaseSync = null;
|
|
52
|
-
try {
|
|
53
|
+
try {
|
|
54
|
+
({ DatabaseSync } = sqliteRequire(import.meta.url)('node:sqlite'));
|
|
55
|
+
} catch {
|
|
56
|
+
/* unavailable */
|
|
57
|
+
}
|
|
53
58
|
|
|
54
59
|
// Reading is allowed, not denied: every consultant is an equal reader (Codex
|
|
55
60
|
// runs a read-only shell; Claude Code and Antigravity can read the whole
|
|
@@ -93,18 +98,28 @@ const SANDBOX_TOOLS_OFF = Object.freeze({ edit: false, write: false, patch: fals
|
|
|
93
98
|
// workdir, and a run that cannot ask refuses -- so this consultant read less
|
|
94
99
|
// than the others. The last matching rule wins, so "*" comes first.
|
|
95
100
|
function readRules() {
|
|
96
|
-
const deny = [
|
|
97
|
-
...protectedRoots().flatMap((r) => [r, `${r}/**`]),
|
|
98
|
-
...PROTECTED_FILE_NAMES.flatMap((n) => [n, `**/${n}`]),
|
|
99
|
-
];
|
|
101
|
+
const deny = [...protectedRoots().flatMap((r) => [r, `${r}/**`]), ...PROTECTED_FILE_NAMES.flatMap((n) => [n, `**/${n}`])];
|
|
100
102
|
return { '*': 'allow', ...Object.fromEntries(deny.map((p) => [p, 'deny'])) };
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
const AUTH_REL = path.join('.local', 'share', 'opencode', 'auth.json');
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The synthesised HOME of one consultation, as prepareSandbox returns it.
|
|
109
|
+
* @typedef {object} OpencodeSandbox
|
|
110
|
+
* @property {string} root
|
|
111
|
+
* @property {string} credentials 'copy' or 'missing'
|
|
112
|
+
* @property {string[]} tools
|
|
113
|
+
* @property {string} credentialsSource
|
|
114
|
+
* @property {Record<string, string>} env
|
|
115
|
+
* @property {() => void} cleanup
|
|
116
|
+
* @property {(provider: string) => boolean} seed insert the provider's credential into the session database
|
|
117
|
+
*/
|
|
118
|
+
|
|
105
119
|
/**
|
|
106
120
|
* Build the synthesised HOME for one consultation.
|
|
107
|
-
* @param {{workdir: string}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
|
|
121
|
+
* @param {{workdir: string, network?: 'on' | 'off' | undefined}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
|
|
122
|
+
* @returns {OpencodeSandbox}
|
|
108
123
|
*/
|
|
109
124
|
export function prepareSandbox({ workdir, network = 'on' }) {
|
|
110
125
|
const root = sandboxHome(path.basename(path.dirname(workdir)));
|
|
@@ -162,6 +177,7 @@ export function prepareSandbox({ workdir, network = 'on' }) {
|
|
|
162
177
|
credentials = 'copy';
|
|
163
178
|
}
|
|
164
179
|
|
|
180
|
+
/** @type {OpencodeSandbox} */
|
|
165
181
|
const sandbox = {
|
|
166
182
|
root,
|
|
167
183
|
credentials,
|
|
@@ -180,19 +196,25 @@ export function prepareSandbox({ workdir, network = 'on' }) {
|
|
|
180
196
|
// parent directories of the (empty) working directory.
|
|
181
197
|
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
|
182
198
|
OPENCODE_DISABLE_PROJECT_CONFIG: '1',
|
|
183
|
-
...(process.platform === 'win32'
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
...(process.platform === 'win32'
|
|
200
|
+
? {
|
|
201
|
+
USERPROFILE: root,
|
|
202
|
+
HOMEDRIVE: path.parse(root).root.replace(/[\\/]$/, ''),
|
|
203
|
+
HOMEPATH: root.slice(path.parse(root).root.length - 1),
|
|
204
|
+
APPDATA: path.join(root, 'AppData', 'Roaming'),
|
|
205
|
+
LOCALAPPDATA: path.join(root, 'AppData', 'Local'),
|
|
206
|
+
}
|
|
207
|
+
: {}),
|
|
190
208
|
},
|
|
191
209
|
cleanup() {
|
|
192
|
-
try {
|
|
210
|
+
try {
|
|
211
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
212
|
+
} catch {
|
|
213
|
+
/* best effort */
|
|
214
|
+
}
|
|
193
215
|
},
|
|
216
|
+
seed: (provider) => seedCredentialFromAuth(sandbox, provider),
|
|
194
217
|
};
|
|
195
|
-
sandbox.seed = (provider) => seedCredentialFromAuth(sandbox, provider);
|
|
196
218
|
return sandbox;
|
|
197
219
|
}
|
|
198
220
|
|
|
@@ -207,6 +229,8 @@ export function prepareSandbox({ workdir, network = 'on' }) {
|
|
|
207
229
|
* -- note "key" (the method), not the "api" auth.json uses. Only api-key
|
|
208
230
|
* entries are seeded; OAuth tokens have a different shape and lifecycle.
|
|
209
231
|
*
|
|
232
|
+
* @param {{ root: string }} sandbox
|
|
233
|
+
* @param {unknown} provider
|
|
210
234
|
* @returns {boolean} true when a row was written and a retry is worth it.
|
|
211
235
|
*/
|
|
212
236
|
export function seedCredentialFromAuth(sandbox, provider) {
|
|
@@ -218,6 +242,7 @@ export function seedCredentialFromAuth(sandbox, provider) {
|
|
|
218
242
|
// CLI's own migrations, so an unbootstrapped sandbox is simply not seedable.
|
|
219
243
|
if (!fs.existsSync(dbPath)) return false;
|
|
220
244
|
|
|
245
|
+
/** @type {{ label: import('node:sqlite').SQLInputValue, value: string } | null} */
|
|
221
246
|
let row = null;
|
|
222
247
|
// The operator's session database is what the 2.x CLI itself authenticates
|
|
223
248
|
// with, so it outranks the copied auth.json: an operator who rotated their
|
|
@@ -231,7 +256,9 @@ export function seedCredentialFromAuth(sandbox, provider) {
|
|
|
231
256
|
try {
|
|
232
257
|
const db = new DatabaseSync(operatorDb, { readOnly: true });
|
|
233
258
|
try {
|
|
234
|
-
const found = db
|
|
259
|
+
const found = db
|
|
260
|
+
.prepare('SELECT label, value FROM credential WHERE integration_id = ? ORDER BY time_updated DESC LIMIT 1')
|
|
261
|
+
.get(provider);
|
|
235
262
|
if (found && typeof found.value === 'string') {
|
|
236
263
|
const parsed = JSON.parse(found.value);
|
|
237
264
|
if (parsed && typeof parsed === 'object' && typeof parsed.key === 'string' && parsed.key) {
|
|
@@ -241,23 +268,33 @@ export function seedCredentialFromAuth(sandbox, provider) {
|
|
|
241
268
|
} finally {
|
|
242
269
|
db.close();
|
|
243
270
|
}
|
|
244
|
-
} catch {
|
|
271
|
+
} catch {
|
|
272
|
+
/* unreadable or reshaped: try the auth.json source */
|
|
273
|
+
}
|
|
245
274
|
}
|
|
246
275
|
if (!row && fs.existsSync(authPath)) {
|
|
247
276
|
try {
|
|
248
277
|
const entry = JSON.parse(fs.readFileSync(authPath, 'utf8'))[provider];
|
|
249
|
-
if (
|
|
250
|
-
|
|
278
|
+
if (
|
|
279
|
+
entry &&
|
|
280
|
+
typeof entry === 'object' &&
|
|
281
|
+
typeof entry.key === 'string' &&
|
|
282
|
+
entry.key &&
|
|
283
|
+
(!entry.type || entry.type === 'api')
|
|
284
|
+
) {
|
|
251
285
|
row = { label: 'API key', value: JSON.stringify({ type: 'key', key: entry.key }) };
|
|
252
286
|
}
|
|
253
|
-
} catch {
|
|
287
|
+
} catch {
|
|
288
|
+
/* unreadable auth.json: nothing seedable */
|
|
289
|
+
}
|
|
254
290
|
}
|
|
255
291
|
if (!row) return false;
|
|
256
292
|
try {
|
|
257
293
|
const db = new DatabaseSync(dbPath);
|
|
258
294
|
try {
|
|
259
|
-
db.prepare(
|
|
260
|
-
|
|
295
|
+
db.prepare(
|
|
296
|
+
'INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)',
|
|
297
|
+
).run(`cred_${crypto.randomBytes(18).toString('base64url')}`, provider, row.label, row.value, Date.now(), Date.now());
|
|
261
298
|
} finally {
|
|
262
299
|
db.close();
|
|
263
300
|
}
|