@commonlyai/cli 0.1.166 → 0.1.169
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/commands/agent.js +73 -10
- package/src/commands/daemon.js +11 -0
- package/src/lib/adapters/claude.js +9 -4
- package/src/lib/adapters/codex.js +8 -3
- package/src/lib/adapters/dsh.js +9 -4
- package/src/lib/adapters/hermes.js +4 -2
- package/src/lib/adapters/opencode.js +11 -6
- package/src/lib/daemon-supervisor.js +4 -1
- package/src/lib/enforcement.js +45 -1
- package/src/lib/native-runtime-inventory.js +33 -0
- package/src/lib/own-key-launch.js +42 -0
- package/src/lib/pod-catch-up.js +19 -3
- package/src/lib/provider-proxy.js +38 -3
- package/src/lib/runtime-inventory.js +12 -0
- package/src/lib/runtime-observation.js +51 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createOwnKeyResolver, createMachineReportClient } from '../lib/own-key-launch.js';
|
|
2
|
+
import { WALKED_HARNESSES } from '../lib/runtime-inventory.js';
|
|
3
|
+
import { createRuntimeReporter } from '../lib/runtime-observation.js';
|
|
1
4
|
/**
|
|
2
5
|
* commonly agent <subcommand>
|
|
3
6
|
*
|
|
@@ -1262,8 +1265,12 @@ export const performRun = ({
|
|
|
1262
1265
|
sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
1263
1266
|
// The agent to @mention when the model provider refuses a request's content
|
|
1264
1267
|
// (lib/content-refusal.js). Null: the refusal notice names no one.
|
|
1268
|
+
reportRuntime = false,
|
|
1269
|
+
resolveOwnProviderKey = null,
|
|
1270
|
+
runtimeReportClient = null,
|
|
1265
1271
|
refusalHandoff = null,
|
|
1266
1272
|
}) => {
|
|
1273
|
+
reportRuntime = reportRuntime && WALKED_HARNESSES.includes(adapter?.name);
|
|
1267
1274
|
if (environment?.provider !== undefined && !adapter?.supportsProvider) {
|
|
1268
1275
|
throw new Error('environment.provider requires a provider-capable adapter');
|
|
1269
1276
|
}
|
|
@@ -1271,6 +1278,11 @@ export const performRun = ({
|
|
|
1271
1278
|
const effectiveTurnTimeoutMs = Number.isFinite(requestedTurnTimeout) && requestedTurnTimeout > 0
|
|
1272
1279
|
? Math.min(requestedTurnTimeout, 2_147_483_647) : 15 * 60 * 1000;
|
|
1273
1280
|
const client = createClient({ instance: instanceUrl, token });
|
|
1281
|
+
const runtimeReporter = createRuntimeReporter({
|
|
1282
|
+
client: runtimeReportClient || { post: async () => {} },
|
|
1283
|
+
harness: adapter.name, detect: () => adapter.detect(),
|
|
1284
|
+
});
|
|
1285
|
+
const runtimeConnected = reportRuntime ? runtimeReporter.connect() : Promise.resolve();
|
|
1274
1286
|
const missingModelNotices = new Set();
|
|
1275
1287
|
const handoffAgent = sanitizeHandoffAgent(refusalHandoff);
|
|
1276
1288
|
if (refusalHandoff && !handoffAgent) {
|
|
@@ -1321,12 +1333,18 @@ export const performRun = ({
|
|
|
1321
1333
|
// the adapter spawns with, and the stored record keeps its broker, so the
|
|
1322
1334
|
// daemon stays the layer that judges a host it knows. Applied once for the
|
|
1323
1335
|
// run rather than per turn: `agent run` reads its record once at boot.
|
|
1324
|
-
const
|
|
1336
|
+
const requestedSeatEnvironment = withholdGrantBroker(environment, adapter?.name, {
|
|
1325
1337
|
instanceUrl,
|
|
1326
1338
|
onRefuse: (refusal, names) => log(
|
|
1327
1339
|
`${refusal.code} (${refusal.reason}) — withholding ${names.join(', ')}: ${refusal.detail}`,
|
|
1328
1340
|
),
|
|
1329
1341
|
});
|
|
1342
|
+
// Without native effort inventory a machine runner cannot promise that a
|
|
1343
|
+
// stored effort is supported. Preserve the record and use harness default.
|
|
1344
|
+
// Standalone CLI behavior remains under the operator's control.
|
|
1345
|
+
const runtimeInventoryPromise = reportRuntime ? Promise.resolve(adapter.inventory?.()).then((value) => value || { models: null, efforts: [] })
|
|
1346
|
+
.catch(() => ({ models: null, efforts: [] })) : Promise.resolve(null);
|
|
1347
|
+
let seatEnvironment = requestedSeatEnvironment;
|
|
1330
1348
|
// OpenCode retains tool-call IDs in its resumed history. A Gemini proxy
|
|
1331
1349
|
// marks those IDs with signature references, so changing providers must
|
|
1332
1350
|
// start a fresh session before another upstream can receive them.
|
|
@@ -1384,6 +1402,10 @@ export const performRun = ({
|
|
|
1384
1402
|
const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10));
|
|
1385
1403
|
|
|
1386
1404
|
const processEvent = async (event) => {
|
|
1405
|
+
const inventory = await runtimeInventoryPromise;
|
|
1406
|
+
if (reportRuntime && requestedSeatEnvironment && !inventory?.efforts.includes(requestedSeatEnvironment.effort)) {
|
|
1407
|
+
seatEnvironment = { ...requestedSeatEnvironment, effort: undefined };
|
|
1408
|
+
}
|
|
1387
1409
|
const eventPodId = event.podId || podId;
|
|
1388
1410
|
const prompt = extractPrompt(event);
|
|
1389
1411
|
if (!prompt || !eventPodId) {
|
|
@@ -1785,6 +1807,7 @@ export const performRun = ({
|
|
|
1785
1807
|
? { mounted: serverSkills.mounted, conflicted: serverSkills.conflicted, ...(skillReadUnavailable ? { status: 'unavailable' } : {}) }
|
|
1786
1808
|
: null;
|
|
1787
1809
|
let spawnCompleted = false;
|
|
1810
|
+
let observedSpawn = false;
|
|
1788
1811
|
let commitAttribution = null;
|
|
1789
1812
|
try {
|
|
1790
1813
|
// The local CLI is a wrapper for a Commonly seat, so its Git commits
|
|
@@ -1802,7 +1825,24 @@ export const performRun = ({
|
|
|
1802
1825
|
effort: seatEnvironment?.effort,
|
|
1803
1826
|
});
|
|
1804
1827
|
}
|
|
1828
|
+
await runtimeConnected;
|
|
1829
|
+
let providerKey;
|
|
1830
|
+
if (reportRuntime && resolveOwnProviderKey) {
|
|
1831
|
+
try {
|
|
1832
|
+
providerKey = await resolveOwnProviderKey({ opened, harness: adapter.name,
|
|
1833
|
+
provider: seatEnvironment?.provider });
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
if (!error.ownKeySpawnRefused) throw error;
|
|
1836
|
+
return { outcome: 'no_action', refused: { reason: 'delivery-refused' },
|
|
1837
|
+
reason: 'own-key-requires-scoped-spawn' };
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1805
1840
|
result = await adapter.spawn(frameDecisionForkRule(promptForSpawn), {
|
|
1841
|
+
onSpawn: reportRuntime ? (controls) => {
|
|
1842
|
+
observedSpawn = true;
|
|
1843
|
+
return runtimeReporter.spawn(controls);
|
|
1844
|
+
} : undefined,
|
|
1845
|
+
providerKey,
|
|
1806
1846
|
timeoutMs: effectiveTurnTimeoutMs,
|
|
1807
1847
|
sessionId,
|
|
1808
1848
|
cwd: agentCwd,
|
|
@@ -1831,6 +1871,7 @@ export const performRun = ({
|
|
|
1831
1871
|
spawnCompleted = true;
|
|
1832
1872
|
} finally {
|
|
1833
1873
|
try {
|
|
1874
|
+
if (reportRuntime) await runtimeReporter.settled(observedSpawn ? spawnCompleted ? 'completed' : 'failed' : null);
|
|
1834
1875
|
commitAttribution?.cleanup();
|
|
1835
1876
|
} finally {
|
|
1836
1877
|
if (spawnCompleted && skillMount) {
|
|
@@ -1994,15 +2035,32 @@ export const performRun = ({
|
|
|
1994
2035
|
} else {
|
|
1995
2036
|
// ADR-018 length gate: the tone contract is enforced here, where the
|
|
1996
2037
|
// wrapper is the one posting. Split, attach — never truncate.
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2038
|
+
let delivery;
|
|
2039
|
+
try {
|
|
2040
|
+
delivery = await deliverChatReply({
|
|
2041
|
+
client,
|
|
2042
|
+
podId: eventPodId,
|
|
2043
|
+
text: replyText,
|
|
2044
|
+
limit: chatCharLimit,
|
|
2045
|
+
maxChunks: maxChatChunks,
|
|
2046
|
+
uploadName: `${agentName}-reply-${event._id}.md`,
|
|
2047
|
+
log: (line) => log(`[${event.type}] ${line}`),
|
|
2048
|
+
});
|
|
2049
|
+
} catch (deliveryError) {
|
|
2050
|
+
// TASK-535: every rung threw, so the reply reached nobody. The throw
|
|
2051
|
+
// still leaves the events unacked for redelivery (unchanged), but the
|
|
2052
|
+
// retry report is about the BATCH; this line is about the reply, and
|
|
2053
|
+
// it names where it was going so a lost summary is never silent.
|
|
2054
|
+
const eventCount = Array.isArray(event.payload?.batchEventIds)
|
|
2055
|
+
? event.payload.batchEventIds.length
|
|
2056
|
+
: 1;
|
|
2057
|
+
log(
|
|
2058
|
+
`[${event.type}] ERROR reply NOT delivered to pod ${eventPodId} — `
|
|
2059
|
+
+ `${Buffer.byteLength(replyText)} bytes lost, ${eventCount} event${eventCount === 1 ? '' : 's'} `
|
|
2060
|
+
+ `left unacked for retry: ${deliveryError.message}`,
|
|
2061
|
+
);
|
|
2062
|
+
throw deliveryError;
|
|
2063
|
+
}
|
|
2006
2064
|
if (delivery.skipped) {
|
|
2007
2065
|
// The server answered 200 and created nothing: a duplicate inside its
|
|
2008
2066
|
// dedupe window, a silent body, a heartbeat it keeps. Nothing reached
|
|
@@ -3537,6 +3595,11 @@ Docs:
|
|
|
3537
3595
|
// TASK-102 part B: the operator's TTL ask, if any. Unset means the
|
|
3538
3596
|
// server's published default, which is the only lifetime we can justify
|
|
3539
3597
|
// without an operator saying otherwise.
|
|
3598
|
+
runtimeReportClient: createMachineReportClient({ instanceUrl: record.instanceUrl,
|
|
3599
|
+
machineId: record.machineId, agentName: record.agentName, instanceId: record.instanceId }),
|
|
3600
|
+
resolveOwnProviderKey: createOwnKeyResolver({ instanceUrl: record.instanceUrl,
|
|
3601
|
+
machineId: record.machineId, agentName: record.agentName, instanceId: record.instanceId }),
|
|
3602
|
+
reportRuntime: Boolean(record.machineId || process.env.COMMONLY_MANAGED_RUNNER === '1'),
|
|
3540
3603
|
spawnCredentialTtlSeconds: resolveSpawnTtlSeconds(process.env),
|
|
3541
3604
|
refusalHandoff: opts.refusalHandoff || null,
|
|
3542
3605
|
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
package/src/commands/daemon.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectRuntimeInventory } from '../lib/runtime-inventory.js';
|
|
1
2
|
/**
|
|
2
3
|
* commonly daemon <subcommand>
|
|
3
4
|
*
|
|
@@ -474,6 +475,8 @@ Examples:
|
|
|
474
475
|
// exit parks it on macOS; systemd honors 77 directly.
|
|
475
476
|
process.exitCode = saved || process.platform !== 'darwin' ? 77 : 0;
|
|
476
477
|
};
|
|
478
|
+
let cachedInventory;
|
|
479
|
+
let inventoryCheckedAt = 0;
|
|
477
480
|
supervisor = createDaemonSupervisor({
|
|
478
481
|
record,
|
|
479
482
|
client,
|
|
@@ -488,10 +491,18 @@ Examples:
|
|
|
488
491
|
chmodSync(seatLog, 0o600);
|
|
489
492
|
return spawn(process.execPath, [process.argv[1], 'agent', 'run', agentName], {
|
|
490
493
|
stdio: ['ignore', out, out],
|
|
494
|
+
env: { ...process.env, COMMONLY_MANAGED_RUNNER: '1' },
|
|
491
495
|
});
|
|
492
496
|
},
|
|
493
497
|
loadToken: loadAgentToken,
|
|
494
498
|
saveToken: saveAgentToken,
|
|
499
|
+
runtimeInventory: async () => {
|
|
500
|
+
if (!cachedInventory || Date.now() - inventoryCheckedAt > 300_000) {
|
|
501
|
+
cachedInventory = await collectRuntimeInventory(getAdapter);
|
|
502
|
+
inventoryCheckedAt = Date.now();
|
|
503
|
+
}
|
|
504
|
+
return cachedInventory;
|
|
505
|
+
},
|
|
495
506
|
resolveAdapter: (runtime) => resolveAdapterForRuntime(runtime),
|
|
496
507
|
log: stampLog,
|
|
497
508
|
persistState: (seats) => saveDaemonState({
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { claudeRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
|
+
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
1
3
|
/**
|
|
2
4
|
* claude adapter — wraps the local `claude` CLI as a Commonly agent.
|
|
3
5
|
*
|
|
@@ -289,12 +291,13 @@ const publicSkillPromptArgs = async (skillSources) => {
|
|
|
289
291
|
return { args: text ? ['--append-system-prompt', text] : [], included };
|
|
290
292
|
};
|
|
291
293
|
|
|
292
|
-
const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
294
|
+
const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
|
|
293
295
|
const proc = spawnImpl(cmd, args, {
|
|
294
296
|
cwd,
|
|
295
297
|
env,
|
|
296
298
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
297
299
|
});
|
|
300
|
+
observeSpawn(proc, onSpawn, controls);
|
|
298
301
|
let stdout = '';
|
|
299
302
|
let stderr = '';
|
|
300
303
|
let timedOut = false;
|
|
@@ -736,11 +739,13 @@ export default {
|
|
|
736
739
|
// hosted variant.
|
|
737
740
|
runtimeType: 'claude-code',
|
|
738
741
|
|
|
742
|
+
inventory: () => claudeRuntimeInventory({ env: withClaudePath(process.env) }),
|
|
743
|
+
|
|
739
744
|
async detect() {
|
|
740
745
|
try {
|
|
741
746
|
const claudeEnv = withClaudePath(process.env);
|
|
742
747
|
const res = spawnSync('claude', ['--version'], {
|
|
743
|
-
encoding: 'utf8',
|
|
748
|
+
encoding: 'utf8', timeout: 5000,
|
|
744
749
|
env: claudeEnv,
|
|
745
750
|
});
|
|
746
751
|
if (res.error || res.status !== 0) return null;
|
|
@@ -900,7 +905,7 @@ export default {
|
|
|
900
905
|
// and its provider key is not an env var here, so the seat credential is
|
|
901
906
|
// the value a tail can echo back.
|
|
902
907
|
credentials: spawnCredentials(spawnCtx),
|
|
903
|
-
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
908
|
+
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
|
|
904
909
|
});
|
|
905
910
|
// TASK-440: the context this turn left, read from the transcript the
|
|
906
911
|
// child wrote under its own env (an isolated HOME for public seats).
|
|
@@ -951,7 +956,7 @@ export default {
|
|
|
951
956
|
env: retry.env,
|
|
952
957
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
953
958
|
credentials: spawnCredentials(retryCtx),
|
|
954
|
-
spawnImpl: ctx._spawnImpl,
|
|
959
|
+
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
955
960
|
});
|
|
956
961
|
const contextTokens = claudeContextTokens({
|
|
957
962
|
cwd: ctx.cwd, sessionId: freshId,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { codexRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
|
+
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
1
3
|
/**
|
|
2
4
|
* codex adapter — wraps the local `codex` CLI as a Commonly agent.
|
|
3
5
|
*
|
|
@@ -570,7 +572,7 @@ const CODEX_PROVIDER_KEY_ENVS = [
|
|
|
570
572
|
'CODEX_API_KEY',
|
|
571
573
|
];
|
|
572
574
|
|
|
573
|
-
const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
|
|
575
|
+
const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
|
|
574
576
|
// defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
|
|
575
577
|
// `Reading additional input from stdin...` because it sees an open pipe
|
|
576
578
|
// and waits for input that never arrives. Interactive runs are fine because
|
|
@@ -578,6 +580,7 @@ const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = chi
|
|
|
578
580
|
// `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
|
|
579
581
|
// argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
|
|
580
582
|
const proc = spawnImpl('codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
583
|
+
observeSpawn(proc, onSpawn, controls);
|
|
581
584
|
let stderr = '';
|
|
582
585
|
let timedOut = false;
|
|
583
586
|
const events = makeEventParser();
|
|
@@ -625,9 +628,11 @@ export default {
|
|
|
625
628
|
// `runtimeType` and differ only on `host`.
|
|
626
629
|
runtimeType: 'codex',
|
|
627
630
|
|
|
631
|
+
inventory: codexRuntimeInventory,
|
|
632
|
+
|
|
628
633
|
async detect() {
|
|
629
634
|
try {
|
|
630
|
-
const res = spawnSync('codex', ['--version'], { encoding: 'utf8' });
|
|
635
|
+
const res = spawnSync('codex', ['--version'], { encoding: 'utf8', timeout: 5000 });
|
|
631
636
|
if (res.error || res.status !== 0) return null;
|
|
632
637
|
// `codex --version` prints e.g. "codex-cli 0.125.0" — last token is
|
|
633
638
|
// the version. Defensive against future format tweaks: if no
|
|
@@ -734,7 +739,7 @@ export default {
|
|
|
734
739
|
// already removed the token from the child copy, and after that there is
|
|
735
740
|
// nothing left to compare a tail against.
|
|
736
741
|
credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
|
|
737
|
-
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
742
|
+
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
|
|
738
743
|
});
|
|
739
744
|
|
|
740
745
|
let text = '';
|
package/src/lib/adapters/dsh.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeSpawn } from '../runtime-observation.js';
|
|
1
2
|
/** DeepSeek Harness 0.2.1-rc.2, private seats only. Configuration is per spawn. */
|
|
2
3
|
import { requireSeatModel } from './seat-model.js';
|
|
3
4
|
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
@@ -167,8 +168,9 @@ const parseEvent = (line, state) => {
|
|
|
167
168
|
}
|
|
168
169
|
};
|
|
169
170
|
|
|
170
|
-
export const runDsh = ({ binary, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
171
|
+
export const runDsh = ({ binary, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
|
|
171
172
|
const proc = spawnImpl(binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
173
|
+
observeSpawn(proc, onSpawn, controls);
|
|
172
174
|
const state = { sessionId: null, final: null, error: null, buffer: '' };
|
|
173
175
|
let stderr = '';
|
|
174
176
|
let timedOut = false;
|
|
@@ -277,11 +279,14 @@ export default {
|
|
|
277
279
|
const mcp = resolveCommonlyMcp(ctx.environment?.mcp, {
|
|
278
280
|
credentialFile: credential.path, instanceUrl: ctx.instanceUrl,
|
|
279
281
|
});
|
|
280
|
-
const
|
|
282
|
+
const patchData = buildPatch({
|
|
281
283
|
proxyBaseURL: proxy.baseURL, model: seatModel,
|
|
282
284
|
cwd: ctx.cwd, mcp, effort: ctx.environment.effort,
|
|
283
285
|
deepseekOfficial: new URL(ctx.environment.provider.baseURL).hostname === 'api.deepseek.com',
|
|
284
|
-
})
|
|
286
|
+
});
|
|
287
|
+
const patch = JSON.stringify(patchData, null, 2);
|
|
288
|
+
const passedProvider = patchData.find((entry) => entry.id === 'llm-pi-ai').config.providers.commonly;
|
|
289
|
+
const passedModel = patchData.find((entry) => entry.id === 'agent-default-model').config.model;
|
|
285
290
|
assertPatchHasNoCredentials(patch, [ctx.runtimeToken, bearer]);
|
|
286
291
|
const patchPath = join(tempDir, 'seat.patch.yml');
|
|
287
292
|
await writeFile(patchPath, `${patch}\n`, { mode: 0o600 });
|
|
@@ -296,7 +301,7 @@ export default {
|
|
|
296
301
|
? Math.min(requested, MAX_TIMEOUT_MS) : MAX_TIMEOUT_MS;
|
|
297
302
|
return await runDsh({
|
|
298
303
|
binary, args, cwd: ctx.cwd, env, timeoutMs,
|
|
299
|
-
credentials: [...spawnCredentials(ctx, []), bearer], spawnImpl: ctx._spawnImpl,
|
|
304
|
+
credentials: [...spawnCredentials(ctx, []), bearer], spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: { model: passedModel || null, effort: passedProvider.reasoning || null },
|
|
300
305
|
});
|
|
301
306
|
} finally {
|
|
302
307
|
try { await proxy?.close(); } catch { /* best effort */ }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
1
2
|
/** Hermes 0.22.2 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */
|
|
2
3
|
import { requireSeatModel, seatModelRequired } from './seat-model.js';
|
|
3
4
|
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
@@ -49,10 +50,11 @@ export const readHermesJsonl = (line, state) => {
|
|
|
49
50
|
}
|
|
50
51
|
};
|
|
51
52
|
|
|
52
|
-
const runHermes = ({ args, prompt, cwd, env, timeoutMs, spawnImpl, credentials }) => new Promise((resolve, reject) => {
|
|
53
|
+
const runHermes = ({ args, prompt, cwd, env, timeoutMs, spawnImpl, credentials, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
|
|
53
54
|
const child = (spawnImpl || childSpawn)('hermes', args, {
|
|
54
55
|
cwd, env, stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
56
|
});
|
|
57
|
+
observeSpawn(child, onSpawn, controls);
|
|
56
58
|
const state = { started: false, result: null };
|
|
57
59
|
const decoder = new StringDecoder('utf8');
|
|
58
60
|
let buffer = '';
|
|
@@ -190,7 +192,7 @@ export default {
|
|
|
190
192
|
? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
|
|
191
193
|
return runHermes({
|
|
192
194
|
args, prompt: fullPrompt, cwd: ctx.cwd, env, timeoutMs,
|
|
193
|
-
spawnImpl: ctx._spawnImpl,
|
|
195
|
+
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }),
|
|
194
196
|
credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']),
|
|
195
197
|
});
|
|
196
198
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
1
2
|
/**
|
|
2
3
|
* OpenCode adapter — wraps `opencode run --format json` as a Commonly seat.
|
|
3
4
|
*
|
|
@@ -393,7 +394,7 @@ const publicToolFailedError = ({ tool, error }) => {
|
|
|
393
394
|
return failure;
|
|
394
395
|
};
|
|
395
396
|
|
|
396
|
-
const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
397
|
+
const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
|
|
397
398
|
let proc;
|
|
398
399
|
try {
|
|
399
400
|
proc = spawnImpl(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -401,6 +402,7 @@ const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl =
|
|
|
401
402
|
reject(err);
|
|
402
403
|
return;
|
|
403
404
|
}
|
|
405
|
+
observeSpawn(proc, onSpawn, controls);
|
|
404
406
|
let stderr = '';
|
|
405
407
|
let timedOut = false;
|
|
406
408
|
let killTimer = null;
|
|
@@ -634,7 +636,7 @@ export default {
|
|
|
634
636
|
|
|
635
637
|
async detect() {
|
|
636
638
|
try {
|
|
637
|
-
const res = spawnSync('opencode', ['--version'], { encoding: 'utf8' });
|
|
639
|
+
const res = spawnSync('opencode', ['--version'], { encoding: 'utf8', timeout: VERSION_CHECK_TIMEOUT_MS });
|
|
638
640
|
if (res.error || res.status !== 0) return null;
|
|
639
641
|
const stdout = (res.stdout || '').trim();
|
|
640
642
|
const version = parseOpenCodeVersion(stdout) || stdout || 'unknown';
|
|
@@ -666,7 +668,10 @@ export default {
|
|
|
666
668
|
if (isPublic && !provider) {
|
|
667
669
|
throw new Error('public OpenCode seats require environment.provider; the implicit public tier and operator auth.json are unsupported for public trust');
|
|
668
670
|
}
|
|
669
|
-
|
|
671
|
+
if (ctx.providerKey && (!isPublic || !['workspace', 'read-only', 'bwrap'].includes(sandboxMode))) {
|
|
672
|
+
throw new Error('Own provider keys require a confined OpenCode runtime');
|
|
673
|
+
}
|
|
674
|
+
const providerKeyFile = ctx.providerKey ? null : await validateProviderEnvironment(ctx.environment, ctx.cwd);
|
|
670
675
|
|
|
671
676
|
const sourceEnv = ctx.env || process.env;
|
|
672
677
|
const childSourceEnv = Object.fromEntries(Object.entries(sourceEnv)
|
|
@@ -739,7 +744,7 @@ export default {
|
|
|
739
744
|
// the bearer only prevents unrelated local processes from using it.
|
|
740
745
|
providerProxy = await startProviderProxy({
|
|
741
746
|
provider,
|
|
742
|
-
keyFile: providerKeyFile,
|
|
747
|
+
keyFile: providerKeyFile, keyMaterial: ctx.providerKey,
|
|
743
748
|
tokenFile: join(tempDir, 'provider-proxy-token'),
|
|
744
749
|
...(sandboxMode === 'bwrap' ? {
|
|
745
750
|
socketPath: join(tempDir, 'provider-proxy.sock'),
|
|
@@ -868,7 +873,7 @@ export default {
|
|
|
868
873
|
});
|
|
869
874
|
[cmd, ...spawnArgs] = wrapped;
|
|
870
875
|
}
|
|
871
|
-
const credentials = spawnCredentials(ctx, sourceAuthEnvNames(sourceEnv));
|
|
876
|
+
const credentials = [...spawnCredentials(ctx, sourceAuthEnvNames(sourceEnv)), ctx.providerKey].filter(Boolean);
|
|
872
877
|
const result = await runOpenCode({
|
|
873
878
|
cmd,
|
|
874
879
|
args: spawnArgs,
|
|
@@ -876,7 +881,7 @@ export default {
|
|
|
876
881
|
env: childEnv,
|
|
877
882
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
878
883
|
credentials,
|
|
879
|
-
spawnImpl: ctx._spawnImpl,
|
|
884
|
+
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(0, -1), { modelFlag: '-m', effortFlag: '--variant', valueFlags: ['-s', '--dir', '--title', '--format'] }),
|
|
880
885
|
});
|
|
881
886
|
if (isPublic) {
|
|
882
887
|
const failure = publicToolFailure(result.toolErrors, ctx.environment?.mcp);
|
|
@@ -64,6 +64,7 @@ export const createDaemonSupervisor = ({
|
|
|
64
64
|
spawnChild, // (agentName) => child emitting 'exit'; must expose .kill()
|
|
65
65
|
loadToken, // (agentName) => token record | null
|
|
66
66
|
saveToken, // (agentName, record) => void
|
|
67
|
+
runtimeInventory = async () => undefined,
|
|
67
68
|
resolveAdapter, // async (runtime) => adapter name for THIS machine
|
|
68
69
|
persistState = () => {}, // (agentStates) => void; must not persist secrets
|
|
69
70
|
log = () => {},
|
|
@@ -343,6 +344,7 @@ export const createDaemonSupervisor = ({
|
|
|
343
344
|
const workspacePath = workspacePathFor(nextEnvironment);
|
|
344
345
|
const nextRecord = {
|
|
345
346
|
...existing,
|
|
347
|
+
machineId: record.machineId,
|
|
346
348
|
...(adapterChanged ? { adapter: nextAdapter } : {}),
|
|
347
349
|
environment: nextEnvironment,
|
|
348
350
|
...(workspacePath ? { workspacePath } : {}),
|
|
@@ -445,6 +447,7 @@ export const createDaemonSupervisor = ({
|
|
|
445
447
|
instanceId: row.instanceId,
|
|
446
448
|
runtimeToken: minted.token,
|
|
447
449
|
instanceUrl: record.instanceUrl,
|
|
450
|
+
machineId: record.machineId,
|
|
448
451
|
podId: row.podIds?.[0] || null,
|
|
449
452
|
adapter,
|
|
450
453
|
...(recordEnvironment ? { environment: recordEnvironment } : {}),
|
|
@@ -552,7 +555,7 @@ export const createDaemonSupervisor = ({
|
|
|
552
555
|
const heartbeat = async () => {
|
|
553
556
|
if (stopped || now() < reconnectAfter) return;
|
|
554
557
|
try {
|
|
555
|
-
await client.post(`/api/machines/${record.machineDbId}/heartbeat`, { agents: agentStates() });
|
|
558
|
+
await client.post(`/api/machines/${record.machineDbId}/heartbeat`, { agents: agentStates(), runtimeInventory: await runtimeInventory() });
|
|
556
559
|
connectionRestored();
|
|
557
560
|
} catch (error) {
|
|
558
561
|
connectionFailure(error, 'heartbeat failed');
|
package/src/lib/enforcement.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* stands the agent down, and both are logged with the reason.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { isUnseenMessagesHold } from './pod-catch-up.js';
|
|
22
|
+
|
|
21
23
|
// Event types whose payload.messageId identifies a claimable trigger message.
|
|
22
24
|
// first_contact is deliberately absent: the welcome wake targets exactly one
|
|
23
25
|
// agent, so there is nothing to contend for. Heartbeats have no message at
|
|
@@ -673,7 +675,49 @@ export const deliverChatReply = async ({
|
|
|
673
675
|
// request, but no message was created. Keep that distinction at the client
|
|
674
676
|
// boundary so every delivery mode shares it rather than treating a resolved
|
|
675
677
|
// promise as proof of a post.
|
|
676
|
-
|
|
678
|
+
//
|
|
679
|
+
// TASK-535 — the send-time freshness hold. The backend answers 409
|
|
680
|
+
// `unseen_messages` when other members posted after this seat's last
|
|
681
|
+
// recorded tail read. A turn that ran ~43 minutes (rhea, 2026-10-25 10:01Z)
|
|
682
|
+
// hit it on the thread headline, the attach card and the split fallback in
|
|
683
|
+
// turn, so the reply was lost and its 10 events re-ran. A retry without an
|
|
684
|
+
// override cannot succeed — every rung posts to the same guarded route.
|
|
685
|
+
//
|
|
686
|
+
// CTO ruling: on the FIRST hold of a delivery, re-read the pod tail once,
|
|
687
|
+
// then post with `continueAnyway: true`, and keep the override for every
|
|
688
|
+
// later post in THIS delivery (remaining chunks, later rungs). The re-read
|
|
689
|
+
// is GET /messages at the route's 50-row cap with no `before`/`threadRootId`
|
|
690
|
+
// — the read that records a seen cursor (AgentSendFreshness.recordRead). It
|
|
691
|
+
// advances the cursor only when the page holds EVERY message since the last
|
|
692
|
+
// recorded read. The wrapper's own post-turn snapshot is that same read at
|
|
693
|
+
// limit 10, which is why a 12-message gap stayed unseen. So a re-read alone
|
|
694
|
+
// passes a normal post only while the gap is ≤ 50 and nothing lands between
|
|
695
|
+
// read and post; the override is what delivers, and the read records what
|
|
696
|
+
// the wrapper saw, exactly as its snapshots already do. One re-read and one
|
|
697
|
+
// override per delivery; a post that still fails throws to the next rung as
|
|
698
|
+
// before. Nothing here loops.
|
|
699
|
+
let freshnessOverride = false;
|
|
700
|
+
const postMessage = async (body) => {
|
|
701
|
+
if (freshnessOverride) return client.post(messagesPath, { ...body, continueAnyway: true });
|
|
702
|
+
try {
|
|
703
|
+
return await client.post(messagesPath, body);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
if (!isUnseenMessagesHold(error)) throw error;
|
|
706
|
+
freshnessOverride = true;
|
|
707
|
+
const unseen = error.body?.unseenCount;
|
|
708
|
+
log(
|
|
709
|
+
`reply held: ${Number.isFinite(unseen) ? unseen : 'some'} unseen message(s) in pod ${podId} `
|
|
710
|
+
+ 'arrived during the turn — re-reading the pod once, then posting with continueAnyway (TASK-535)',
|
|
711
|
+
);
|
|
712
|
+
try {
|
|
713
|
+
await client.get(messagesPath, { limit: 50 });
|
|
714
|
+
} catch (readError) {
|
|
715
|
+
// The re-read is bookkeeping, not permission: the override delivers.
|
|
716
|
+
log(`pod re-read before the continueAnyway post failed (${readError.message}) — posting anyway`);
|
|
717
|
+
}
|
|
718
|
+
return client.post(messagesPath, { ...body, continueAnyway: true });
|
|
719
|
+
}
|
|
720
|
+
};
|
|
677
721
|
const refused = (response, messages, attemptedMessages) => ({
|
|
678
722
|
mode: 'refused',
|
|
679
723
|
messages,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const claudeEffortsFromHelp = (help) => {
|
|
7
|
+
const section = String(help).match(/--effort\s+<[^>]+>([\s\S]*?)(?=\n\s+--|$)/)?.[1];
|
|
8
|
+
const declared = section?.match(/\((low(?:\s*,\s*(?:medium|high|xhigh|max))*)\)/)?.[1];
|
|
9
|
+
return declared ? declared.split(',').map((level) => level.trim()) : [];
|
|
10
|
+
};
|
|
11
|
+
export const codexModelsFromCache = (cache) => {
|
|
12
|
+
const models = Array.isArray(cache?.models) ? cache.models.filter((row) => typeof row.slug === 'string'
|
|
13
|
+
&& row.slug.length > 0 && row.slug.length <= 256 && row.visibility !== 'hidden') : [];
|
|
14
|
+
const levels = models.map((row) => (Array.isArray(row.supported_reasoning_levels)
|
|
15
|
+
? row.supported_reasoning_levels.flatMap((level) => typeof level.effort === 'string' ? [level.effort] : []) : []));
|
|
16
|
+
return { models: models.length ? models.map((row) => row.slug) : null,
|
|
17
|
+
// Only levels supported by every advertised model are offered globally.
|
|
18
|
+
efforts: levels.length ? levels[0].filter((level) => levels.every((list) => list.includes(level))) : [] };
|
|
19
|
+
};
|
|
20
|
+
export const claudeRuntimeInventory = async ({ spawnSyncImpl = spawnSync, env = process.env } = {}) => {
|
|
21
|
+
const result = spawnSyncImpl('claude', ['--help'], {
|
|
22
|
+
encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024, cwd: tmpdir(), env,
|
|
23
|
+
});
|
|
24
|
+
return { models: null, efforts: result.status === 0 ? claudeEffortsFromHelp(result.stdout) : [] };
|
|
25
|
+
};
|
|
26
|
+
export const codexRuntimeInventory = async ({ readFileImpl = readFile, statImpl = stat, env = process.env } = {}) => {
|
|
27
|
+
// Native cache only: no command that could execute workspace extensions.
|
|
28
|
+
const path = join(env.CODEX_HOME || join(homedir(), '.codex'), 'models_cache.json');
|
|
29
|
+
try {
|
|
30
|
+
if ((await statImpl(path)).size > 2 * 1024 * 1024) return { models: null, efforts: [] };
|
|
31
|
+
return codexModelsFromCache(JSON.parse(await readFileImpl(path, 'utf8')));
|
|
32
|
+
} catch { return { models: null, efforts: [] }; }
|
|
33
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { loadDaemonRecord } from './daemon-store.js';
|
|
2
|
+
import { createClient } from './api.js';
|
|
3
|
+
|
|
4
|
+
// The daemon bearer stays in this parent closure. Seat tokens can be inherited
|
|
5
|
+
// by model children during mint fallback, so they never authorize key reads.
|
|
6
|
+
export const createOwnKeyResolver = ({ instanceUrl, machineId, agentName, instanceId = 'default',
|
|
7
|
+
loadRecord = loadDaemonRecord, clientFactory = createClient }) => async ({ opened, harness, provider }) => {
|
|
8
|
+
if (harness !== 'opencode' || !provider) return undefined;
|
|
9
|
+
const record = loadRecord();
|
|
10
|
+
if (!record || (machineId && record.machineId !== machineId)
|
|
11
|
+
|| new URL(record.instanceUrl).href !== new URL(instanceUrl).href) {
|
|
12
|
+
throw new Error('Own provider key requires this machine’s registered daemon');
|
|
13
|
+
}
|
|
14
|
+
let launch;
|
|
15
|
+
try {
|
|
16
|
+
launch = await clientFactory({ instance: instanceUrl, token: record.daemonToken })
|
|
17
|
+
.post('/api/agents/runtime/provider-key/launch', { agentName, instanceId, harness,
|
|
18
|
+
providerId: provider.id, baseURL: provider.baseURL, metadataOnly: opened?.source !== 'spawn' });
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if ([404, 405].includes(error.status)) return undefined;
|
|
21
|
+
throw new Error('Own provider key could not be resolved; retry after checking Settings');
|
|
22
|
+
}
|
|
23
|
+
if (!launch?.set) return undefined;
|
|
24
|
+
if (opened?.source !== 'spawn') {
|
|
25
|
+
const error = new Error('Own provider key requires a scoped spawn credential; turn was not started');
|
|
26
|
+
error.ownKeySpawnRefused = true;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
return launch.key;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Parent-only receipt client. Failure to load machine authority disables
|
|
33
|
+
// diagnostics; it must never fall back to a seat bearer.
|
|
34
|
+
export const createMachineReportClient = ({ instanceUrl, machineId, agentName, instanceId = 'default' }) => {
|
|
35
|
+
try {
|
|
36
|
+
const record = loadDaemonRecord();
|
|
37
|
+
if (!record || (machineId && machineId !== record.machineId)
|
|
38
|
+
|| new URL(record.instanceUrl).href !== new URL(instanceUrl).href) return null;
|
|
39
|
+
const client = createClient({ instance: instanceUrl, token: record.daemonToken });
|
|
40
|
+
return { post: (path, body) => client.post(path, { ...body, agentName, instanceId }) };
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
};
|
package/src/lib/pod-catch-up.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backend's send-time freshness hold (agentSendFreshness.unseenMessages):
|
|
3
|
+
* HTTP 409 with `code: 'unseen_messages'`, surfaced by api.js as
|
|
4
|
+
* `error.status` + `error.body`. Shared by the turn-start probe below and the
|
|
5
|
+
* end-of-turn delivery in enforcement.js, so both read the hold the same way.
|
|
6
|
+
*/
|
|
7
|
+
export const isUnseenMessagesHold = (error) => (
|
|
8
|
+
error?.status === 409 && error?.body?.code === 'unseen_messages'
|
|
9
|
+
);
|
|
10
|
+
|
|
1
11
|
/** Explicit turn-start recovery for history that cannot fit in a tail read. */
|
|
2
12
|
export const catchUpBeforeTurn = async (client, podId) => {
|
|
3
13
|
const base = `/api/agents/runtime/pods/${encodeURIComponent(podId)}`;
|
|
@@ -9,13 +19,19 @@ export const catchUpBeforeTurn = async (client, podId) => {
|
|
|
9
19
|
// Unknown freshness preserves existing turn behavior; the send-time auth
|
|
10
20
|
// and freshness checks still decide whether output may be posted.
|
|
11
21
|
if (error?.status !== 409) return '';
|
|
12
|
-
if (error
|
|
22
|
+
if (!isUnseenMessagesHold(error)) throw error;
|
|
13
23
|
unseenCount = error.body.unseenCount;
|
|
14
24
|
}
|
|
15
25
|
// Routine wakes retain the seat's existing read behavior and prompt size.
|
|
16
26
|
if (!(unseenCount > 50)) return '';
|
|
17
|
-
//
|
|
18
|
-
// supplies fresh conversation
|
|
27
|
+
// Before the turn, the answer to a hold is context, not an override: a
|
|
28
|
+
// catch-up supplies fresh conversation BEFORE the model gets a turn, so
|
|
29
|
+
// nothing here sends with continueAnyway. The one place an already
|
|
30
|
+
// generated reply IS retried with continueAnyway is end-of-turn delivery
|
|
31
|
+
// (deliverChatReply, TASK-535): a turn long enough for the pod to move on
|
|
32
|
+
// otherwise lost its whole reply to this same hold and left its events to
|
|
33
|
+
// re-run. That is a CTO ruling scoped to delivery; it does not license
|
|
34
|
+
// continueAnyway anywhere a fresh read can still reach the model.
|
|
19
35
|
let body;
|
|
20
36
|
try {
|
|
21
37
|
body = await client.post(`${base}/catch-up`, { confirmSkipHistory: true });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Transform } from 'node:stream';
|
|
1
2
|
/** The per-spawn loopback proxy shared by OpenAI-compatible CLI adapters. */
|
|
2
3
|
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
3
4
|
import { constants as fsConstants, realpathSync } from 'fs';
|
|
@@ -6,6 +7,30 @@ import { request as httpsRequest } from 'https';
|
|
|
6
7
|
import { chmod, lstat, open, unlink, writeFile } from 'fs/promises';
|
|
7
8
|
import { isAbsolute, relative, sep } from 'path';
|
|
8
9
|
|
|
10
|
+
// Providers sometimes echo request headers in an error response. Redact the
|
|
11
|
+
// material across arbitrary stream chunk boundaries before any model sees it.
|
|
12
|
+
export const redactProviderMaterial = (material) => {
|
|
13
|
+
const secret = Buffer.from(material);
|
|
14
|
+
let pending = Buffer.alloc(0);
|
|
15
|
+
return new Transform({
|
|
16
|
+
transform(chunk, _encoding, done) {
|
|
17
|
+
pending = Buffer.concat([pending, Buffer.from(chunk)]);
|
|
18
|
+
while (pending.length >= secret.length) {
|
|
19
|
+
const index = pending.indexOf(secret);
|
|
20
|
+
if (index >= 0) {
|
|
21
|
+
this.push(pending.subarray(0, index)); this.push('[redacted]');
|
|
22
|
+
pending = pending.subarray(index + secret.length);
|
|
23
|
+
} else {
|
|
24
|
+
const count = pending.length - secret.length + 1;
|
|
25
|
+
this.push(pending.subarray(0, count)); pending = pending.subarray(count);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
done();
|
|
29
|
+
},
|
|
30
|
+
flush(done) { this.push(pending); pending = Buffer.alloc(0); done(); },
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
9
34
|
const isPathWithin = (parent, candidate) => {
|
|
10
35
|
const fromParent = relative(parent, candidate);
|
|
11
36
|
return fromParent === '' || (fromParent !== '..'
|
|
@@ -68,7 +93,7 @@ const readProviderKey = async (keyFile, adapterLabel) => {
|
|
|
68
93
|
};
|
|
69
94
|
|
|
70
95
|
export const startProviderProxy = async ({
|
|
71
|
-
provider, keyFile, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode',
|
|
96
|
+
provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode',
|
|
72
97
|
}) => {
|
|
73
98
|
const upstream = new URL(provider.baseURL);
|
|
74
99
|
if (socketPath && (!isAbsolute(socketPath)
|
|
@@ -80,7 +105,8 @@ export const startProviderProxy = async ({
|
|
|
80
105
|
throw new Error(`${adapterLabel} provider.baseURL must be an http(s) URL without embedded credentials or query data`);
|
|
81
106
|
}
|
|
82
107
|
const basePath = upstream.pathname.replace(/\/+$/, '') || '/';
|
|
83
|
-
const upstreamKey = await readProviderKey(keyFile, adapterLabel);
|
|
108
|
+
const upstreamKey = keyMaterial === undefined ? await readProviderKey(keyFile, adapterLabel) : keyMaterial;
|
|
109
|
+
if (typeof upstreamKey !== 'string' || !upstreamKey || /[\r\n]/.test(upstreamKey)) throw new Error('Invalid provider key');
|
|
84
110
|
const proxyToken = randomBytes(32).toString('base64url');
|
|
85
111
|
await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
|
|
86
112
|
await chmod(tokenFile, 0o600);
|
|
@@ -176,6 +202,7 @@ export const startProviderProxy = async ({
|
|
|
176
202
|
].includes(name.toLowerCase())));
|
|
177
203
|
headers.authorization = `Bearer ${upstreamKey}`;
|
|
178
204
|
headers.host = target.host;
|
|
205
|
+
headers['accept-encoding'] = 'identity';
|
|
179
206
|
const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
|
|
180
207
|
protocol: target.protocol,
|
|
181
208
|
hostname: target.hostname,
|
|
@@ -189,13 +216,21 @@ export const startProviderProxy = async ({
|
|
|
189
216
|
outgoing.writeHead(502).end('Provider redirects are not supported');
|
|
190
217
|
return;
|
|
191
218
|
}
|
|
219
|
+
if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') {
|
|
220
|
+
response.resume(); outgoing.writeHead(502).end('Compressed provider responses are not supported'); return;
|
|
221
|
+
}
|
|
192
222
|
const responseHeaders = Object.fromEntries(Object.entries(response.headers)
|
|
193
223
|
.filter(([name]) => ![
|
|
194
224
|
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
195
225
|
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
196
226
|
].includes(name.toLowerCase())));
|
|
227
|
+
delete responseHeaders['content-length'];
|
|
228
|
+
for (const [name, value] of Object.entries(responseHeaders)) {
|
|
229
|
+
if (typeof value === 'string') responseHeaders[name] = value.split(upstreamKey).join('[redacted]');
|
|
230
|
+
else if (Array.isArray(value)) responseHeaders[name] = value.map((entry) => entry.split(upstreamKey).join('[redacted]'));
|
|
231
|
+
}
|
|
197
232
|
outgoing.writeHead(response.statusCode || 502, responseHeaders);
|
|
198
|
-
response.pipe(outgoing);
|
|
233
|
+
response.pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
|
|
199
234
|
});
|
|
200
235
|
request.on('error', () => {
|
|
201
236
|
if (!outgoing.headersSent) outgoing.writeHead(502);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Probe only the walked adapter's version command. Never enumerate workspace
|
|
2
|
+
// extensions to discover models: a model picker must not execute project code.
|
|
3
|
+
export const WALKED_HARNESSES = ['claude', 'codex', 'opencode', 'hermes', 'dsh'];
|
|
4
|
+
export const collectRuntimeInventory = async (getAdapter, capabilities) => Promise.all(WALKED_HARNESSES.map(async (harness) => {
|
|
5
|
+
const adapter = getAdapter(harness);
|
|
6
|
+
let detected = null;
|
|
7
|
+
try { detected = await adapter?.detect(); } catch { /* unavailable */ }
|
|
8
|
+
let choices = { models: null, efforts: [] };
|
|
9
|
+
if (detected) { try { choices = (await (capabilities ? capabilities(harness) : adapter?.inventory?.())) || choices; } catch { /* unavailable */ } }
|
|
10
|
+
return { harness, installed: Boolean(detected), version: detected?.version || null,
|
|
11
|
+
models: choices.models, efforts: choices.efforts };
|
|
12
|
+
}));
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Read only adapter-owned control arguments. Callers exclude prompt/stdin and
|
|
2
|
+
// provide every value-taking option, so a flag-shaped model/session value is
|
|
3
|
+
// never reinterpreted as a second option. Raw arguments never leave the runner.
|
|
4
|
+
export const controlsFromArguments = (args, { modelFlag, effortFlag, valueFlags = [], codex = false }) => {
|
|
5
|
+
const controls = { model: null, effort: null };
|
|
6
|
+
const flags = new Set([modelFlag, effortFlag, ...valueFlags].filter(Boolean));
|
|
7
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
8
|
+
const flag = args[index];
|
|
9
|
+
if (!flags.has(flag)) continue;
|
|
10
|
+
const value = args[++index];
|
|
11
|
+
if (flag === modelFlag) controls.model = value || null;
|
|
12
|
+
if (flag === effortFlag) controls.effort = value || null;
|
|
13
|
+
if (codex && flag === '-c' && value?.startsWith('model_reasoning_effort=')) {
|
|
14
|
+
try { controls.effort = JSON.parse(value.slice('model_reasoning_effort='.length)); } catch { /* unknown encoding */ }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return controls;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Called at the child's successful OS spawn event, not when a requested
|
|
21
|
+
// configuration is saved or when a turn is merely queued. Never send argv.
|
|
22
|
+
export const observeSpawn = (child, onSpawn, controls) => {
|
|
23
|
+
if (typeof onSpawn !== 'function') return;
|
|
24
|
+
child.once('spawn', () => {
|
|
25
|
+
try { onSpawn({ model: controls.model || null, effort: controls.effort || null, at: new Date().toISOString() }); } catch { /* diagnostic only */ }
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const createRuntimeReporter = ({ client, harness, detect }) => {
|
|
30
|
+
let queue = Promise.resolve();
|
|
31
|
+
const send = (body) => {
|
|
32
|
+
// Serialize so a fast process cannot have its settled report overtake its
|
|
33
|
+
// spawn report. Old backends and diagnostic outages don't stop a turn.
|
|
34
|
+
queue = queue.then(() => client.post('/api/agents/runtime/observation', body)).catch(() => {});
|
|
35
|
+
return queue;
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
async connect() {
|
|
39
|
+
return send({ event: 'connected' });
|
|
40
|
+
},
|
|
41
|
+
spawn: ({ model, effort, at }) => {
|
|
42
|
+
const receipt = { event: 'spawn', harness, version: null, model, effort, ...(at ? { at } : {}) };
|
|
43
|
+
queue = queue.then(async () => {
|
|
44
|
+
try { receipt.version = (await detect())?.version || null; } catch { /* unavailable */ }
|
|
45
|
+
await client.post('/api/agents/runtime/observation', receipt);
|
|
46
|
+
}).catch(() => {});
|
|
47
|
+
return queue;
|
|
48
|
+
},
|
|
49
|
+
settled: (outcome = null) => send({ event: 'settled', ...(outcome ? { outcome } : {}) }),
|
|
50
|
+
};
|
|
51
|
+
};
|