deepdebug-local-agent 1.9.0 → 1.11.0
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/AGENTS.md +1 -1
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +1 -1
- package/docs/examples/agent2-runner/docker-compose.yml +53 -0
- package/docs/la-agent2-runner.md +115 -0
- package/docs/la-jobs-and-llm.md +62 -0
- package/docs/la-protocol-v2.md +7 -5
- package/env.example +12 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/content-handlers/install.js +27 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -6
- package/src/la-protocol-v2/contract/README.md +75 -2
- package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
- package/src/la-protocol-v2/contract/index.json +18 -2
- package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
- package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
- package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
- package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
- package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
- package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
- package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
- package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
- package/src/la-protocol-v2/jobs/config.js +60 -0
- package/src/la-protocol-v2/jobs/events.js +106 -0
- package/src/la-protocol-v2/jobs/install.js +43 -0
- package/src/la-protocol-v2/jobs/manager.js +281 -0
- package/src/la-protocol-v2/jobs/options.js +56 -0
- package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
- package/src/la-protocol-v2/llm/client.js +432 -0
- package/src/la-protocol-v2/llm/config.js +63 -0
- package/src/la-protocol-v2/llm/install.js +17 -0
- package/src/la-protocol-v2/llm-key/store.js +10 -0
- package/src/la-protocol-v2/runtime.js +69 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
- package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
- package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
- package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
- package/test/la-protocol-v2/agent2-config.test.js +103 -0
- package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
- package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
- package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
- package/test/la-protocol-v2/agent2-runner.test.js +510 -0
- package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
- package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
- package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +8 -7
- package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
- package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
- package/test/la-protocol-v2/helpers/fake-llm.js +75 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
- package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
- package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
- package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
- package/test/la-protocol-v2/jobs-manager.test.js +370 -0
- package/test/la-protocol-v2/jobs-options.test.js +288 -0
- package/test/la-protocol-v2/llm-client.test.js +380 -0
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy-check.js - the PolicyGate relay of the agent2 bridge over the v2 channel (contract 2.2, F3-08c, Local Agent half).
|
|
3
|
+
*
|
|
4
|
+
* agent2 (profile local-agent, PR #308: LocalGatewayInternalClient) asks `POST <bridge>/agent2/cp-request {"type":"policy.check","payload":{...}}`: the payload is already the
|
|
5
|
+
* CLOSED `policy.check` payload of the contract (actionType, requestHash, toolName?, modelAlias?, incidentId?, jobId, repoRefs?). This module checks it again (agent2 is another
|
|
6
|
+
* process: its words are data), pins what this agent knows better than agent2 (the job, its repository, its model alias), sends it as a `policy.check` frame (LA -> CP, unsigned:
|
|
7
|
+
* the authenticated session IS the identity), waits for the CP's signed `policy.decision` (matched by the envelope `requestId` AND the `requestHash`) and gives agent2 back the
|
|
8
|
+
* decision payload as the contract defines it (`decision`, `reasonCode`, `shadow`, `approvalRequired`, `actionType`, `requestHash`, `policySnapshot.hash`, `effectiveMode`?).
|
|
9
|
+
*
|
|
10
|
+
* What this agent does NOT say: the tenant, the agent and the origin. The CP derives all three from the session (the closed schema would refuse them), so a
|
|
11
|
+
* process on this machine cannot claim another tenant or another origin, and there is no bearer of the platform on the customer's host. Anything else agent2 puts in the
|
|
12
|
+
* payload is dropped (counted). What goes in the frame: action type, tool name, the request hash agent2 computed (the one of its `approval_requested`), the job's repository as
|
|
13
|
+
* {provider, org, repo} (never a URL, a branch, a path, a command or a diff), the job id, the incident id and the model alias.
|
|
14
|
+
*
|
|
15
|
+
* Fail closed, always: no session / capability not negotiated (`ready` did not list `policy.check`) / a payload that is not a valid question / a job or repository that is not the
|
|
16
|
+
* running job's / frame refused / acked `rejected` / no decision in `timeoutMs` / a decision that is not a complete answer to what was asked = a DENY (an HTTP error agent2 turns
|
|
17
|
+
* into a DENY) with a reason code. A late or unknown decision is ignored and counted. Nothing of a request is logged or audited beyond ids hashed by the caller, counters and codes.
|
|
18
|
+
*/
|
|
19
|
+
import crypto from "node:crypto";
|
|
20
|
+
|
|
21
|
+
const SHORT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
22
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
23
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
24
|
+
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
25
|
+
const REASON = /^[a-z][a-z0-9_]{1,47}$/;
|
|
26
|
+
const PROVIDERS = new Set(["github", "gitlab", "bitbucket", "azure", "other"]);
|
|
27
|
+
const DECISIONS = new Set(["ALLOW", "DENY", "REQUIRE_HUMAN"]);
|
|
28
|
+
const MODES = new Set(["observe", "assist", "autonomous", "none"]);
|
|
29
|
+
const CHECK_KEYS = new Set(["actionType", "requestHash", "toolName", "repoRefs", "incidentId", "jobId", "modelAlias"]);
|
|
30
|
+
const MAX_REPOS = 8;
|
|
31
|
+
const MAX_PENDING = 16;
|
|
32
|
+
|
|
33
|
+
const isShort = (v) => typeof v === "string" && SHORT_ID.test(v);
|
|
34
|
+
const refOf = (r) => (r && typeof r === "object" && !Array.isArray(r) && PROVIDERS.has(r.provider) && typeof r.org === "string" && NAME.test(r.org) && typeof r.repo === "string" && NAME.test(r.repo)
|
|
35
|
+
? { provider: r.provider, org: r.org, repo: r.repo } : null);
|
|
36
|
+
const sameRef = (a, b) => a.provider === b.provider && a.org === b.org && a.repo === b.repo;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* agent2's `policy.check` payload -> the payload this agent sends. @returns {{payload:object, dropped:number}|{error:string, status:number}}
|
|
40
|
+
* Pinned from the running job (never from agent2): `jobId`, `repoRefs` (the job's repository; what agent2 names must BE that repository), `modelAlias`.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizePolicyCheck(p, job) {
|
|
43
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return { error: "bad_request", status: 400 };
|
|
44
|
+
if (!isShort(p.actionType)) return { error: "bad_action_type", status: 400 };
|
|
45
|
+
if (typeof p.requestHash !== "string" || !SHA256.test(p.requestHash)) return { error: "bad_request_hash", status: 400 };
|
|
46
|
+
if (p.toolName !== undefined && !isShort(p.toolName)) return { error: "bad_tool_name", status: 400 };
|
|
47
|
+
if (p.incidentId !== undefined && !isShort(p.incidentId)) return { error: "bad_incident_id", status: 400 };
|
|
48
|
+
if (p.modelAlias !== undefined && !isShort(p.modelAlias)) return { error: "bad_model_alias", status: 400 };
|
|
49
|
+
if (p.jobId !== undefined && (typeof p.jobId !== "string" || p.jobId !== job.jobId)) return { error: "job_mismatch", status: 403 };
|
|
50
|
+
const jobRef = refOf(job.repoRef);
|
|
51
|
+
if (p.repoRefs !== undefined) {
|
|
52
|
+
if (!Array.isArray(p.repoRefs) || p.repoRefs.length < 1 || p.repoRefs.length > MAX_REPOS) return { error: "bad_repo_refs", status: 400 };
|
|
53
|
+
for (const r of p.repoRefs) {
|
|
54
|
+
const ref = refOf(r);
|
|
55
|
+
if (!ref || Object.keys(r).length !== 3) return { error: "bad_repo_refs", status: 400 };
|
|
56
|
+
if (!jobRef || !sameRef(ref, jobRef)) return { error: "repo_mismatch", status: 403 }; // a repository that is not the job's is not asked about here
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let dropped = 0;
|
|
60
|
+
for (const k of Object.keys(p)) if (!CHECK_KEYS.has(k)) dropped++;
|
|
61
|
+
const incidentId = isShort(p.incidentId) ? p.incidentId : isShort(job.incidentId) ? job.incidentId : undefined;
|
|
62
|
+
return {
|
|
63
|
+
dropped,
|
|
64
|
+
payload: {
|
|
65
|
+
actionType: p.actionType, requestHash: p.requestHash,
|
|
66
|
+
...(p.toolName ? { toolName: p.toolName } : {}),
|
|
67
|
+
...(jobRef ? { repoRefs: [jobRef] } : {}),
|
|
68
|
+
...(incidentId ? { incidentId } : {}),
|
|
69
|
+
...(UUID.test(String(job.jobId)) ? { jobId: job.jobId } : {}),
|
|
70
|
+
...(isShort(job.alias) ? { modelAlias: job.alias } : {}),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The signed `policy.decision` payload, checked once more as a complete and consistent answer to `asked`; null when it is not. Only the contract's fields come out. */
|
|
76
|
+
export function decisionBody(p, asked) {
|
|
77
|
+
if (!p || typeof p !== "object" || !DECISIONS.has(p.decision)) return null;
|
|
78
|
+
if (typeof p.shadow !== "boolean" || typeof p.approvalRequired !== "boolean") return null;
|
|
79
|
+
if (p.approvalRequired !== (p.decision === "REQUIRE_HUMAN" && !p.shadow)) return null; // the contract pins this equivalence
|
|
80
|
+
if (typeof p.reasonCode !== "string" || !REASON.test(p.reasonCode)) return null;
|
|
81
|
+
if (p.requestHash !== asked.requestHash || !isShort(p.actionType)) return null;
|
|
82
|
+
if (!p.policySnapshot || typeof p.policySnapshot.hash !== "string" || !SHA256.test(p.policySnapshot.hash)) return null;
|
|
83
|
+
return { decision: p.decision, reasonCode: p.reasonCode, shadow: p.shadow, approvalRequired: p.approvalRequired, actionType: p.actionType, requestHash: p.requestHash,
|
|
84
|
+
...(MODES.has(p.effectiveMode) ? { effectiveMode: p.effectiveMode } : {}), policySnapshot: { hash: p.policySnapshot.hash } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {object} o
|
|
89
|
+
* @param {()=>object|null} o.getSession the v2 session (late-bound)
|
|
90
|
+
* @param {number} [o.timeoutMs] 10 s = `policyCheckTimeoutMs` of the contract
|
|
91
|
+
* @returns {{relay:Function, handleDecision:Function, stats:object, readonly pending:number, close:Function}}
|
|
92
|
+
*/
|
|
93
|
+
export function createPolicyCheckRelay({ getSession, timeoutMs = 10_000, log = console, now = Date.now, randomUuid = () => crypto.randomUUID(), audit = null }) {
|
|
94
|
+
const pending = new Map(); // requestId -> { requestHash, actionType, resolve, timer }
|
|
95
|
+
const stats = { sent: 0, decided: 0, allow: 0, deny: 0, requireHuman: 0, shadow: 0, unavailable: 0, late: 0, mismatch: 0, invalid: 0, dropped: 0, byReason: {} };
|
|
96
|
+
const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
|
|
97
|
+
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
98
|
+
void now;
|
|
99
|
+
|
|
100
|
+
const unavailable = (reason, status = 503) => {
|
|
101
|
+
stats.unavailable++; stats.byReason[reason] = (stats.byReason[reason] || 0) + 1;
|
|
102
|
+
return { status, json: { decision: "DENY", shadow: false, approvalRequired: false, reasonCode: "gate_unavailable", error: reason } };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function count(p) {
|
|
106
|
+
stats.decided++;
|
|
107
|
+
if (p.decision === "ALLOW") stats.allow++; else if (p.decision === "DENY") stats.deny++; else stats.requireHuman++;
|
|
108
|
+
if (p.shadow) stats.shadow++;
|
|
109
|
+
stats.byReason[p.reasonCode] = (stats.byReason[p.reasonCode] || 0) + 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function settle(requestId, result) {
|
|
113
|
+
const e = pending.get(requestId);
|
|
114
|
+
if (!e) return;
|
|
115
|
+
pending.delete(requestId);
|
|
116
|
+
clearTimeout(e.timer);
|
|
117
|
+
e.resolve(result);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
stats,
|
|
122
|
+
get pending() { return pending.size; },
|
|
123
|
+
/** the bridge's policyRelay: ({check, job}) -> {status, json}; `check` = the payload of agent2's `policy.check`; never throws */
|
|
124
|
+
async relay({ check, job }) {
|
|
125
|
+
const session = getSession();
|
|
126
|
+
if (!session || session.state !== "ready" || typeof session.peerHas !== "function") return unavailable("no_session");
|
|
127
|
+
if (!session.peerHas("policy.check")) return unavailable("not_negotiated"); // the CP did not list the capability in `ready`: it would not answer
|
|
128
|
+
if (pending.size >= MAX_PENDING) return unavailable("busy", 429);
|
|
129
|
+
const built = normalizePolicyCheck(check, job);
|
|
130
|
+
if (built.error) return unavailable(built.error, built.status);
|
|
131
|
+
stats.dropped += built.dropped;
|
|
132
|
+
const requestId = randomUuid();
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const timer = setTimeout(() => { settle(requestId, unavailable("timeout", 504)); note("policy_check_timeout", {}); }, timeoutMs);
|
|
135
|
+
if (timer.unref) timer.unref();
|
|
136
|
+
pending.set(requestId, { requestHash: built.payload.requestHash, actionType: built.payload.actionType, resolve, timer });
|
|
137
|
+
session.enqueue("policy.check", built.payload, { requestId, workspaceId: job.workspaceId }).then((h) => {
|
|
138
|
+
stats.sent++;
|
|
139
|
+
// the CP may refuse the frame (ack `rejected`: a stray check of a CP with the feature off, say): that is the answer, no decision will come
|
|
140
|
+
h.acked.catch((e) => { settle(requestId, unavailable(e && typeof e.code === "string" ? `rejected_${e.code}`.slice(0, 40) : "rejected")); });
|
|
141
|
+
}, (e) => settle(requestId, unavailable(e && typeof e.code === "string" ? `send_${e.code}`.slice(0, 40) : "send_failed")));
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
/** session handler of `policy.decision` (the session already checked schema, signature, tenant and sequence) */
|
|
145
|
+
handleDecision(ctx) {
|
|
146
|
+
const e = ctx.requestId ? pending.get(ctx.requestId) : null;
|
|
147
|
+
if (!e) { stats.late++; note("policy_decision_unknown", {}); return { accept: true }; } // late (timed out already) or never asked: dropped, acked
|
|
148
|
+
const p = ctx.params;
|
|
149
|
+
if (p.requestHash !== e.requestHash) { // not the answer to what was asked: never read as one
|
|
150
|
+
stats.mismatch++;
|
|
151
|
+
warn("[la-agent2] a policy.decision did not match its policy.check; the action is denied");
|
|
152
|
+
note("policy_decision_mismatch", {});
|
|
153
|
+
settle(ctx.requestId, unavailable("decision_mismatch", 502));
|
|
154
|
+
return { accept: true };
|
|
155
|
+
}
|
|
156
|
+
const body = decisionBody(p, e);
|
|
157
|
+
if (!body) { // the session checked the schema; this is the second look: an answer that is not complete and consistent is never read as one
|
|
158
|
+
stats.invalid++;
|
|
159
|
+
note("policy_decision_invalid", {});
|
|
160
|
+
settle(ctx.requestId, unavailable("decision_invalid", 502));
|
|
161
|
+
return { accept: true };
|
|
162
|
+
}
|
|
163
|
+
count(body);
|
|
164
|
+
settle(ctx.requestId, { status: 200, json: body });
|
|
165
|
+
return { accept: true };
|
|
166
|
+
},
|
|
167
|
+
/** the session is gone: nothing will answer */
|
|
168
|
+
close() { for (const id of [...pending.keys()]) settle(id, unavailable("closed")); },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner.js - the `agent2` Runner (F3-08c, Local Agent half): the packaged agent loop (agent2, profile local-agent) runs the job NEXT TO this agent, on the
|
|
3
|
+
* customer's side; the control plane only assigns the job and receives metadata. Selected with LOCAL_AGENT_RUNNER=agent2 (runtime.js); see
|
|
4
|
+
* docs/la-agent2-runner.md.
|
|
5
|
+
*
|
|
6
|
+
* job.assign (+options) --> POST /local/v1/jobs of agent2 the workspace is the LOCAL one, the request text is the job's `inline` inputs (and the
|
|
7
|
+
* allowed files named by `ref`), the "LLM key" agent2 receives is a per-job token of the
|
|
8
|
+
* loopback bridge (bridge.js), never the virtual key
|
|
9
|
+
* GET /local/v1/jobs/{id}/events (SSE) --> job.event / job.result only whitelisted metadata (frames.js); phase/iteration/files go through the manager's
|
|
10
|
+
* progress, so tokens and cost are THIS agent's (counted on the bridge), the result is
|
|
11
|
+
* sent BEFORE the terminal event (manager), `waiting_approval` while a person decides
|
|
12
|
+
* audit.event approval_requested --> the control plane actionType, toolName, requestHash only; the command or file list stays in agent2
|
|
13
|
+
* policy question (bridge) --> policy.check / policy.decision over the v2 channel; the control plane derives tenant, agent and origin from the session (policy-check.js)
|
|
14
|
+
* agent2's /workspace calls (bridge) --> this agent's /workspace vouched for by this agent, so the signature check can stay on (bridge.js)
|
|
15
|
+
* approval.decision --> POST /local/v1/approvals/{id} after the token check (approvals.js)
|
|
16
|
+
* job.cancel / timeout --> POST /local/v1/jobs/{id}/cancel
|
|
17
|
+
*
|
|
18
|
+
* One job at a time (agent2 refuses a second with 409; runtime.js forces LOCAL_AGENT_JOBS_MAX_CONCURRENT=1 for this runner). Failure codes only: agent2
|
|
19
|
+
* unavailable / crashed / stream lost = `infra`; agent2 refusing the job = `policy_denied` (403) or `unknown`.
|
|
20
|
+
*
|
|
21
|
+
* Never in a log, an audit, a stat or an error: the request text, a file, an answer, the per-job token, the API token, the workspace path. Ids are
|
|
22
|
+
* hashed (short), everything else is a counter or a code.
|
|
23
|
+
*/
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
import { createAudit, short } from "../../content-handlers/audit.js";
|
|
26
|
+
import { ProtocolError } from "../../errors.js";
|
|
27
|
+
import { createAgent2Bridge } from "./bridge.js";
|
|
28
|
+
import { createAgent2Supervisor, Agent2UnavailableError } from "./supervisor.js";
|
|
29
|
+
import { createApprovalDecider } from "./approvals.js";
|
|
30
|
+
import { normalizeFrame } from "./frames.js";
|
|
31
|
+
import { createPolicyCheckRelay } from "./policy-check.js";
|
|
32
|
+
import { internalSignatureHeaders } from "../../../utils/request-signing.js";
|
|
33
|
+
import { internalAuthHeaders, resolveAuthConfig } from "../../../utils/agent-auth.js";
|
|
34
|
+
|
|
35
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
36
|
+
const RETRYABLE = new Set(["rate_limited", "not_ready"]);
|
|
37
|
+
const utf8 = new TextEncoder();
|
|
38
|
+
const defaultSleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); });
|
|
39
|
+
|
|
40
|
+
/** agent2's API wants UUID workspace ids; the Local Agent's are free-form strings ("default", "ws-1"): a stable name-based UUID stands in for those. */
|
|
41
|
+
export function workspaceUuid(tenantId, workspaceId) {
|
|
42
|
+
if (UUID.test(String(workspaceId))) return workspaceId;
|
|
43
|
+
const h = crypto.createHash("sha256").update(`vh-la-workspace:${tenantId}:${workspaceId}`).digest();
|
|
44
|
+
h[6] = (h[6] & 0x0f) | 0x50; // version 5 layout
|
|
45
|
+
h[8] = (h[8] & 0x3f) | 0x80;
|
|
46
|
+
const x = h.subarray(0, 16).toString("hex");
|
|
47
|
+
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function truncateBytes(text, max) {
|
|
51
|
+
const b = utf8.encode(text);
|
|
52
|
+
if (b.length <= max) return text;
|
|
53
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(b.subarray(0, max)).replace(/�+$/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const HTTP_TO_JOB = Object.freeze({ 403: "policy_denied", 401: "infra", 409: "infra" });
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} o
|
|
60
|
+
* @param {object} o.config resolveAgent2RunnerConfig() (enabled)
|
|
61
|
+
* @param {string} o.tenantId
|
|
62
|
+
* @param {(workspaceId:string)=>string|null} o.resolveWorkspaceRoot
|
|
63
|
+
* @param {()=>object} o.getSession the v2 session (late-bound: the link does not exist yet when the runner is built)
|
|
64
|
+
* @param {{keys:Map}} o.signing the trusted platform keys (approval tokens)
|
|
65
|
+
* @param {Function} [o.policyRelay] ({body, job}) => Promise<{status, json}>; TEST SEAM. Default: the policy.check relay over the v2 session (no session / no answer = DENY)
|
|
66
|
+
*/
|
|
67
|
+
export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, getSession, signing, env = process.env, log = console, policyRelay = null, fetchImpl, spawnImpl, now = Date.now, sleep = defaultSleep, rssOf, audit = null }) {
|
|
68
|
+
const tenant = String(tenantId).toLowerCase();
|
|
69
|
+
const auditor = audit || createAudit({ now });
|
|
70
|
+
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
71
|
+
const note = (event, extra) => { try { auditor.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
|
|
72
|
+
const policyCheck = createPolicyCheckRelay({ getSession, timeoutMs: config.policyCheckTimeoutMs, log, now, audit: auditor });
|
|
73
|
+
const authCfg = resolveAuthConfig(env);
|
|
74
|
+
// the hop agent2 -> bridge -> this agent's own API: the internal marker of this process (what the WS tunnel uses for its own hops) + the signed identity it stands for
|
|
75
|
+
// (tenant and the running job's workspace) + X-Agent-Token when this agent has one. agent2 holds none of it.
|
|
76
|
+
const workspace = { url: config.laUrl, maxBodyBytes: config.maxWorkspaceBodyBytes, timeoutMs: config.workspaceTimeoutMs,
|
|
77
|
+
headers: ({ tenantId: t, workspaceId }) => ({ ...internalAuthHeaders(authCfg), ...internalSignatureHeaders(), "X-VH-Tenant-Id": t, "X-VH-Workspace-Id": workspaceId }) };
|
|
78
|
+
const bridge = createAgent2Bridge({ port: config.bridgePort, maxBodyBytes: config.maxBodyBytes, policyRelay: policyRelay || policyCheck.relay, tenantId: tenant, log, now, workspace });
|
|
79
|
+
const supervisor = createAgent2Supervisor({ config, tenantId: tenant, bridge, env, log, now, sleep, ...(fetchImpl ? { fetchImpl } : {}), ...(spawnImpl ? { spawnImpl } : {}), ...(rssOf ? { rssOf } : {}) });
|
|
80
|
+
const approvals = createApprovalDecider({ api: supervisor.api, signing, tenantId: tenant, now, audit: auditor, log });
|
|
81
|
+
const stats = { jobs: 0, ok: 0, failed: 0, cancelled: 0, busy: 0, framesDropped: 0, tenantViolations: 0, jobViolations: 0, auditForwarded: 0, auditGivenUp: 0, streamReconnects: 0, byCode: {} };
|
|
82
|
+
let active = null; // { jobId, workspaceId }
|
|
83
|
+
|
|
84
|
+
async function forwardAudit(payload, workspaceId, jobShort) {
|
|
85
|
+
const session = getSession();
|
|
86
|
+
for (let i = 0; ; i++) {
|
|
87
|
+
try { const h = await session.enqueue("audit.event", payload, { workspaceId }); h.acked.catch(() => {}); stats.auditForwarded++; return; } catch (e) {
|
|
88
|
+
const code = e instanceof ProtocolError ? e.code : "internal";
|
|
89
|
+
if (!RETRYABLE.has(code) || i >= config.auditForwardRetries) { stats.auditGivenUp++; note("audit_forward_given_up", { jobShort, code, tries: i + 1 }); return; }
|
|
90
|
+
await sleep(config.auditForwardRetryMs);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function requestText(job, ctx) {
|
|
96
|
+
const parts = [];
|
|
97
|
+
for (const i of job.inputs || []) {
|
|
98
|
+
if (typeof i.inline === "string") parts.push(i.inline);
|
|
99
|
+
else if (typeof i.ref === "string") {
|
|
100
|
+
const r = await ctx.readFile(i.ref, { maxBytes: config.maxRefFileBytes });
|
|
101
|
+
if (r && r.ok && typeof r.text === "string") parts.push(r.text);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let text = parts.join("\n\n").trim();
|
|
105
|
+
if (!text) text = `${job.kind === "implement" ? "Implement the requested change in this repository." : "Investigate and fix the reported problem in this repository."}${job.incidentRef?.id ? ` Incident ${job.incidentRef.id}.` : ""}`;
|
|
106
|
+
return truncateBytes(text, config.maxRequestTextBytes);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function jobBody(job, root, text, token) {
|
|
110
|
+
const o = job.options || {};
|
|
111
|
+
const options = {};
|
|
112
|
+
for (const k of ["ticketKey", "branchName", "createBranch", "createPullRequest", "dryRun", "maxIterations"]) if (o[k] !== undefined) options[k] = o[k];
|
|
113
|
+
return {
|
|
114
|
+
jobId: job.jobId, tenantId: tenant, workspaceId: workspaceUuid(tenant, job.workspaceId), kind: job.kind,
|
|
115
|
+
repoRef: job.repoRef, policy: job.policy, budget: { tokens: job.budget.tokens, seconds: job.budget.seconds },
|
|
116
|
+
llm: { alias: job.llm.alias, key: token },
|
|
117
|
+
...(Object.keys(options).length ? { options } : {}),
|
|
118
|
+
...(job.incidentRef?.id ? { incidentId: job.incidentRef.id } : {}),
|
|
119
|
+
request: { text }, workspacePath: root,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const failed = (errorCode) => { stats.failed++; stats.byCode[errorCode] = (stats.byCode[errorCode] || 0) + 1; return { outcome: "failed", errorCode }; };
|
|
124
|
+
|
|
125
|
+
async function start(job, ctx) {
|
|
126
|
+
stats.jobs++;
|
|
127
|
+
const jobShort = short(job.jobId);
|
|
128
|
+
if (active) { stats.busy++; note("job_refused_busy", { jobShort }); return failed("infra"); }
|
|
129
|
+
active = { jobId: job.jobId, workspaceId: job.workspaceId };
|
|
130
|
+
const evAc = new AbortController();
|
|
131
|
+
let proxyToken = null;
|
|
132
|
+
let cancelTimer = null;
|
|
133
|
+
let onDown = null;
|
|
134
|
+
let sendCancel = null;
|
|
135
|
+
try {
|
|
136
|
+
const root = resolveWorkspaceRoot(job.workspaceId);
|
|
137
|
+
if (!root) return failed("infra");
|
|
138
|
+
try {
|
|
139
|
+
const { baseUrl } = await bridge.listen();
|
|
140
|
+
if (config.mode === "attach" && config.callbackToken) bridge.setCallbackToken(config.callbackToken);
|
|
141
|
+
void baseUrl;
|
|
142
|
+
await supervisor.ensureReady();
|
|
143
|
+
} catch (e) {
|
|
144
|
+
note("agent2_unavailable", { jobShort, code: e instanceof Agent2UnavailableError ? e.code : "bridge", mode: config.mode });
|
|
145
|
+
return failed("infra");
|
|
146
|
+
}
|
|
147
|
+
if (ctx.signal.aborted) return { outcome: "cancelled" };
|
|
148
|
+
proxyToken = bridge.registerJob({ jobId: job.jobId, alias: job.llm.alias, llm: ctx.llm, workspaceId: job.workspaceId, incidentId: job.incidentRef?.id, root, repoRef: job.repoRef });
|
|
149
|
+
const text = await requestText(job, ctx);
|
|
150
|
+
try {
|
|
151
|
+
await supervisor.api.postJob(jobBody(job, root, text, proxyToken), ctx.signal);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
note("agent2_job_refused", { jobShort, code: e && e.code, status: e && e.status, field: e && e.field });
|
|
154
|
+
if (ctx.signal.aborted) return { outcome: "cancelled" };
|
|
155
|
+
return failed(HTTP_TO_JOB[e && e.status] || (e && (e.status === 400 || e.status === 422) ? "unknown" : "infra"));
|
|
156
|
+
}
|
|
157
|
+
ctx.progress({ phase: "context" });
|
|
158
|
+
|
|
159
|
+
// ---- cancellation: the job's signal reaches agent2, which reports `cancelled` itself; we wait for that for a while, then let go
|
|
160
|
+
let cancelSent = false;
|
|
161
|
+
sendCancel = () => {
|
|
162
|
+
if (cancelSent) return;
|
|
163
|
+
cancelSent = true;
|
|
164
|
+
supervisor.api.cancel(job.jobId, "user").catch(() => {});
|
|
165
|
+
cancelTimer = setTimeout(() => evAc.abort(), config.cancelWaitMs);
|
|
166
|
+
if (cancelTimer.unref) cancelTimer.unref();
|
|
167
|
+
};
|
|
168
|
+
if (ctx.signal.aborted) sendCancel(); else ctx.signal.addEventListener("abort", sendCancel, { once: true });
|
|
169
|
+
active.sendCancel = sendCancel;
|
|
170
|
+
onDown = () => evAc.abort();
|
|
171
|
+
supervisor.on("down", onDown); // agent2 died: nothing more will come
|
|
172
|
+
|
|
173
|
+
// ---- the event stream (replays from the start on a reconnect: skip what was handled)
|
|
174
|
+
const st = { lastSeq: 0, result: null, terminal: null, waiting: false, forwards: [] };
|
|
175
|
+
const handle = (raw) => {
|
|
176
|
+
const f = normalizeFrame(raw, { tenantId: tenant, jobId: job.jobId });
|
|
177
|
+
if (!f) { stats.framesDropped++; return; }
|
|
178
|
+
if (f.violation) { if (f.violation === "tenant") stats.tenantViolations++; else stats.jobViolations++; note("frame_dropped", { jobShort, why: f.violation }); return; }
|
|
179
|
+
if (f.seq <= st.lastSeq) return;
|
|
180
|
+
st.lastSeq = f.seq;
|
|
181
|
+
if (f.type === "job.event") {
|
|
182
|
+
if (f.terminal) { st.terminal = f.payload; return; }
|
|
183
|
+
const waiting = f.payload.state === "waiting_approval";
|
|
184
|
+
st.waiting = waiting;
|
|
185
|
+
ctx.progress({ ...(f.payload.phase ? { phase: f.payload.phase } : {}), ...(f.payload.iteration !== undefined ? { iteration: f.payload.iteration } : {}),
|
|
186
|
+
...(f.payload.filesRead !== undefined ? { filesRead: f.payload.filesRead } : {}), ...(f.payload.filesChanged !== undefined ? { filesChanged: f.payload.filesChanged } : {}), waitingApproval: waiting });
|
|
187
|
+
} else if (f.type === "job.result") {
|
|
188
|
+
st.result = f.payload;
|
|
189
|
+
} else if (f.type === "audit.event") {
|
|
190
|
+
if (f.payload.action === "approval_requested") {
|
|
191
|
+
note("approval_requested", { jobShort, actionType: f.payload.actionType, toolName: f.payload.toolName, hinted: !!f.approvalHint });
|
|
192
|
+
if (f.approvalHint) approvals.noteRequested(job.jobId, f.approvalHint);
|
|
193
|
+
}
|
|
194
|
+
st.forwards.push(forwardAudit(f.payload, job.workspaceId, jobShort));
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
let reconnects = 0;
|
|
198
|
+
for (;;) {
|
|
199
|
+
try { await supervisor.api.events(job.jobId, { signal: evAc.signal, onFrame: handle }); } catch (e) {
|
|
200
|
+
if (evAc.signal.aborted || ctx.signal.aborted && (e && e.code === "cancelled")) break;
|
|
201
|
+
if (supervisor.state !== "ready") break;
|
|
202
|
+
if (e && (e.code === "not_found" || e.code === "unauthorized")) break;
|
|
203
|
+
}
|
|
204
|
+
if (st.result || evAc.signal.aborted) break;
|
|
205
|
+
if (supervisor.state !== "ready" || reconnects >= config.streamReconnects) break;
|
|
206
|
+
reconnects++; stats.streamReconnects++;
|
|
207
|
+
await sleep(250 * reconnects);
|
|
208
|
+
}
|
|
209
|
+
await Promise.allSettled(st.forwards);
|
|
210
|
+
|
|
211
|
+
// ---- the outcome
|
|
212
|
+
const r = st.result;
|
|
213
|
+
const t = st.terminal;
|
|
214
|
+
if (!r) {
|
|
215
|
+
if (ctx.signal.aborted) return { outcome: "cancelled" };
|
|
216
|
+
return failed("infra");
|
|
217
|
+
}
|
|
218
|
+
if (r.outcome === "failed") {
|
|
219
|
+
const code = t && t.errorCode && !["none", "cancelled"].includes(t.errorCode) ? t.errorCode : "unknown";
|
|
220
|
+
return failed(code);
|
|
221
|
+
}
|
|
222
|
+
if (r.outcome === "cancelled") { stats.cancelled++; return { outcome: "cancelled" }; }
|
|
223
|
+
stats.ok++;
|
|
224
|
+
return { outcome: r.outcome, ...(r.prUrl ? { prUrl: r.prUrl } : {}), ...(r.branch ? { branch: r.branch } : {}), ...(r.commitSha ? { commitSha: r.commitSha } : {}),
|
|
225
|
+
...(r.filesChangedCount !== undefined ? { filesChangedCount: r.filesChangedCount } : {}), ...(r.diffSha256 ? { diffSha256: r.diffSha256 } : {}), ...(r.tests ? { tests: r.tests } : {}) };
|
|
226
|
+
} finally {
|
|
227
|
+
if (cancelTimer) clearTimeout(cancelTimer);
|
|
228
|
+
if (onDown) supervisor.off("down", onDown);
|
|
229
|
+
if (sendCancel) ctx.signal.removeEventListener("abort", sendCancel);
|
|
230
|
+
try { evAc.abort(); } catch { /* done */ }
|
|
231
|
+
approvals.forgetJob(job.jobId);
|
|
232
|
+
if (proxyToken) bridge.revokeJob(job.jobId); // the per-job "key" stops working the moment the job is over
|
|
233
|
+
if (active && active.jobId === job.jobId) active = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
name: "agent2",
|
|
239
|
+
kinds: ["fix", "implement"], // `quality` is not served by agent2 local mode (422 there): the manager acks it protocol_unsupported
|
|
240
|
+
needsLlm: true,
|
|
241
|
+
supportsOptions: true,
|
|
242
|
+
start,
|
|
243
|
+
cancel(jobId) {
|
|
244
|
+
if (active && active.jobId === jobId && active.sendCancel) active.sendCancel();
|
|
245
|
+
},
|
|
246
|
+
/** session handler of `approval.decision` (runtime.js registers it) */
|
|
247
|
+
async handleApprovalDecision(ctx) {
|
|
248
|
+
const p = ctx.params;
|
|
249
|
+
if (active && ctx.workspaceId && ctx.workspaceId !== active.workspaceId) { note("approval_decision_other_workspace", {}); return { reject: "unauthorized" }; }
|
|
250
|
+
return approvals.decide(p, active ? { jobId: active.jobId, workspaceId: active.workspaceId } : null);
|
|
251
|
+
},
|
|
252
|
+
/** session handler of `policy.decision` (runtime.js registers it): the answer to a policy.check the bridge asked */
|
|
253
|
+
handlePolicyDecision(ctx) { return policyCheck.handleDecision(ctx); },
|
|
254
|
+
async close() { policyCheck.close(); await Promise.allSettled([supervisor.stop(), bridge.close()]); },
|
|
255
|
+
stats, audit: auditor, bridge, supervisor, approvals, policyCheck,
|
|
256
|
+
get active() { return active ? active.jobId : null; },
|
|
257
|
+
};
|
|
258
|
+
}
|