@commonlyai/cli 0.1.233 → 0.1.235
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 +44 -3
- package/src/commands/daemon.js +48 -19
- package/src/lib/adapters/claude.js +15 -0
- package/src/lib/daemon-service-windows.js +8 -1
- package/src/lib/enforcement.js +18 -8
- package/src/lib/reply-receipt.js +53 -0
- package/src/lib/sandbox/windows-unsandboxed.js +4 -1
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -89,7 +89,7 @@ import { createClaimedWorkGate } from '../lib/claimed-work-gate.js';
|
|
|
89
89
|
import { prepareCommitAttribution } from '../lib/commit-attribution.js';
|
|
90
90
|
import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
91
91
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
92
|
-
import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
|
|
92
|
+
import { UNSANDBOXED_FLAG, hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
|
|
93
93
|
import {
|
|
94
94
|
UNSANDBOXED_NOTICE, hostUnconfinedForRun, runOptedInUnsandboxed, unsandboxedFlagRefusal,
|
|
95
95
|
} from '../lib/sandbox/windows-unsandboxed.js';
|
|
@@ -134,6 +134,7 @@ import {
|
|
|
134
134
|
peerHoldsFrame,
|
|
135
135
|
resolveCascadeSettings,
|
|
136
136
|
} from '../lib/enforcement.js';
|
|
137
|
+
import { buildReplyReceipt } from '../lib/reply-receipt.js';
|
|
137
138
|
|
|
138
139
|
const isPodFocusDiagnostic = (error) => (
|
|
139
140
|
typeof error?.code === 'string'
|
|
@@ -1175,6 +1176,8 @@ export const performAttach = async ({
|
|
|
1175
1176
|
log = () => {},
|
|
1176
1177
|
adapterRegistry = { getAdapter, listAdapterNames },
|
|
1177
1178
|
seatbelt: probeSeatbelt = detectSeatbelt,
|
|
1179
|
+
bwrap: probeBwrap = detectBwrap,
|
|
1180
|
+
platform = process.platform,
|
|
1178
1181
|
}) => {
|
|
1179
1182
|
const adapter = adapterRegistry.getAdapter(adapterName);
|
|
1180
1183
|
if (!adapter) {
|
|
@@ -1209,10 +1212,37 @@ export const performAttach = async ({
|
|
|
1209
1212
|
}
|
|
1210
1213
|
log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
|
|
1211
1214
|
|
|
1212
|
-
const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName, adapter });
|
|
1215
|
+
const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName, adapter, platform });
|
|
1216
|
+
// D245: native Windows has neither sandbox, so its refusal names Windows
|
|
1217
|
+
// and the user's own opt-in, never Seatbelt or bwrap advice. Attach still
|
|
1218
|
+
// refuses there, as before; only the words differ.
|
|
1219
|
+
const windowsRefusal = () => new Error(adapterName === 'dsh'
|
|
1220
|
+
? `confined dsh sandbox.mode=${sandboxMode} needs a sandbox, and Commonly has no sandbox for Windows yet, `
|
|
1221
|
+
+ 'so dsh cannot run confined on native Windows. Choose "Run it here" on the Connect page for a hosted agent.'
|
|
1222
|
+
: `This environment file asks for sandbox.mode=${sandboxMode}, which Commonly cannot enforce on Windows, `
|
|
1223
|
+
+ 'so attach stops here.\n'
|
|
1224
|
+
// This attach made no agent, so Claude's options name the path that works on
|
|
1225
|
+
// Windows: the Connect page's commands, which set the two env vars and run with
|
|
1226
|
+
// the flag, keeping the public trust and policy (#3407 review B1). Other
|
|
1227
|
+
// adapters keep the shared Windows text.
|
|
1228
|
+
+ (adapterName === 'claude' ? [
|
|
1229
|
+
'Instead:',
|
|
1230
|
+
' - Run it without an OS sandbox, which is your choice: use the commands on the Connect page.'
|
|
1231
|
+
+ ' They set COMMONLY_API_URL and COMMONLY_AGENT_TOKEN, then run'
|
|
1232
|
+
+ ` commonly agent run <name> --adapter claude ${UNSANDBOXED_FLAG}.`
|
|
1233
|
+
+ " It keeps Commonly's restricted tool settings and gets no shell, but nothing at the OS level"
|
|
1234
|
+
+ ' confines it, and it gets no connector grants.'
|
|
1235
|
+
// agent run prefers a saved record over the page's env vars, so an
|
|
1236
|
+
// older record of that name would run with its own settings (#3407).
|
|
1237
|
+
+ ' If this computer already has an agent saved under that name, the run uses that one and its'
|
|
1238
|
+
+ ' settings instead: remove it first with commonly agent detach <name> --force.',
|
|
1239
|
+
' - Use Commonly from your own Claude Code sessions: the Connect page shows a PowerShell `claude mcp add` command.',
|
|
1240
|
+
' - A hosted agent: choose "Run it here" on the Connect page. Nothing runs on this machine.',
|
|
1241
|
+
].join('\n') : windowsSandboxMessage(adapterName)));
|
|
1213
1242
|
if (sandboxMode === 'bwrap') {
|
|
1214
|
-
const bwrap =
|
|
1243
|
+
const bwrap = probeBwrap();
|
|
1215
1244
|
if (!bwrap.available) {
|
|
1245
|
+
if (platform === 'win32') throw windowsRefusal();
|
|
1216
1246
|
throw new Error(`sandbox.mode=bwrap requested but unavailable: ${bwrap.error}`);
|
|
1217
1247
|
}
|
|
1218
1248
|
if (environment.sandbox?.network?.policy === 'restricted') {
|
|
@@ -1238,6 +1268,7 @@ export const performAttach = async ({
|
|
|
1238
1268
|
|| adapterName;
|
|
1239
1269
|
const seatbelt = probeSeatbelt();
|
|
1240
1270
|
if (!seatbelt.available) {
|
|
1271
|
+
if (platform === 'win32') throw windowsRefusal();
|
|
1241
1272
|
// dsh refuses bwrap, so it gets no Linux hint that leads to a second refusal.
|
|
1242
1273
|
throw new Error(adapterName === 'dsh'
|
|
1243
1274
|
? `confined dsh sandbox.mode=${sandboxMode} requires macOS Seatbelt: ${seatbelt.error}.`
|
|
@@ -2296,6 +2327,8 @@ export const performRun = ({
|
|
|
2296
2327
|
? { mounted: serverSkills.mounted, conflicted: serverSkills.conflicted, ...(skillReadUnavailable ? { status: 'unavailable' } : {}) }
|
|
2297
2328
|
: null;
|
|
2298
2329
|
let spawnCompleted = false;
|
|
2330
|
+
// D245 item 4: how long the runtime ran, for the reply receipt.
|
|
2331
|
+
let spawnDurationMs;
|
|
2299
2332
|
let observedSpawn = false;
|
|
2300
2333
|
let commitAttribution = null;
|
|
2301
2334
|
try {
|
|
@@ -2364,7 +2397,9 @@ export const performRun = ({
|
|
|
2364
2397
|
};
|
|
2365
2398
|
// Supervised or plain, a failure is thrown exactly as the adapter's own
|
|
2366
2399
|
// would be, so the run loop's classification and retry apply unchanged.
|
|
2400
|
+
const spawnStartedAt = Date.now();
|
|
2367
2401
|
result = await turn.run(adapter, spawnPrompt, spawnCtx);
|
|
2402
|
+
spawnDurationMs = Date.now() - spawnStartedAt;
|
|
2368
2403
|
spawnCompleted = true;
|
|
2369
2404
|
} finally {
|
|
2370
2405
|
try {
|
|
@@ -2551,6 +2586,12 @@ export const performRun = ({
|
|
|
2551
2586
|
maxChunks: maxChatChunks,
|
|
2552
2587
|
uploadName: `${agentName}-reply-${event._id}.md`,
|
|
2553
2588
|
log: (line) => log(`[${event.type}] ${line}`),
|
|
2589
|
+
// D245 item 4: the wrapper's own account of this turn.
|
|
2590
|
+
receipt: buildReplyReceipt({
|
|
2591
|
+
adapterName: adapter.name,
|
|
2592
|
+
stats: result.stats,
|
|
2593
|
+
durationMs: spawnDurationMs,
|
|
2594
|
+
}),
|
|
2554
2595
|
});
|
|
2555
2596
|
} catch (deliveryError) {
|
|
2556
2597
|
// TASK-535: every rung threw, so the reply reached nobody. The throw
|
package/src/commands/daemon.js
CHANGED
|
@@ -300,6 +300,34 @@ export const resolveAdapterForRuntime = async (runtime, registry = { getAdapter
|
|
|
300
300
|
return null;
|
|
301
301
|
};
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The daemon's first poll and heartbeat, with the Windows launcher watch armed
|
|
305
|
+
* BEFORE them (D245). Arming first puts the EPIPE guard on stdout and stderr
|
|
306
|
+
* before any supervision log line, and a launcher that is already gone stops
|
|
307
|
+
* the daemon here instead of after a tick logged into a dead pipe. Returns
|
|
308
|
+
* false when the daemon should not go on to its timers.
|
|
309
|
+
*/
|
|
310
|
+
export const firstSupervisionPass = async ({
|
|
311
|
+
supervisor,
|
|
312
|
+
onLauncherGone,
|
|
313
|
+
isEnded = () => false,
|
|
314
|
+
armLauncherWatch = watchWindowsLauncher,
|
|
315
|
+
}) => {
|
|
316
|
+
let launcherGone = false;
|
|
317
|
+
armLauncherWatch({
|
|
318
|
+
onGone: (pid) => {
|
|
319
|
+
launcherGone = true;
|
|
320
|
+
onLauncherGone(pid);
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
const stopped = () => launcherGone || isEnded();
|
|
324
|
+
if (stopped()) return false;
|
|
325
|
+
await supervisor.tick();
|
|
326
|
+
if (stopped()) return false;
|
|
327
|
+
await supervisor.heartbeat();
|
|
328
|
+
return !stopped();
|
|
329
|
+
};
|
|
330
|
+
|
|
303
331
|
export const registerDaemon = (program) => {
|
|
304
332
|
const daemon = program.command('daemon').description('Manage the local Commonly daemon');
|
|
305
333
|
|
|
@@ -654,10 +682,26 @@ Examples:
|
|
|
654
682
|
});
|
|
655
683
|
|
|
656
684
|
stampLog(`daemon supervising for ${record.machineName} — poll ${opts.poll}ms, heartbeat ${opts.heartbeat}ms (ctrl+c to stop)`);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
685
|
+
const shutdown = () => {
|
|
686
|
+
stampLog('daemon stopping — terminating supervised agents');
|
|
687
|
+
clearInterval(pollTimer);
|
|
688
|
+
clearInterval(heartbeatTimer);
|
|
689
|
+
supervisor.stop();
|
|
690
|
+
process.exit(0);
|
|
691
|
+
};
|
|
692
|
+
// D245: under the Windows launcher, the launcher's exit is the stop
|
|
693
|
+
// signal. Armed before the first tick, so the EPIPE guard covers its
|
|
694
|
+
// log lines and a launcher already gone stops the daemon at once.
|
|
695
|
+
const started = await firstSupervisionPass({
|
|
696
|
+
supervisor,
|
|
697
|
+
onLauncherGone: (pid) => {
|
|
698
|
+
// stdout and stderr already swallow EPIPE (armed with the watch).
|
|
699
|
+
try { stampLog(`launcher ${pid} has exited`); } catch { /* stdout is gone */ }
|
|
700
|
+
shutdown();
|
|
701
|
+
},
|
|
702
|
+
isEnded: () => terminalEnded,
|
|
703
|
+
});
|
|
704
|
+
if (!started) return;
|
|
661
705
|
const runtimeFailure = (error) => {
|
|
662
706
|
if (terminalEnded) return;
|
|
663
707
|
terminalEnded = true;
|
|
@@ -669,23 +713,8 @@ Examples:
|
|
|
669
713
|
};
|
|
670
714
|
pollTimer = setInterval(() => { void supervisor.tick().catch(runtimeFailure); }, Number(opts.poll) || DEFAULT_POLL_MS);
|
|
671
715
|
heartbeatTimer = setInterval(() => { void supervisor.heartbeat().catch(runtimeFailure); }, Number(opts.heartbeat) || DEFAULT_HEARTBEAT_MS);
|
|
672
|
-
const shutdown = () => {
|
|
673
|
-
stampLog('daemon stopping — terminating supervised agents');
|
|
674
|
-
clearInterval(pollTimer);
|
|
675
|
-
clearInterval(heartbeatTimer);
|
|
676
|
-
supervisor.stop();
|
|
677
|
-
process.exit(0);
|
|
678
|
-
};
|
|
679
716
|
process.on('SIGINT', shutdown);
|
|
680
717
|
process.on('SIGTERM', shutdown);
|
|
681
|
-
// D245: under the Windows launcher, the launcher's exit is the stop signal.
|
|
682
|
-
watchWindowsLauncher({
|
|
683
|
-
onGone: (pid) => {
|
|
684
|
-
// stdout and stderr already swallow EPIPE (armed with the watch).
|
|
685
|
-
try { stampLog(`launcher ${pid} has exited`); } catch { /* stdout is gone */ }
|
|
686
|
-
shutdown();
|
|
687
|
-
},
|
|
688
|
-
});
|
|
689
718
|
} catch (error) {
|
|
690
719
|
console.error(`Daemon run failed: ${error.message}`);
|
|
691
720
|
const badConfig = !record || error.message.startsWith('Daemon credential file');
|
|
@@ -53,6 +53,7 @@ import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/t
|
|
|
53
53
|
*/
|
|
54
54
|
|
|
55
55
|
import { requireSeatModel, seatModelRequired } from './seat-model.js';
|
|
56
|
+
import { claudeRunStats } from '../reply-receipt.js';
|
|
56
57
|
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
57
58
|
import { createHash, randomUUID } from 'crypto';
|
|
58
59
|
import { accessSync, constants, realpathSync } from 'fs';
|
|
@@ -83,6 +84,7 @@ import {
|
|
|
83
84
|
} from '../environment.js';
|
|
84
85
|
import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
|
|
85
86
|
import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from '../sandbox/mode.js';
|
|
87
|
+
import { UNSANDBOXED_FLAG } from '../sandbox/host-check.js';
|
|
86
88
|
import {
|
|
87
89
|
publicClaudeStateRoot,
|
|
88
90
|
wrapArgvWithSeatbelt,
|
|
@@ -1153,6 +1155,16 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
1153
1155
|
// unconfined seat with a public record, which is how a record can claim
|
|
1154
1156
|
// confinement it never applies.
|
|
1155
1157
|
if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
|
|
1158
|
+
// D245: on native Windows, name Windows and the user's own opt-in, never
|
|
1159
|
+
// macOS or bwrap advice (host-check passes an unrecognised mode through).
|
|
1160
|
+
if ((ctx._platform || process.platform) === 'win32') {
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
`${sandboxTrust} Claude agents run inside a sandbox, and Commonly has no sandbox it can enforce on Windows `
|
|
1163
|
+
+ `(got sandbox.mode=${JSON.stringify(sandboxMode)}). To run this agent without one, which is your choice, `
|
|
1164
|
+
+ `run commonly agent run <name> ${UNSANDBOXED_FLAG} (or commonly daemon install ${UNSANDBOXED_FLAG} for the agents `
|
|
1165
|
+
+ 'this computer\'s daemon runs), or choose "Run it here" on the Connect page for a hosted agent.',
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1156
1168
|
throw new Error(
|
|
1157
1169
|
`${sandboxTrust} Claude agents require an enforced sandbox mode `
|
|
1158
1170
|
+ `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`,
|
|
@@ -1521,6 +1533,8 @@ export default {
|
|
|
1521
1533
|
text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }),
|
|
1522
1534
|
newSessionId: sessionId,
|
|
1523
1535
|
contextTokens,
|
|
1536
|
+
// D245 item 4: what the run said about itself, for the reply receipt.
|
|
1537
|
+
stats: claudeRunStats(stdout),
|
|
1524
1538
|
};
|
|
1525
1539
|
} catch (err) {
|
|
1526
1540
|
// A public seat's refusal is not a session-store failure, whatever its
|
|
@@ -1591,6 +1605,7 @@ export default {
|
|
|
1591
1605
|
text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }),
|
|
1592
1606
|
newSessionId: freshId,
|
|
1593
1607
|
contextTokens,
|
|
1608
|
+
stats: claudeRunStats(stdout),
|
|
1594
1609
|
};
|
|
1595
1610
|
}
|
|
1596
1611
|
throw err;
|
|
@@ -201,7 +201,8 @@ const pidAlive = (pid) => {
|
|
|
201
201
|
* keep running, unsupervised and invisible to the task (seen on windows-latest,
|
|
202
202
|
* 11-04: a stopped task left its daemon checking in). A daemon started by the
|
|
203
203
|
* launcher therefore polls the launcher's pid and calls `onGone` once it has
|
|
204
|
-
* exited
|
|
204
|
+
* exited, at once if it is already gone when the watch is armed. Returns a
|
|
205
|
+
* stop function; does nothing outside the launcher.
|
|
205
206
|
*/
|
|
206
207
|
export const watchWindowsLauncher = ({
|
|
207
208
|
env = process.env,
|
|
@@ -221,6 +222,12 @@ export const watchWindowsLauncher = ({
|
|
|
221
222
|
// swallow errors from the moment the watch is armed, not only after the
|
|
222
223
|
// launcher's exit is noticed up to two seconds later.
|
|
223
224
|
for (const stream of streams) stream?.on?.('error', () => {});
|
|
225
|
+
// Already gone when armed (the daemon is armed before its first tick): say
|
|
226
|
+
// so now, rather than after a poll and a heartbeat logged into a dead pipe.
|
|
227
|
+
if (!isAlive(pid)) {
|
|
228
|
+
onGone(pid);
|
|
229
|
+
return () => {};
|
|
230
|
+
}
|
|
224
231
|
const timer = setIntervalFn(() => {
|
|
225
232
|
if (isAlive(pid)) return;
|
|
226
233
|
clearIntervalFn(timer);
|
package/src/lib/enforcement.js
CHANGED
|
@@ -668,6 +668,9 @@ export const deliverChatReply = async ({
|
|
|
668
668
|
attachThreshold = 800,
|
|
669
669
|
uploadName = 'reply.md',
|
|
670
670
|
log = () => {},
|
|
671
|
+
// D245 item 4: how this reply was made (lib/reply-receipt.js). It rides
|
|
672
|
+
// the first message that actually reaches the pod and no later one.
|
|
673
|
+
receipt = null,
|
|
671
674
|
}) => {
|
|
672
675
|
const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
|
|
673
676
|
const chunks = splitForChat(text, { limit });
|
|
@@ -697,7 +700,14 @@ export const deliverChatReply = async ({
|
|
|
697
700
|
// override per delivery; a post that still fails throws to the next rung as
|
|
698
701
|
// before. Nothing here loops.
|
|
699
702
|
let freshnessOverride = false;
|
|
700
|
-
|
|
703
|
+
let receiptDelivered = !receipt;
|
|
704
|
+
const postOnce = async (rawBody) => {
|
|
705
|
+
const body = receiptDelivered ? rawBody : { ...rawBody, receipt };
|
|
706
|
+
const response = await postGuarded(body);
|
|
707
|
+
if (response?.refused !== true && response?.skipped !== true) receiptDelivered = true;
|
|
708
|
+
return response;
|
|
709
|
+
};
|
|
710
|
+
const postGuarded = async (body) => {
|
|
701
711
|
if (freshnessOverride) return client.post(messagesPath, { ...body, continueAnyway: true });
|
|
702
712
|
try {
|
|
703
713
|
return await client.post(messagesPath, body);
|
|
@@ -774,7 +784,7 @@ export const deliverChatReply = async ({
|
|
|
774
784
|
// the gate (found by the fleet's implementation audit).
|
|
775
785
|
const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
|
|
776
786
|
if (chunks.length <= 1 && !hasIndivisibleOversize) {
|
|
777
|
-
const response = await
|
|
787
|
+
const response = await postOnce({ content: chunks[0] ?? text });
|
|
778
788
|
if (undelivered(response)) return nonDelivery(response, 0, 1);
|
|
779
789
|
return { mode: 'single', messages: 1 };
|
|
780
790
|
}
|
|
@@ -782,7 +792,7 @@ export const deliverChatReply = async ({
|
|
|
782
792
|
let messages = 0;
|
|
783
793
|
for (const chunk of chunks) {
|
|
784
794
|
// eslint-disable-next-line no-await-in-loop
|
|
785
|
-
const response = await
|
|
795
|
+
const response = await postOnce({ content: chunk }); // in order, so the reply reads top-down
|
|
786
796
|
if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
|
|
787
797
|
messages += 1;
|
|
788
798
|
}
|
|
@@ -802,7 +812,7 @@ export const deliverChatReply = async ({
|
|
|
802
812
|
// fail at the root-id step before any continuation has posted.
|
|
803
813
|
let posted = 0;
|
|
804
814
|
try {
|
|
805
|
-
const rootRes = await
|
|
815
|
+
const rootRes = await postOnce({ content: chunks[0] });
|
|
806
816
|
if (undelivered(rootRes)) return nonDelivery(rootRes, 0, chunks.length);
|
|
807
817
|
posted = 1;
|
|
808
818
|
// The runtime route answers `res.json(result)` with the created row on
|
|
@@ -814,7 +824,7 @@ export const deliverChatReply = async ({
|
|
|
814
824
|
if (!rootId) throw new Error('no message id in post response — cannot root the thread');
|
|
815
825
|
for (const chunk of chunks.slice(1)) {
|
|
816
826
|
// eslint-disable-next-line no-await-in-loop
|
|
817
|
-
const response = await
|
|
827
|
+
const response = await postOnce({ content: chunk, threadRootId: String(rootId) });
|
|
818
828
|
if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
|
|
819
829
|
posted += 1;
|
|
820
830
|
}
|
|
@@ -829,7 +839,7 @@ export const deliverChatReply = async ({
|
|
|
829
839
|
log(`thread continuation failed (${err.message}) — posting the remainder top-level`);
|
|
830
840
|
for (const chunk of chunks.slice(posted)) {
|
|
831
841
|
// eslint-disable-next-line no-await-in-loop
|
|
832
|
-
const response = await
|
|
842
|
+
const response = await postOnce({ content: chunk });
|
|
833
843
|
if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
|
|
834
844
|
posted += 1;
|
|
835
845
|
}
|
|
@@ -855,7 +865,7 @@ export const deliverChatReply = async ({
|
|
|
855
865
|
const lead = chunks[0] && chunks[0].length <= limit
|
|
856
866
|
? chunks[0]
|
|
857
867
|
: '(reply too large for chat — attached in full)';
|
|
858
|
-
const response = await
|
|
868
|
+
const response = await postOnce({ content: `${lead}\n\n${directive}` });
|
|
859
869
|
if (undelivered(response)) return nonDelivery(response, 0, 1);
|
|
860
870
|
return { mode: 'attach', messages: 1 };
|
|
861
871
|
} catch (err) {
|
|
@@ -863,7 +873,7 @@ export const deliverChatReply = async ({
|
|
|
863
873
|
let messages = 0;
|
|
864
874
|
for (const chunk of chunks) {
|
|
865
875
|
// eslint-disable-next-line no-await-in-loop
|
|
866
|
-
const response = await
|
|
876
|
+
const response = await postOnce({ content: chunk });
|
|
867
877
|
if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
|
|
868
878
|
messages += 1;
|
|
869
879
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// D245 item 4: the receipt the wrapper posts with a reply, so a BYO reply
|
|
2
|
+
// carries "how this reply was made" the way a hosted reply has its run trace.
|
|
3
|
+
// It is the wrapper's own account and the server stores it as self-reported
|
|
4
|
+
// (backend/services/byoReceipt.ts), which also bounds and checks every field.
|
|
5
|
+
// This side keeps to what it actually observed: an adapter that reports no
|
|
6
|
+
// stats sends only its name and the measured duration.
|
|
7
|
+
|
|
8
|
+
const isName = (value) => typeof value === 'string' && value.length > 0;
|
|
9
|
+
const isCount = (value) => Number.isInteger(value) && value >= 0;
|
|
10
|
+
|
|
11
|
+
const parseLine = (raw) => {
|
|
12
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a Claude Code stream-json run says about itself: the model from the
|
|
17
|
+
* init event, the turn count from the result event, and each tool it called,
|
|
18
|
+
* once, in first-use order.
|
|
19
|
+
*/
|
|
20
|
+
export const claudeRunStats = (stdout) => {
|
|
21
|
+
const events = String(stdout || '').split('\n').map(parseLine).filter(Boolean);
|
|
22
|
+
const stats = {};
|
|
23
|
+
const init = events.find((event) => event.type === 'system' && event.subtype === 'init');
|
|
24
|
+
if (isName(init?.model)) stats.model = init.model;
|
|
25
|
+
const result = [...events].reverse().find((event) => event.type === 'result');
|
|
26
|
+
if (isCount(result?.num_turns)) stats.turns = result.num_turns;
|
|
27
|
+
const tools = [];
|
|
28
|
+
for (const event of events) {
|
|
29
|
+
if (event.type !== 'assistant') continue;
|
|
30
|
+
for (const block of event.message?.content || []) {
|
|
31
|
+
if (block?.type === 'tool_use' && isName(block.name) && !tools.includes(block.name)) tools.push(block.name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (tools.length) stats.tools = tools;
|
|
35
|
+
return stats;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The receipt for one reply, or null when there is no adapter to name. */
|
|
39
|
+
export const buildReplyReceipt = ({ adapterName, stats = {}, durationMs } = {}) => {
|
|
40
|
+
if (!isName(adapterName)) return null;
|
|
41
|
+
const receipt = { adapter: adapterName };
|
|
42
|
+
// A cloud-hosted model can be named by an ARN or a resource path that
|
|
43
|
+
// carries the account or project it runs in; that is not this reply's to
|
|
44
|
+
// publish, so such a model id is left out.
|
|
45
|
+
if (isName(stats?.model) && !/^arn:|(^|\/)projects\//i.test(stats.model)) receipt.model = stats.model;
|
|
46
|
+
if (isCount(stats?.turns)) receipt.turns = stats.turns;
|
|
47
|
+
if (Array.isArray(stats?.tools)) {
|
|
48
|
+
const tools = stats.tools.filter(isName);
|
|
49
|
+
if (tools.length) receipt.tools = tools;
|
|
50
|
+
}
|
|
51
|
+
if (Number.isFinite(durationMs) && durationMs >= 0) receipt.durationMs = Math.round(durationMs);
|
|
52
|
+
return receipt;
|
|
53
|
+
};
|
|
@@ -41,6 +41,7 @@ import { homedir } from 'os';
|
|
|
41
41
|
import { join } from 'path';
|
|
42
42
|
|
|
43
43
|
import { writeServiceFile } from '../daemon-service.js';
|
|
44
|
+
import { effectiveSandboxTrust } from '../environment.js';
|
|
44
45
|
import { UNSANDBOXED_FLAG } from './host-check.js';
|
|
45
46
|
|
|
46
47
|
export { UNSANDBOXED_FLAG };
|
|
@@ -100,7 +101,9 @@ export const POLICY_ONLY_ADAPTERS = new Set(['claude']);
|
|
|
100
101
|
export const hostUnconfinedForRun = ({
|
|
101
102
|
optedIn, adapterName, environment, platform = process.platform,
|
|
102
103
|
}) => {
|
|
103
|
-
|
|
104
|
+
// The effective trust, as host-check.js and the claude adapter read it: a
|
|
105
|
+
// legacy `internal` record means `public`, so it gets the same opt-in.
|
|
106
|
+
const trust = effectiveSandboxTrust(environment?.sandbox?.trust);
|
|
104
107
|
return platform === 'win32' && optedIn === true && POLICY_ONLY_ADAPTERS.has(adapterName)
|
|
105
108
|
&& (trust === 'public' || trust === 'workspace');
|
|
106
109
|
};
|