@agenthippo-ai/agenthippo 1.110.9 → 1.110.11
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/out/agentpack/index.js +3 -1
- package/out/agentpack/paths.js +25 -2
- package/out/analytics/dashboard/localFleetAdapter.js +82 -2
- package/out/analytics/service.js +6 -0
- package/out/analytics/sessionEvents.js +4 -3
- package/out/auth-broker/server.js +23 -19
- package/out/cli/commands/data-plane.js +84 -0
- package/out/cli/commands/sandbox.js +28 -9
- package/out/cli/commands/serve.js +289 -97
- package/out/cli/credential-context.js +17 -0
- package/out/cli/data-plane/index.js +2 -1
- package/out/cli/data-plane/sidecar.js +66 -2
- package/out/cli/deploy/layers/identity.js +5 -2
- package/out/cli/deploy-init.js +4 -4
- package/out/cli/index.js +16 -1
- package/out/cli/serve-hardening.js +181 -12
- package/out/engines/claude.js +1 -1
- package/out/engines/codex.js +1 -1
- package/out/engines/custom.js +7 -1
- package/out/evidence/sessionShip.js +50 -17
- package/out/headless/index.js +8 -1
- package/out/pack/governor.js +32 -15
- package/out/pack/lease-schema.js +4 -1
- package/out/pack/lease.js +42 -0
- package/out/runner/host.js +24 -14
- package/out/runner/protocol.js +24 -4
- package/out/runner/remoteEngine.js +53 -17
- package/out/sandbox/containerDriver.js +4 -2
- package/out/sandbox/processDriver.js +4 -1
- package/out/sandbox/sandboxRunner.js +148 -36
- package/out/scripts/agentide_litellm_hooks_v1.py +85 -1
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +4 -0
- package/out/scripts/analytics/setup-analytics.ps1 +8 -1
- package/out/scripts/analytics/setup-analytics.sh +13 -2
- package/out/test/caps-governor.test.js +6 -1
- package/out/test/data-plane-engine-wiring.test.js +32 -0
- package/out/test/data-plane.test.js +313 -0
- package/out/test/deploy-init.test.js +7 -1
- package/out/test/governor-hardening.test.js +6 -1
- package/out/test/lease-validation.test.js +55 -0
- package/out/test/localFleetAdapter.test.js +91 -0
- package/out/test/sandbox-driver.test.js +2 -2
- package/out/test/sandbox-hardening.test.js +12 -1
- package/out/test/sandbox-runner.test.js +1 -1
- package/out/test/sandbox-scope.test.js +449 -0
- package/out/test/serve-cron-acceptance.test.js +370 -0
- package/out/test/serve-gateway-turn.test.js +122 -0
- package/out/test/serve-hardening.test.js +182 -9
- package/out/test/sessionShip.test.js +58 -0
- package/package.json +1 -1
|
@@ -95,6 +95,10 @@ function setStatus(patch) {
|
|
|
95
95
|
function isTruthy(value) {
|
|
96
96
|
return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
|
|
97
97
|
}
|
|
98
|
+
/** evidence-ship reads EVIDENCE_ALLOW_INSECURE_URL; serve documents AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL. */
|
|
99
|
+
function resolveAllowInsecure(env) {
|
|
100
|
+
return isTruthy(env[ALLOW_INSECURE_ENV]) || isTruthy(env.EVIDENCE_ALLOW_INSECURE_URL);
|
|
101
|
+
}
|
|
98
102
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
|
|
99
103
|
/**
|
|
100
104
|
* Validate the ingest URL.
|
|
@@ -120,16 +124,19 @@ function validateEvidenceUrl(raw, env = process.env) {
|
|
|
120
124
|
if (LOOPBACK_HOSTS.has(parsed.hostname)) {
|
|
121
125
|
return { url: parsed.toString().replace(/\/$/, '') };
|
|
122
126
|
}
|
|
123
|
-
if (
|
|
127
|
+
if (resolveAllowInsecure(env)) {
|
|
128
|
+
const reason = isTruthy(env[ALLOW_INSECURE_ENV])
|
|
129
|
+
? ALLOW_INSECURE_ENV
|
|
130
|
+
: 'EVIDENCE_ALLOW_INSECURE_URL';
|
|
124
131
|
console.warn(`[evidence-ship] INSECURE: shipping evidence and the ingest token over plaintext http ` +
|
|
125
|
-
`to ${parsed.host} because ${
|
|
132
|
+
`to ${parsed.host} because ${reason} is set. Use https unless this network ` +
|
|
126
133
|
`is genuinely private.`);
|
|
127
134
|
return { url: parsed.toString().replace(/\/$/, '') };
|
|
128
135
|
}
|
|
129
136
|
return {
|
|
130
137
|
error: `${URL_ENV} must use https — plaintext http to ${parsed.host} would put the ingest ` +
|
|
131
138
|
`token and the audit trail on the wire. Use https, a loopback address, or set ` +
|
|
132
|
-
`${ALLOW_INSECURE_ENV}=1 if the network is genuinely private.`,
|
|
139
|
+
`${ALLOW_INSECURE_ENV}=1 (or EVIDENCE_ALLOW_INSECURE_URL=1) if the network is genuinely private.`,
|
|
133
140
|
};
|
|
134
141
|
}
|
|
135
142
|
/** Resolve config from env; returns undefined when shipping is disabled or misconfigured. */
|
|
@@ -180,6 +187,7 @@ function resolveSessionShipConfig(instanceId, sessionsDir, stateDir, env = proce
|
|
|
180
187
|
intervalSec,
|
|
181
188
|
sinceDays,
|
|
182
189
|
drainTimeoutSec,
|
|
190
|
+
allowInsecure: resolveAllowInsecure(env),
|
|
183
191
|
};
|
|
184
192
|
}
|
|
185
193
|
// -----------------------------------------------------------------------------
|
|
@@ -262,6 +270,7 @@ function startSessionShipper(config) {
|
|
|
262
270
|
const intervalSec = config.intervalSec ?? 60;
|
|
263
271
|
const drainTimeoutMs = (config.drainTimeoutSec ?? DEFAULT_DRAIN_TIMEOUT_SEC) * 1000;
|
|
264
272
|
const captureContent = (0, identity_1.isContentCaptureEnabled)();
|
|
273
|
+
const allowInsecure = config.allowInsecure === true;
|
|
265
274
|
let stopped = false;
|
|
266
275
|
let inFlight = false;
|
|
267
276
|
let lastOkAt;
|
|
@@ -299,6 +308,10 @@ function startSessionShipper(config) {
|
|
|
299
308
|
// is safe — but it would also strip content on a deployment that deliberately
|
|
300
309
|
// opted in, turning the opt-in into a no-op. Pass our own decision through.
|
|
301
310
|
captureContent,
|
|
311
|
+
// Compose stacks use http://evidence:8787 on a private network. serve accepts that
|
|
312
|
+
// via AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL; evidence-ship needs allowInsecure here
|
|
313
|
+
// (or EVIDENCE_ALLOW_INSECURE_URL in the process env).
|
|
314
|
+
allowInsecure,
|
|
302
315
|
// Heartbeat is posted here instead: the upstream one is the literal '{}', which
|
|
303
316
|
// proves the process was alive and nothing about whether records are complete.
|
|
304
317
|
heartbeat: false,
|
|
@@ -449,7 +462,14 @@ function startSessionShipper(config) {
|
|
|
449
462
|
}
|
|
450
463
|
if (losses.length > 0) {
|
|
451
464
|
// Appended, never truncated: a loss record must outlive the tick that noticed it.
|
|
452
|
-
|
|
465
|
+
//
|
|
466
|
+
// The NAME matters and cannot be `_evidence-loss.jsonl`: @agenthippo-ai/evidence-ship's
|
|
467
|
+
// shipSessions() skips every file whose name starts with `_evidence-loss` (it posts its
|
|
468
|
+
// OWN losses under that name from its cursor, and refuses to re-ingest one found on
|
|
469
|
+
// disk). Staging ours under that name made it a dead letter — the warning printed, the
|
|
470
|
+
// counters incremented, and the record never left the machine, which is precisely the
|
|
471
|
+
// silent gap this feature exists to close.
|
|
472
|
+
const lossFile = path.join(stagingDir, 'evidence-loss.jsonl');
|
|
453
473
|
await fs_1.promises.appendFile(lossFile, `${losses.join('\n')}\n`);
|
|
454
474
|
const seconds = Date.now() / 1000;
|
|
455
475
|
await fs_1.promises.utimes(lossFile, seconds, seconds);
|
|
@@ -532,28 +552,41 @@ function startSessionShipper(config) {
|
|
|
532
552
|
try {
|
|
533
553
|
const staged = await stage();
|
|
534
554
|
unshippedRecords += staged;
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
555
|
+
try {
|
|
556
|
+
const shipper = await getShipper();
|
|
557
|
+
const ok = await shipper.drain({ sessions: stagingDir, heartbeat: false });
|
|
558
|
+
if (ok) {
|
|
559
|
+
const state = await loadCursor();
|
|
560
|
+
state.pending.shipped += unshippedRecords;
|
|
561
|
+
totals.shipped += unshippedRecords;
|
|
562
|
+
unshippedRecords = 0;
|
|
563
|
+
lastOkAt = Date.now();
|
|
564
|
+
consecutiveFailures = 0;
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
consecutiveFailures++;
|
|
568
|
+
warn(`[evidence-ship] drain failed (${consecutiveFailures} in a row); ` +
|
|
569
|
+
`${unshippedRecords} record(s) still unshipped`);
|
|
570
|
+
}
|
|
544
571
|
}
|
|
545
|
-
|
|
572
|
+
catch (shipErr) {
|
|
546
573
|
consecutiveFailures++;
|
|
547
|
-
warn(`[evidence-ship]
|
|
548
|
-
`${unshippedRecords} record(s) still unshipped`);
|
|
574
|
+
warn(`[evidence-ship] ${shipErr instanceof Error ? shipErr.message : String(shipErr)}`);
|
|
549
575
|
}
|
|
550
|
-
await postHeartbeat();
|
|
551
576
|
}
|
|
552
577
|
catch (err) {
|
|
553
578
|
consecutiveFailures++;
|
|
554
579
|
warn(`[evidence-ship] ${err instanceof Error ? err.message : String(err)}`);
|
|
555
580
|
}
|
|
556
581
|
finally {
|
|
582
|
+
// R-13: heartbeat runs every tick even when drain/shipper fails, so gap detection
|
|
583
|
+
// sees window bounds and staged-vs-shipped counts instead of silence.
|
|
584
|
+
try {
|
|
585
|
+
await postHeartbeat();
|
|
586
|
+
}
|
|
587
|
+
catch (hbErr) {
|
|
588
|
+
warn(`[evidence-ship] heartbeat failed: ${hbErr instanceof Error ? hbErr.message : String(hbErr)}`);
|
|
589
|
+
}
|
|
557
590
|
inFlight = false;
|
|
558
591
|
setStatus({
|
|
559
592
|
lastOkAt,
|
package/out/headless/index.js
CHANGED
|
@@ -373,7 +373,11 @@ function createHeadlessRunner(config) {
|
|
|
373
373
|
// the key for the engine's destination — passing the collapsed key here would let
|
|
374
374
|
// it win over the correct provider key (the original bug). Back-compat single-key
|
|
375
375
|
// callers keep options.apiKey.
|
|
376
|
-
|
|
376
|
+
//
|
|
377
|
+
// A per-run `modelToken` outranks both. That is safe precisely where it is used:
|
|
378
|
+
// inside a sandbox there are no provider keys to lose a precedence fight with
|
|
379
|
+
// (the credential invariant), and the warrant IS the only route to a model.
|
|
380
|
+
apiKey: options.modelToken ?? (hasSplitKeys ? undefined : config.apiKey),
|
|
377
381
|
baseUrl: config.baseUrl,
|
|
378
382
|
useLiteLLM: config.useLiteLLM,
|
|
379
383
|
agentPackId: packId,
|
|
@@ -384,6 +388,9 @@ function createHeadlessRunner(config) {
|
|
|
384
388
|
tier: provenance?.tier,
|
|
385
389
|
instanceId: provenance?.instanceId,
|
|
386
390
|
permissions: options.permissions,
|
|
391
|
+
// Only ever set by a sandbox child. In serve's own process the engine reads the
|
|
392
|
+
// credential frame directly and this is undefined.
|
|
393
|
+
engineHomeUserKey: options.engineHomeUserKey,
|
|
387
394
|
requestId, // Same requestId used by ObservableStreamEmitter for Layer 1/2 correlation
|
|
388
395
|
};
|
|
389
396
|
// Emit turn_start event
|
package/out/pack/governor.js
CHANGED
|
@@ -98,7 +98,7 @@ function createGovernor(opts) {
|
|
|
98
98
|
const killAll = async (reason) => {
|
|
99
99
|
const live = opts.sandboxes.sandboxes();
|
|
100
100
|
for (const c of live) {
|
|
101
|
-
await opts.sandboxes.destroySession(c.
|
|
101
|
+
await opts.sandboxes.destroySession(c.sandboxKey).catch(() => undefined);
|
|
102
102
|
// The meter deliberately SURVIVES the kill, for exactly the reason `enforce` gives when
|
|
103
103
|
// it destroys a sandbox on a cap breach: a cap that renews when the sandbox goes away is
|
|
104
104
|
// not a cap. `meter.forget` here meant a kill-then-restore (a billing hold lifted, a
|
|
@@ -159,22 +159,39 @@ function createGovernor(opts) {
|
|
|
159
159
|
// `kill` rules end work already running. `drain` deliberately does not — it lets the
|
|
160
160
|
// in-flight turn finish and only refuses the next one.
|
|
161
161
|
for (const sandbox of opts.sandboxes.sandboxes()) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
162
|
+
// Per SESSION, not per sandbox: under user scope one sandbox carries several, and a
|
|
163
|
+
// rule naming any one of them ends the sandbox they share.
|
|
164
|
+
const matched = sandbox.sessionKeys
|
|
165
|
+
.map((sessionKey) => ({
|
|
166
|
+
sessionKey,
|
|
167
|
+
v: (0, lease_1.denyVerdict)(lease, {
|
|
168
|
+
sessionKey,
|
|
169
|
+
pack: opts.pack ?? '',
|
|
170
|
+
userHash: opts.userHash,
|
|
171
|
+
// Remembered from this session's turns, so a `user`-scoped kill reaches a
|
|
172
|
+
// gateway session whose key carries no `u<hash>--` prefix to read it off.
|
|
173
|
+
userId: sessionUsers.get(sessionKey),
|
|
174
|
+
followedUsers,
|
|
175
|
+
}),
|
|
176
|
+
}))
|
|
177
|
+
.find(({ v }) => v.denied && effectiveDenyMode(v.mode, canTerminateInFlight) === 'kill');
|
|
178
|
+
if (matched) {
|
|
179
|
+
await opts.sandboxes.destroySession(sandbox.sandboxKey).catch(() => undefined);
|
|
180
|
+
for (const sessionKey of sandbox.sessionKeys) {
|
|
181
|
+
sessionUsers.delete(sessionKey);
|
|
182
|
+
}
|
|
174
183
|
// The meter survives, same rule as `killAll` and `enforce`: a stop order must not
|
|
175
184
|
// hand the session a fresh budget when it is lifted.
|
|
176
|
-
opts.logger?.warn(`[Governor] deny(kill) destroyed ${sandbox.sandboxId} — ${v.reason}`);
|
|
177
|
-
|
|
185
|
+
opts.logger?.warn(`[Governor] deny(kill) destroyed ${sandbox.sandboxId} — ${matched.v.reason}`);
|
|
186
|
+
// The sessions that were NOT named are recorded too: under user scope a rule
|
|
187
|
+
// aimed at one conversation stops the others sharing its sandbox, and an
|
|
188
|
+
// operator reading the audit must be able to see that.
|
|
189
|
+
audit('session_killed', {
|
|
190
|
+
sessionKey: matched.sessionKey,
|
|
191
|
+
sandboxId: sandbox.sandboxId,
|
|
192
|
+
reason: matched.v.reason,
|
|
193
|
+
collateralSessions: sandbox.sessionKeys.filter((s) => s !== matched.sessionKey),
|
|
194
|
+
});
|
|
178
195
|
}
|
|
179
196
|
}
|
|
180
197
|
const verdict = (0, lease_1.leaseIsDead)(lease);
|
package/out/pack/lease-schema.js
CHANGED
|
@@ -233,7 +233,10 @@ function parseLease(raw) {
|
|
|
233
233
|
}
|
|
234
234
|
lease[key] = v;
|
|
235
235
|
}
|
|
236
|
-
|
|
236
|
+
// Strict booleans, never coerced — `"false"` is truthy in JavaScript, and for every flag here
|
|
237
|
+
// that would flip the safe answer to the unsafe one. `allowServiceTurns` joins the same loop
|
|
238
|
+
// so it inherits that property for free (PLAN/agent-identity.md §5.6).
|
|
239
|
+
for (const key of ['requireSignedPack', 'requireSignedLease', 'allowServiceTurns']) {
|
|
237
240
|
const v = raw[key];
|
|
238
241
|
if (v === undefined || v === null) {
|
|
239
242
|
continue;
|
package/out/pack/lease.js
CHANGED
|
@@ -8,6 +8,8 @@ exports.resolveLeasePollSeconds = resolveLeasePollSeconds;
|
|
|
8
8
|
exports.leaseCacheSecondsFor = leaseCacheSecondsFor;
|
|
9
9
|
exports.effectiveKillLatencySeconds = effectiveKillLatencySeconds;
|
|
10
10
|
exports.leaseMaxStalenessFromEnv = leaseMaxStalenessFromEnv;
|
|
11
|
+
exports.resolveAllowServiceTurns = resolveAllowServiceTurns;
|
|
12
|
+
exports.describeServiceTurnGate = describeServiceTurnGate;
|
|
11
13
|
exports.denyVerdict = denyVerdict;
|
|
12
14
|
exports.resolveFollowedUsers = resolveFollowedUsers;
|
|
13
15
|
exports.leaseIsDead = leaseIsDead;
|
|
@@ -104,6 +106,46 @@ function leaseMaxStalenessFromEnv(env = process.env) {
|
|
|
104
106
|
}
|
|
105
107
|
return Math.floor(n);
|
|
106
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* May this deployment run turns with no human behind them (PLAN/agent-identity.md §5.6)?
|
|
111
|
+
*
|
|
112
|
+
* **Polarity note, because it is the opposite of every other flag here.** `requireSignedPack` and
|
|
113
|
+
* friends are TIGHTEN-ONLY: absent means "nobody asked for the restriction", so absent is
|
|
114
|
+
* permissive and that is safe. This flag GRANTS a standing unattended identity, so the same
|
|
115
|
+
* treatment would be exactly backwards — a control plane that has not been taught the field would
|
|
116
|
+
* silently authorise unsupervised runs across the fleet. Absent therefore means DENY wherever a
|
|
117
|
+
* control plane exists to have said otherwise.
|
|
118
|
+
*
|
|
119
|
+
* Unmanaged deployments (no lease URL configured at all) keep the existing local-first philosophy:
|
|
120
|
+
* there is no control plane to ask, so the operator's env is the whole answer. serve says so at
|
|
121
|
+
* boot rather than leaving it implicit.
|
|
122
|
+
*/
|
|
123
|
+
function resolveAllowServiceTurns(opts) {
|
|
124
|
+
if (!opts.managed) {
|
|
125
|
+
return { allowed: true, gate: 'unmanaged_no_lease' };
|
|
126
|
+
}
|
|
127
|
+
if (opts.lease === true) {
|
|
128
|
+
return { allowed: true, gate: 'lease_allows' };
|
|
129
|
+
}
|
|
130
|
+
// `undefined` and `false` are deliberately NOT distinguished in the outcome, only in the
|
|
131
|
+
// reason: both deny, so a control plane too old to emit the field is exactly as safe as one
|
|
132
|
+
// that emitted `false`.
|
|
133
|
+
return { allowed: false, gate: opts.lease === false ? 'lease_denies' : 'lease_absent_field' };
|
|
134
|
+
}
|
|
135
|
+
/** Operator-facing explanation for a `service_turn_denied` audit row and the boot banner. */
|
|
136
|
+
function describeServiceTurnGate(gate) {
|
|
137
|
+
switch (gate) {
|
|
138
|
+
case 'lease_allows':
|
|
139
|
+
return 'permitted by the control plane (lease.allowServiceTurns)';
|
|
140
|
+
case 'unmanaged_no_lease':
|
|
141
|
+
return 'permitted: no control plane configured, so AGENTHIPPO_SERVICE_USER is the only gate';
|
|
142
|
+
case 'lease_denies':
|
|
143
|
+
return 'refused: the control plane set lease.allowServiceTurns=false';
|
|
144
|
+
case 'lease_absent_field':
|
|
145
|
+
return 'refused: this is a managed deployment and the lease does not set allowServiceTurns '
|
|
146
|
+
+ '(absent means deny — enable it in the console)';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
107
149
|
/**
|
|
108
150
|
* Does any live rule name this session?
|
|
109
151
|
*
|
package/out/runner/host.js
CHANGED
|
@@ -78,7 +78,7 @@ function startRunnerHost(opts) {
|
|
|
78
78
|
}
|
|
79
79
|
const controller = new AbortController();
|
|
80
80
|
inflight.set(id, controller);
|
|
81
|
-
const emitter = makeEmitter(send);
|
|
81
|
+
const emitter = makeEmitter(send, id);
|
|
82
82
|
try {
|
|
83
83
|
// `options` crossed the boundary already scrubbed of apiKey (see toWireOptions); the
|
|
84
84
|
// engine reaches models via baseUrl + modelToken, both sandbox-scoped.
|
|
@@ -96,29 +96,39 @@ function startRunnerHost(opts) {
|
|
|
96
96
|
inflight.delete(id);
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
-
// The runner is live once the engine is constructed and the loop is wired.
|
|
100
|
-
|
|
99
|
+
// The runner is live once the engine is constructed and the loop is wired. `demux: true` tells
|
|
100
|
+
// serve this host tags streamed events with their run id, so it may run several turns here at
|
|
101
|
+
// once; a serve talking to an older image never sees it and serializes instead.
|
|
102
|
+
send({ type: 'ready', demux: true });
|
|
101
103
|
return { done, close: () => finish() };
|
|
102
104
|
}
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Bridges `StreamEmitter` calls onto the wire, 1:1 with the existing worker event union.
|
|
107
|
+
*
|
|
108
|
+
* Every event is tagged with the run it came from. One sandbox may host several of a user's
|
|
109
|
+
* sessions, and an untagged event can only be broadcast to every in-flight turn — which wrote one
|
|
110
|
+
* session's tokens and tool calls into another's transcript.
|
|
111
|
+
*/
|
|
112
|
+
function makeEmitter(send, id) {
|
|
113
|
+
const emit = (event) => send({ id, event });
|
|
105
114
|
return {
|
|
106
|
-
async thinking(delta) {
|
|
107
|
-
async text(delta) {
|
|
115
|
+
async thinking(delta) { emit({ type: 'thinking', delta }); },
|
|
116
|
+
async text(delta) { emit({ type: 'text', delta }); },
|
|
108
117
|
async toolStart(toolName, input, toolCallId) {
|
|
109
|
-
|
|
118
|
+
emit({ type: 'toolStart', toolName, ...(input !== undefined ? { input } : {}), ...(toolCallId ? { toolCallId } : {}) });
|
|
110
119
|
},
|
|
111
120
|
async toolEnd(toolName, toolCallId, result, isError) {
|
|
112
|
-
|
|
121
|
+
emit({ type: 'toolEnd', toolName, toolCallId, ...(result !== undefined ? { result } : {}), ...(isError !== undefined ? { isError } : {}) });
|
|
113
122
|
},
|
|
114
|
-
async progress(message) {
|
|
123
|
+
async progress(message) { emit({ type: 'progress', message }); },
|
|
115
124
|
async question(payload) {
|
|
116
|
-
// One-way: forwarded for visibility, answered here. No answer frame exists
|
|
117
|
-
|
|
125
|
+
// One-way: forwarded for visibility, answered here. No answer frame exists, but it
|
|
126
|
+
// still reaches a client stream, so it carries the run id like any other event.
|
|
127
|
+
send({ type: 'question', payload, id });
|
|
118
128
|
return DECLINED;
|
|
119
129
|
},
|
|
120
|
-
async error(message) {
|
|
121
|
-
async done() {
|
|
130
|
+
async error(message) { emit({ type: 'error', message }); },
|
|
131
|
+
async done() { emit({ type: 'done' }); },
|
|
122
132
|
};
|
|
123
133
|
}
|
|
124
134
|
function errText(err) {
|
package/out/runner/protocol.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*--------------------------------------------------------------------------------------------*/
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.isTerminalFrame = isTerminalFrame;
|
|
7
|
+
exports.isTaggedEvent = isTaggedEvent;
|
|
7
8
|
exports.encodeRunnerFrame = encodeRunnerFrame;
|
|
8
9
|
exports.encodeClientFrame = encodeClientFrame;
|
|
9
10
|
exports.parseRunnerFrame = parseRunnerFrame;
|
|
@@ -11,7 +12,11 @@ exports.parseClientFrame = parseClientFrame;
|
|
|
11
12
|
exports.splitFrames = splitFrames;
|
|
12
13
|
exports.toWireOptions = toWireOptions;
|
|
13
14
|
function isTerminalFrame(frame) {
|
|
14
|
-
return frame.type === 'result' || frame.type === 'failed';
|
|
15
|
+
return 'type' in frame && (frame.type === 'result' || frame.type === 'failed');
|
|
16
|
+
}
|
|
17
|
+
/** A streamed event that names its turn — the only shape safe to route to a single emitter. */
|
|
18
|
+
function isTaggedEvent(frame) {
|
|
19
|
+
return 'event' in frame;
|
|
15
20
|
}
|
|
16
21
|
function encodeRunnerFrame(frame) {
|
|
17
22
|
return `${JSON.stringify(frame)}\n`;
|
|
@@ -27,7 +32,18 @@ function parseRunnerFrame(line) {
|
|
|
27
32
|
}
|
|
28
33
|
const type = parsed.type;
|
|
29
34
|
if (type === 'ready') {
|
|
30
|
-
return { type: 'ready' };
|
|
35
|
+
return { type: 'ready', ...(parsed.demux === true ? { demux: true } : {}) };
|
|
36
|
+
}
|
|
37
|
+
// Tagged streamed event. Checked before the untagged branch below because this envelope
|
|
38
|
+
// carries no top-level `type` — the inner event holds it. Validating that inner type here
|
|
39
|
+
// keeps a malformed envelope from becoming an event the emitter switch silently ignores,
|
|
40
|
+
// which would look like a turn that streamed nothing.
|
|
41
|
+
if (typeof parsed.id === 'string' && parsed.event && typeof parsed.event === 'object') {
|
|
42
|
+
const inner = parsed.event.type;
|
|
43
|
+
if (typeof inner === 'string' && RUNNER_EVENT_TYPES.has(inner)) {
|
|
44
|
+
return { id: parsed.id, event: parsed.event };
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
31
47
|
}
|
|
32
48
|
if (type === 'result' && typeof parsed.id === 'string') {
|
|
33
49
|
return {
|
|
@@ -44,9 +60,13 @@ function parseRunnerFrame(line) {
|
|
|
44
60
|
};
|
|
45
61
|
}
|
|
46
62
|
if (type === 'question' && parsed.payload && typeof parsed.payload === 'object') {
|
|
47
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
type: 'question',
|
|
65
|
+
payload: parsed.payload,
|
|
66
|
+
...(typeof parsed.id === 'string' ? { id: parsed.id } : {}),
|
|
67
|
+
};
|
|
48
68
|
}
|
|
49
|
-
//
|
|
69
|
+
// Untagged streamed event (legacy peer) — validated by shape, not re-listed here.
|
|
50
70
|
if (typeof type === 'string' && RUNNER_EVENT_TYPES.has(type)) {
|
|
51
71
|
return parsed;
|
|
52
72
|
}
|
|
@@ -23,6 +23,12 @@ class RemoteEngine {
|
|
|
23
23
|
buffer = '';
|
|
24
24
|
seq = 0;
|
|
25
25
|
wired = false;
|
|
26
|
+
/**
|
|
27
|
+
* Set from the peer's `ready` frame. Starts FALSE and stays false until the peer proves it
|
|
28
|
+
* tags streamed events: a run frame can be sent before `ready` arrives, so "not yet known"
|
|
29
|
+
* must mean "assume it cannot", or the first pair of concurrent turns races the handshake.
|
|
30
|
+
*/
|
|
31
|
+
peerDemux = false;
|
|
26
32
|
constructor(opts) {
|
|
27
33
|
this.id = opts.id;
|
|
28
34
|
this.displayName = opts.displayName;
|
|
@@ -34,7 +40,7 @@ class RemoteEngine {
|
|
|
34
40
|
this.wire();
|
|
35
41
|
const id = `r${++this.seq}`;
|
|
36
42
|
const settled = new Promise((resolve, reject) => {
|
|
37
|
-
this.pending.set(id, { emitter, resolve, reject });
|
|
43
|
+
this.pending.set(id, { emitter, resolve, reject, chain: Promise.resolve() });
|
|
38
44
|
});
|
|
39
45
|
const onAbort = () => { this.send({ type: 'abort', id }); };
|
|
40
46
|
if (signal) {
|
|
@@ -85,38 +91,68 @@ class RemoteEngine {
|
|
|
85
91
|
this.logger?.warn('[RemoteEngine] dropped malformed runner frame');
|
|
86
92
|
continue;
|
|
87
93
|
}
|
|
88
|
-
|
|
94
|
+
this.dispatch(frame);
|
|
89
95
|
}
|
|
90
96
|
});
|
|
91
97
|
void this.channel.closed?.then(() => this.failAll(new Error('sandbox channel closed')), (err) => this.failAll(err instanceof Error ? err : new Error(String(err))));
|
|
92
98
|
}
|
|
93
|
-
|
|
99
|
+
dispatch(frame) {
|
|
100
|
+
if ((0, protocol_1.isTaggedEvent)(frame)) {
|
|
101
|
+
// Routed to exactly one turn. No entry means the turn already settled or was aborted
|
|
102
|
+
// and a late event arrived behind its terminal frame — dropping it is correct.
|
|
103
|
+
this.enqueue(this.pending.get(frame.id), (e) => (0, customEngineIpc_1.applyParsedRunnerEvent)(e.emitter, frame.event));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
94
106
|
if (frame.type === 'ready') {
|
|
107
|
+
this.peerDemux = frame.demux === true;
|
|
95
108
|
return;
|
|
96
109
|
}
|
|
97
110
|
if (frame.type === 'result') {
|
|
98
|
-
this
|
|
111
|
+
// Behind this turn's own events, so a turn never settles while its last delta is still
|
|
112
|
+
// inside an unfinished emitter call.
|
|
113
|
+
this.settle(frame.id, (entry) => entry.resolve(frame.nativeSessionId));
|
|
99
114
|
return;
|
|
100
115
|
}
|
|
101
116
|
if (frame.type === 'failed') {
|
|
102
|
-
this.
|
|
117
|
+
this.settle(frame.id, (entry) => entry.reject(new Error(frame.message)));
|
|
103
118
|
return;
|
|
104
119
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// One-way notification; the runner already answered it `declined` locally.
|
|
111
|
-
await entry.emitter.question(frame.payload).catch(() => undefined);
|
|
112
|
-
continue;
|
|
120
|
+
if (frame.type === 'question') {
|
|
121
|
+
// One-way notification; the runner already answered it `declined` locally.
|
|
122
|
+
const targets = frame.id ? [this.pending.get(frame.id)] : [...this.pending.values()];
|
|
123
|
+
for (const entry of targets) {
|
|
124
|
+
this.enqueue(entry, (e) => e.emitter.question(frame.payload).then(() => undefined));
|
|
113
125
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// LEGACY peer only: untagged events carry nothing that names their turn, so the best
|
|
129
|
+
// available behaviour is to broadcast. That is only safe because a peer which has not
|
|
130
|
+
// advertised `demux` is given one turn at a time (see sandboxRunner's `exclusive`).
|
|
131
|
+
for (const entry of this.pending.values()) {
|
|
132
|
+
this.enqueue(entry, (e) => (0, customEngineIpc_1.applyParsedRunnerEvent)(e.emitter, frame).then(() => undefined));
|
|
118
133
|
}
|
|
119
134
|
}
|
|
135
|
+
/** Appends work to one turn's ordered chain. An emitter failure never breaks the chain. */
|
|
136
|
+
enqueue(entry, work) {
|
|
137
|
+
if (!entry) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
entry.chain = entry.chain.then(() => work(entry)).then(() => undefined, (err) => {
|
|
141
|
+
this.logger?.warn(`[RemoteEngine] emitter error: ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Settles a turn once everything already streamed for it has been applied. */
|
|
145
|
+
settle(id, finish) {
|
|
146
|
+
const entry = this.pending.get(id);
|
|
147
|
+
if (!entry) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
void entry.chain.then(() => finish(entry));
|
|
151
|
+
}
|
|
152
|
+
/** True once the peer's `ready` proved it tags streamed events — see `peerDemux`. */
|
|
153
|
+
supportsDemux() {
|
|
154
|
+
return this.peerDemux;
|
|
155
|
+
}
|
|
120
156
|
failAll(err) {
|
|
121
157
|
for (const [id, entry] of this.pending) {
|
|
122
158
|
entry.reject(err);
|
|
@@ -153,7 +153,7 @@ class ContainerSandboxDriver {
|
|
|
153
153
|
}
|
|
154
154
|
async create(spec) {
|
|
155
155
|
assertPinnedByDigest(spec.image);
|
|
156
|
-
const id = `sandbox-${this.instance}-${++this.seq}-${sanitize(spec.
|
|
156
|
+
const id = `sandbox-${this.instance}-${++this.seq}-${sanitize(spec.sandboxKey)}`;
|
|
157
157
|
// One DIRECTORY per sandbox, mounted at a fixed path inside. Docker cannot bind-mount a
|
|
158
158
|
// socket file that does not exist yet — it would create a directory in its place and the
|
|
159
159
|
// sandbox's bind() then fails (observed live). The sandbox creates the socket inside its own
|
|
@@ -217,7 +217,9 @@ class ContainerSandboxDriver {
|
|
|
217
217
|
// The channel: this sandbox's own socket directory and no other's.
|
|
218
218
|
'--volume', `${sandboxSocketDir}:${exports.SANDBOX_SOCKET_DIR_IN_SANDBOX}`,
|
|
219
219
|
'--label', `ai.agenthippo.sandbox=${id}`,
|
|
220
|
-
|
|
220
|
+
// Kept as `session` for operators' existing greps; under user scope it names the user's
|
|
221
|
+
// sandbox rather than one conversation.
|
|
222
|
+
'--label', `ai.agenthippo.session=${spec.sandboxKey}`,
|
|
221
223
|
'--label', `ai.agenthippo.tier=${spec.tier}`,
|
|
222
224
|
];
|
|
223
225
|
for (const entry of spec.fs.readonly) {
|
|
@@ -171,7 +171,7 @@ class ProcessSandboxDriver {
|
|
|
171
171
|
return this.nsWrap;
|
|
172
172
|
}
|
|
173
173
|
async create(spec) {
|
|
174
|
-
const id = `sandbox-${++this.seq}-${sanitize(spec.
|
|
174
|
+
const id = `sandbox-${++this.seq}-${sanitize(spec.sandboxKey)}`;
|
|
175
175
|
const spawnFn = this.opts.spawnFn ?? node_child_process_1.spawn;
|
|
176
176
|
const wrap = await this.namespaceWrap();
|
|
177
177
|
const command = wrap ? 'unshare' : this.opts.command;
|
|
@@ -275,6 +275,9 @@ function baseSandboxEnv(opts = {}, sourceEnv = process.env) {
|
|
|
275
275
|
// the sandbox resolves bundled engines itself via findClaudeCodePath()/findCodexPath(),
|
|
276
276
|
// which already know about .agenthippo-deps and the image's baked SDKs.
|
|
277
277
|
'CLAUDE_CODE_PATH', 'CODEX_PATH', 'AGENTHIPPO_CUSTOM_ENGINES_DIR', 'AGENTHIPPO_NO_AUTO_INSTALL',
|
|
278
|
+
// Boot-stable route URLs (not credentials). Sandboxed serve resolves ${env:AGENTHIPPO_DATA_MCP_URL}
|
|
279
|
+
// when loading pack MCP configs; without these the postgres gateway entry is dropped.
|
|
280
|
+
'AGENTHIPPO_DATA_MCP_URL', 'AGENTHIPPO_LITELLM_BASE_URL',
|
|
278
281
|
];
|
|
279
282
|
const out = {};
|
|
280
283
|
for (const key of allow) {
|