@indigoai-us/hq-cli 5.239.0 → 5.241.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/CHANGELOG.md +36 -0
- package/dist/command-catalog.generated.d.ts +41 -0
- package/dist/command-catalog.generated.js +54 -0
- package/dist/commands/agent-enroll.d.ts +8 -0
- package/dist/commands/agent-enroll.js +19 -2
- package/dist/commands/lanes.js +194 -17
- package/dist/lib/agent-kit/creds.d.ts +30 -2
- package/dist/lib/agent-kit/creds.js +75 -6
- package/dist/lib/lanes/approval-gate.d.ts +8 -0
- package/dist/lib/lanes/approval-gate.js +90 -0
- package/dist/lib/lanes/claude-balance.d.ts +2 -0
- package/dist/lib/lanes/claude-balance.js +2 -1
- package/dist/lib/lanes/errors.d.ts +1 -1
- package/dist/lib/lanes/index.d.ts +3 -1
- package/dist/lib/lanes/index.js +2 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
- package/dist/lib/lanes/outpost-dispatch.js +1 -0
- package/dist/lib/lanes/outpost-registry-worker-approval.d.ts +18 -0
- package/dist/lib/lanes/outpost-registry-worker-approval.js +109 -0
- package/dist/lib/lanes/profile.d.ts +10 -0
- package/dist/lib/lanes/profile.js +88 -0
- package/dist/lib/lanes/reconcile.js +38 -11
- package/dist/lib/lanes/registry-persona.d.ts +20 -0
- package/dist/lib/lanes/registry-persona.js +329 -0
- package/dist/lib/lanes/registry.d.ts +4 -1
- package/dist/lib/lanes/registry.js +8 -3
- package/dist/lib/lanes/spawn.d.ts +1 -1
- package/dist/lib/lanes/spawn.js +28 -7
- package/dist/lib/lanes/store.js +90 -0
- package/dist/lib/lanes/types.d.ts +29 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.d.ts +2 -0
- package/dist/lib/lanes/watcher.js +46 -15
- package/package.json +1 -1
|
@@ -7,6 +7,22 @@
|
|
|
7
7
|
* module reads `~/.hq/cognito-tokens.json` — a human's session is never
|
|
8
8
|
* consulted, copied, or reused by an agent identity (policy
|
|
9
9
|
* never-extract-stored-session-tokens-to-forge-privileged-calls).
|
|
10
|
+
*
|
|
11
|
+
* NOT EVERY FILESYSTEM CAN KEEP THAT PROMISE. A CIFS/SMB mount (Azure Files
|
|
12
|
+
* is the one that found this) is mounted with a fixed `file_mode` and fails
|
|
13
|
+
* `chmod` outright with EPERM; an NFS export or a Docker volume may accept
|
|
14
|
+
* the call and ignore it. A bot whose only persistent storage is such a
|
|
15
|
+
* share used to be unable to enroll at all — enrollment died on the chmod,
|
|
16
|
+
* leaving a stray temp file behind, and the only alternative was to keep the
|
|
17
|
+
* identity on disposable storage where a rebuild erases it.
|
|
18
|
+
*
|
|
19
|
+
* So the write no longer fails there: it reports whether owner-only
|
|
20
|
+
* permissions actually took, and the caller says so out loud. The file is
|
|
21
|
+
* still requested 0600 at creation, the outcome is read back from the
|
|
22
|
+
* filesystem rather than assumed, and a host that cannot honour it learns
|
|
23
|
+
* that its credential is readable by anything else with access to the same
|
|
24
|
+
* share. Refusing to enroll would not have made that credential safer; it
|
|
25
|
+
* would only have moved it somewhere it silently disappears.
|
|
10
26
|
*/
|
|
11
27
|
import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
12
28
|
import * as fs from "node:fs";
|
|
@@ -23,20 +39,73 @@ export function generateHostKeyPair() {
|
|
|
23
39
|
const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
24
40
|
return { privatePem, publicPem, fingerprint: hostFingerprint(publicPem) };
|
|
25
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* chmod failures that mean "this filesystem does not do modes" rather than
|
|
44
|
+
* "something is wrong". Anything else still throws: a refused chmod on a
|
|
45
|
+
* local disk is a real fault and must not be swallowed.
|
|
46
|
+
*/
|
|
47
|
+
const MODE_UNSUPPORTED_CODES = new Set([
|
|
48
|
+
"EPERM",
|
|
49
|
+
"EACCES",
|
|
50
|
+
"EINVAL",
|
|
51
|
+
"ENOTSUP",
|
|
52
|
+
"EOPNOTSUPP",
|
|
53
|
+
]);
|
|
26
54
|
/** Write a file atomically with the given mode, replacing any existing one. */
|
|
27
55
|
function writePrivateFile(dest, contents, mode) {
|
|
28
56
|
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
57
|
+
// Date.now() as well as the pid: two writes from one process must not
|
|
58
|
+
// collide on a temp name, and a leftover from a crashed run must not be
|
|
59
|
+
// adopted by the next one.
|
|
60
|
+
const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
|
|
61
|
+
let chmodError;
|
|
62
|
+
try {
|
|
63
|
+
fs.writeFileSync(tmp, contents, { mode });
|
|
64
|
+
try {
|
|
65
|
+
fs.chmodSync(tmp, mode);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const code = err.code ?? "";
|
|
69
|
+
if (!MODE_UNSUPPORTED_CODES.has(code))
|
|
70
|
+
throw err;
|
|
71
|
+
chmodError = code;
|
|
72
|
+
}
|
|
73
|
+
// Read the truth back rather than trusting either call: a mount can
|
|
74
|
+
// accept chmod and ignore it just as easily as it can refuse.
|
|
75
|
+
let observedMode;
|
|
76
|
+
try {
|
|
77
|
+
observedMode = fs.statSync(tmp).mode & 0o777;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* stat is not worth failing a write over */
|
|
81
|
+
}
|
|
82
|
+
fs.renameSync(tmp, dest);
|
|
83
|
+
return {
|
|
84
|
+
enforced: observedMode === undefined ? !chmodError : (observedMode & 0o077) === 0,
|
|
85
|
+
...(observedMode === undefined ? {} : { observedMode }),
|
|
86
|
+
...(chmodError ? { chmodError } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// Never leave the temp file behind — it holds the same secret as the
|
|
91
|
+
// file that failed to land.
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(tmp);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* already gone */
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
33
100
|
}
|
|
34
101
|
export function writeHostKeyPair(paths, pair) {
|
|
35
|
-
writePrivateFile(paths.hostKeyPath, pair.privatePem, 0o600);
|
|
102
|
+
const key = writePrivateFile(paths.hostKeyPath, pair.privatePem, 0o600);
|
|
103
|
+
// The public half is public; its mode is not a promise worth reporting.
|
|
36
104
|
writePrivateFile(paths.hostKeyPubPath, pair.publicPem, 0o644);
|
|
105
|
+
return key;
|
|
37
106
|
}
|
|
38
107
|
export function writeMachineCreds(paths, creds) {
|
|
39
|
-
writePrivateFile(paths.machineCredsPath, `${JSON.stringify(creds, null, 2)}\n`, 0o600);
|
|
108
|
+
return writePrivateFile(paths.machineCredsPath, `${JSON.stringify(creds, null, 2)}\n`, 0o600);
|
|
40
109
|
}
|
|
41
110
|
/**
|
|
42
111
|
* Read the creds file without trusting it: returns null unless it is an
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LaneEnvelope, LaneRecord } from "./types.js";
|
|
2
|
+
export interface RegistryApprovalGateResult {
|
|
3
|
+
envelope: LaneEnvelope;
|
|
4
|
+
approvedIdsToConsume: string[];
|
|
5
|
+
violation: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function enforceRegistryApprovalGate(lane: LaneRecord, envelope: LaneEnvelope): RegistryApprovalGateResult;
|
|
8
|
+
//# sourceMappingURL=approval-gate.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
3
|
+
? value
|
|
4
|
+
: undefined;
|
|
5
|
+
}
|
|
6
|
+
function text(value) {
|
|
7
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
8
|
+
}
|
|
9
|
+
function externalActionCheckpoint(artifactValue) {
|
|
10
|
+
const artifact = record(artifactValue);
|
|
11
|
+
if (!artifact)
|
|
12
|
+
return undefined;
|
|
13
|
+
const kind = (text(artifact.kind) ?? text(artifact.type) ?? "").toLowerCase().replace(/[\s-]+/g, "_");
|
|
14
|
+
const status = (text(artifact.status) ?? text(artifact.state) ?? "").toLowerCase();
|
|
15
|
+
const hasDate = (...keys) => keys.some((key) => text(artifact[key]) !== undefined);
|
|
16
|
+
const url = text(artifact.url) ?? "";
|
|
17
|
+
const mergedPr = (["pr", "pull_request", "merged_pr"].includes(kind) ||
|
|
18
|
+
/\/(?:pull|pulls|merge_requests)\/\d+(?:\/|$)/i.test(url)) &&
|
|
19
|
+
(status === "merged" || artifact.merged === true || hasDate("merged_at")) ||
|
|
20
|
+
artifact.merged === true || status === "merged";
|
|
21
|
+
const pushedTag = (["tag", "git_tag"].includes(kind) ||
|
|
22
|
+
text(artifact.tag) !== undefined || text(artifact.tag_name) !== undefined) &&
|
|
23
|
+
(status === "pushed" || artifact.pushed === true || hasDate("pushed_at"));
|
|
24
|
+
const publishedPackage = (["package", "npm_package", "published_package"].includes(kind) ||
|
|
25
|
+
text(artifact.package) !== undefined || text(artifact.package_name) !== undefined) &&
|
|
26
|
+
(status === "published" || artifact.published === true || hasDate("published_at"));
|
|
27
|
+
const deployment = ["deploy", "deployment", "deployed"].includes(kind) &&
|
|
28
|
+
(status === "" || ["deployed", "success", "succeeded", "successful", "complete", "completed", "live"].includes(status) ||
|
|
29
|
+
artifact.deployed === true || hasDate("deployed_at"));
|
|
30
|
+
if (!mergedPr && !pushedTag && !publishedPackage && !deployment)
|
|
31
|
+
return undefined;
|
|
32
|
+
return text(artifact.checkpoint) ?? null;
|
|
33
|
+
}
|
|
34
|
+
function approvedEvents(history, workerId, workerYamlSha256) {
|
|
35
|
+
const consumed = new Set(history.flatMap((entry) => entry.event === "consumed" ? [entry.approval_id] : []));
|
|
36
|
+
return history.filter((entry) => entry.event === "approved" &&
|
|
37
|
+
entry.registry_worker_id === workerId &&
|
|
38
|
+
entry.worker_yaml_sha256 === workerYamlSha256 &&
|
|
39
|
+
!consumed.has(entry.approval_id));
|
|
40
|
+
}
|
|
41
|
+
export function enforceRegistryApprovalGate(lane, envelope) {
|
|
42
|
+
const registryWorker = lane.registry_worker;
|
|
43
|
+
const gateEnabled = registryWorker !== undefined &&
|
|
44
|
+
(registryWorker.approval_required || registryWorker.human_checkpoints.length > 0);
|
|
45
|
+
if (!gateEnabled || envelope.decision !== "done") {
|
|
46
|
+
return { envelope, approvedIdsToConsume: [], violation: false };
|
|
47
|
+
}
|
|
48
|
+
const actionCheckpoints = envelope.artifacts
|
|
49
|
+
.map(externalActionCheckpoint)
|
|
50
|
+
.filter((checkpoint) => checkpoint !== undefined);
|
|
51
|
+
if (actionCheckpoints.length === 0) {
|
|
52
|
+
return { envelope, approvedIdsToConsume: [], violation: false };
|
|
53
|
+
}
|
|
54
|
+
const missingCheckpoint = actionCheckpoints.includes(null);
|
|
55
|
+
const requiredCheckpoints = [...new Set(actionCheckpoints.filter((checkpoint) => checkpoint !== null))];
|
|
56
|
+
const configuredCheckpoints = registryWorker.human_checkpoints.length > 0
|
|
57
|
+
? new Set(registryWorker.human_checkpoints)
|
|
58
|
+
: new Set(["external_publish"]);
|
|
59
|
+
const approvals = approvedEvents(lane.registry_worker_approval_history ?? [], registryWorker.id, registryWorker.worker_yaml_sha256);
|
|
60
|
+
const used = [];
|
|
61
|
+
const missing = missingCheckpoint ? ["(checkpoint missing)"] : [];
|
|
62
|
+
for (const checkpoint of requiredCheckpoints) {
|
|
63
|
+
if (!configuredCheckpoints.has(checkpoint)) {
|
|
64
|
+
missing.push(checkpoint);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const approval = approvals.find((entry) => entry.checkpoint === checkpoint && !used.includes(entry.approval_id));
|
|
68
|
+
if (!approval)
|
|
69
|
+
missing.push(checkpoint);
|
|
70
|
+
else
|
|
71
|
+
used.push(approval.approval_id);
|
|
72
|
+
}
|
|
73
|
+
if (missing.length === 0) {
|
|
74
|
+
return { envelope, approvedIdsToConsume: used, violation: false };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
envelope: {
|
|
78
|
+
...envelope,
|
|
79
|
+
decision: "blocked",
|
|
80
|
+
summary: `External action requires senior approval at checkpoint: ${missing.join(", ")}.`,
|
|
81
|
+
story_status: "review",
|
|
82
|
+
blocked_reason: "approval_gate_violated",
|
|
83
|
+
story_complete: false,
|
|
84
|
+
story_complete_reason: "The reported external action has no matching recorded approval.",
|
|
85
|
+
},
|
|
86
|
+
approvedIdsToConsume: [],
|
|
87
|
+
violation: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=approval-gate.js.map
|
|
@@ -70,6 +70,8 @@ export declare function selectClaudeAccount(input: {
|
|
|
70
70
|
config: LanesConfig;
|
|
71
71
|
allowedAccounts?: readonly string[];
|
|
72
72
|
model?: string;
|
|
73
|
+
/** Keep the recorded account eligible for its already-running CLI model. */
|
|
74
|
+
recordedModelAccount?: string;
|
|
73
75
|
explicitAccount?: string;
|
|
74
76
|
unavailableHosts?: readonly BalanceHostGap[];
|
|
75
77
|
}): ClaudeAccountSelection;
|
|
@@ -104,7 +104,8 @@ function buildLoads(input) {
|
|
|
104
104
|
const hosts = input.hosts.map((host) => accountHostLoad(host, account));
|
|
105
105
|
const targetLoad = hosts.find((host) => sameHost(host.host, targetHost));
|
|
106
106
|
const configuredModel = configAccounts[account]?.model;
|
|
107
|
-
const ineligible = targetIneligibilityReason(account, configAccounts[account]?.enabled === true, input.allowedAccounts, targetLoad) ?? (
|
|
107
|
+
const ineligible = targetIneligibilityReason(account, configAccounts[account]?.enabled === true, input.allowedAccounts, targetLoad) ?? (account !== input.recordedModelAccount &&
|
|
108
|
+
input.model !== undefined && configuredModel !== undefined && configuredModel !== input.model
|
|
108
109
|
? "model_mismatch"
|
|
109
110
|
: undefined);
|
|
110
111
|
if (ineligible !== undefined)
|
|
@@ -34,6 +34,6 @@ export declare class AccountResolutionError extends LanesError {
|
|
|
34
34
|
constructor(error: "unknown_account" | "account_disabled" | "no_enabled_account" | "account_model_unavailable" | "account_quota_exhausted", message: string, details?: Record<string, unknown>);
|
|
35
35
|
}
|
|
36
36
|
export declare class WorkerResolutionError extends LanesError {
|
|
37
|
-
constructor(error: "worker_not_found" | "worker_tenancy_unresolved" | "worker_not_available" | "worker_not_active" | "worker_id_conflict" | "worker_profile_invalid" | "worker_policy_invalid" | "worker_profile_ambiguous" | "package_workers_unmounted" | "worker_registry_unreadable", message: string, details?: Record<string, unknown>);
|
|
37
|
+
constructor(error: "worker_not_found" | "worker_tenancy_unresolved" | "worker_not_available" | "worker_not_active" | "worker_id_conflict" | "worker_profile_invalid" | "worker_policy_invalid" | "worker_profile_ambiguous" | "package_workers_unmounted" | "worker_registry_unreadable" | "registry_worker_not_found" | "registry_worker_company_mismatch" | "registry_worker_inactive" | "registry_worker_invalid" | "registry_worker_unreadable" | "registry_worker_ambiguous", message: string, details?: Record<string, unknown>);
|
|
38
38
|
}
|
|
39
39
|
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -56,8 +56,10 @@ export type { KnownModels, KnownModelsDeps, ProbeCommand, ProbeResult } from "./
|
|
|
56
56
|
export { SESSION_MOVE_REASON, copySessionFiles, encodeClaudeProjectDir, findCodexRollout, planClaudeSessionCopy, planCodexSessionCopy, planSessionCopy, } from "./move-account.js";
|
|
57
57
|
export type { SessionCopyPlan, SessionCopyResult } from "./move-account.js";
|
|
58
58
|
export { WORKER_REGISTRY_REL, personalWorkerDir, personalWorkerExists, readWorkerRegistryFile, registryPath, resolveContextBasePaths, resolveLaneWorker, resolveSkillFiles, tenancyFromRegistryPath, } from "./registry.js";
|
|
59
|
-
export { ignoredWorkerProfileFields, loadWorkerProfile, materializeWorkerProfile, personalWorkerProfileDir, workerProfileMarker, workerProfilePrompt, } from "./profile.js";
|
|
59
|
+
export { ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithRegistry, materializeWorkerProfile, personalWorkerProfileDir, REGISTRY_PERSONA_PROMPT_MAX_CHARS, workerProfileMarker, workerProfilePrompt, } from "./profile.js";
|
|
60
|
+
export { loadRegistryPersonaFromRecord, registryWorkerRecord, resolveRegistryPersona, } from "./registry-persona.js";
|
|
60
61
|
export type { WorkerProfile, WorkerProfileMeta, WorkerProfileRuntime, } from "./profile.js";
|
|
62
|
+
export type { RegistryPersona } from "./registry-persona.js";
|
|
61
63
|
export type { RegistryWorkerEntry, ResolvedLaneWorker, WorkerTenancy, WorkerYaml, } from "./registry.js";
|
|
62
64
|
export { bodyStoreForLane, envelopeQuestionId, parseQuestionBodyStore, questionBodyAbsPath, questionBodyRef, raiseEnvelopeQuestions, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "./questions.js";
|
|
63
65
|
export type { QuestionBody, RaiseLaneQuestionInput } from "./questions.js";
|
package/dist/lib/lanes/index.js
CHANGED
|
@@ -31,7 +31,8 @@ export { PROVIDER_REJECTED_REASON, PROVIDER_REJECTION_MESSAGE_CAP, parseClaudeRe
|
|
|
31
31
|
export { CODEX_MODELS_CACHE_NAME, MODEL_PROBE_TIMEOUT_MS, claudeKnownModels, codexKnownModels, defaultProbe, grokKnownModels, knownModels, modelIsKnown, parseClaudeHelpModels, parseGrokModelsOutput, validateLaneModel, } from "./models.js";
|
|
32
32
|
export { SESSION_MOVE_REASON, copySessionFiles, encodeClaudeProjectDir, findCodexRollout, planClaudeSessionCopy, planCodexSessionCopy, planSessionCopy, } from "./move-account.js";
|
|
33
33
|
export { WORKER_REGISTRY_REL, personalWorkerDir, personalWorkerExists, readWorkerRegistryFile, registryPath, resolveContextBasePaths, resolveLaneWorker, resolveSkillFiles, tenancyFromRegistryPath, } from "./registry.js";
|
|
34
|
-
export { ignoredWorkerProfileFields, loadWorkerProfile, materializeWorkerProfile, personalWorkerProfileDir, workerProfileMarker, workerProfilePrompt, } from "./profile.js";
|
|
34
|
+
export { ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithRegistry, materializeWorkerProfile, personalWorkerProfileDir, REGISTRY_PERSONA_PROMPT_MAX_CHARS, workerProfileMarker, workerProfilePrompt, } from "./profile.js";
|
|
35
|
+
export { loadRegistryPersonaFromRecord, registryWorkerRecord, resolveRegistryPersona, } from "./registry-persona.js";
|
|
35
36
|
export { bodyStoreForLane, envelopeQuestionId, parseQuestionBodyStore, questionBodyAbsPath, questionBodyRef, raiseEnvelopeQuestions, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "./questions.js";
|
|
36
37
|
export { LANE_DELIVERY_MECHANISMS, LAUNCH_TRIGGERS, PROVIDER_SESSION_RESUME, PROVIDER_SESSION_PORTABLE, providerSessionPortable, sessionNotPortableMessage, parseGrokResultOutput, RESUMABLE_LANE_STATES, RESUME_COMMAND_STATES, SESSION_ID_ABSENT_REASONS, claimableStatesFor, deliveryRefusalReason, hasRecordedSessionId, isResumableLaneState, isResumeCommandState, laneDeliveryMechanism, parseClaudeResultOutput, parseCodexEventOutput, parseWorkerRunOutput, planLaneLaunch, providerSupportsSessionResume, readWorkerRunResult, resumeCommandRefusal, } from "./delivery.js";
|
|
37
38
|
export { DROPBOX_DIR, INBOX_NAME, appendDropboxMessage, answerDropboxMessage, drainDropbox, dropboxDir, dropboxInboxPath, formatOperatorInstruction, hookAdditionalContextJson, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneIdForAnswerDelivery, operatorMessageRefusalReason, readToolActivity, recordToolActivity, runDrainHook, toolActivityPath, TOOL_ACTIVITY_NAME, } from "./dropbox.js";
|
|
@@ -27,7 +27,7 @@ export interface OutpostDispatchResult {
|
|
|
27
27
|
stderr: string;
|
|
28
28
|
}
|
|
29
29
|
export interface OutpostLaneCommand {
|
|
30
|
-
action: "message" | "resume" | "stop" | "show" | "grant" | "grant-target" | "reparent-target";
|
|
30
|
+
action: "message" | "resume" | "stop" | "show" | "grant" | "grant-target" | "reparent-target" | "approve-target";
|
|
31
31
|
laneId: string;
|
|
32
32
|
hqBinary?: string;
|
|
33
33
|
text?: string;
|
|
@@ -43,6 +43,7 @@ export interface OutpostLaneCommand {
|
|
|
43
43
|
hire?: string;
|
|
44
44
|
grantContext?: string;
|
|
45
45
|
profileRepinContext?: string;
|
|
46
|
+
approvalContext?: string;
|
|
46
47
|
senior?: string;
|
|
47
48
|
reason?: string;
|
|
48
49
|
compact?: boolean;
|
|
@@ -151,6 +151,7 @@ export function buildOutpostLaneCommand(input) {
|
|
|
151
151
|
["--hire", input.hire],
|
|
152
152
|
["--grant-context", input.grantContext],
|
|
153
153
|
["--profile-repin-context", input.profileRepinContext],
|
|
154
|
+
["--approval-context", input.approvalContext],
|
|
154
155
|
["--failed-resume-context", input.failedResumeContext],
|
|
155
156
|
["--senior", input.senior],
|
|
156
157
|
["--reason", input.reason],
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SeniorRef } from "./types.js";
|
|
2
|
+
declare const APPROVAL_CONTEXT_SCHEMA: "hq-lane-registry-worker-approval-context.v1";
|
|
3
|
+
export interface OutpostRegistryWorkerApprovalContext {
|
|
4
|
+
schema_version: typeof APPROVAL_CONTEXT_SCHEMA;
|
|
5
|
+
lane_id: string;
|
|
6
|
+
expected_senior: SeniorRef;
|
|
7
|
+
actor: string;
|
|
8
|
+
checkpoint: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
approval_id: string;
|
|
11
|
+
registry_worker_id: string;
|
|
12
|
+
worker_yaml_sha256: string;
|
|
13
|
+
at: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function createOutpostRegistryWorkerApprovalContext(input: Omit<OutpostRegistryWorkerApprovalContext, "schema_version">): string;
|
|
16
|
+
export declare function parseOutpostRegistryWorkerApprovalContext(encoded: string, expectedLaneId: string): OutpostRegistryWorkerApprovalContext;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=outpost-registry-worker-approval.d.ts.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { LanesError } from "./errors.js";
|
|
2
|
+
import { isSeniorKind } from "./types.js";
|
|
3
|
+
const APPROVAL_CONTEXT_SCHEMA = "hq-lane-registry-worker-approval-context.v1";
|
|
4
|
+
const MAX_CONTEXT_BYTES = 16 * 1024;
|
|
5
|
+
function contextError(message) {
|
|
6
|
+
return new LanesError("registry_worker_approval_context_invalid", message, {});
|
|
7
|
+
}
|
|
8
|
+
function copySenior(senior) {
|
|
9
|
+
return senior.id === undefined
|
|
10
|
+
? { kind: senior.kind }
|
|
11
|
+
: { kind: senior.kind, id: senior.id };
|
|
12
|
+
}
|
|
13
|
+
function parseSenior(value) {
|
|
14
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
15
|
+
return null;
|
|
16
|
+
const record = value;
|
|
17
|
+
if (Object.keys(record).some((key) => key !== "kind" && key !== "id"))
|
|
18
|
+
return null;
|
|
19
|
+
if (!isSeniorKind(record.kind))
|
|
20
|
+
return null;
|
|
21
|
+
if (record.id !== undefined &&
|
|
22
|
+
(typeof record.id !== "string" || !record.id.trim() || record.id.trim() !== record.id))
|
|
23
|
+
return null;
|
|
24
|
+
if ((record.kind === "session" || record.kind === "lane") && record.id === undefined)
|
|
25
|
+
return null;
|
|
26
|
+
if (record.kind === "user" && record.id !== undefined)
|
|
27
|
+
return null;
|
|
28
|
+
return copySenior({ kind: record.kind, ...(record.id === undefined ? {} : { id: record.id }) });
|
|
29
|
+
}
|
|
30
|
+
function nonEmpty(value) {
|
|
31
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
32
|
+
}
|
|
33
|
+
export function createOutpostRegistryWorkerApprovalContext(input) {
|
|
34
|
+
if (!nonEmpty(input.lane_id) ||
|
|
35
|
+
!nonEmpty(input.actor) ||
|
|
36
|
+
!nonEmpty(input.checkpoint) ||
|
|
37
|
+
!nonEmpty(input.reason) ||
|
|
38
|
+
!nonEmpty(input.approval_id) ||
|
|
39
|
+
!nonEmpty(input.registry_worker_id) ||
|
|
40
|
+
!/^sha256:[a-f0-9]{64}$/.test(input.worker_yaml_sha256) ||
|
|
41
|
+
!Number.isFinite(Date.parse(input.at))) {
|
|
42
|
+
throw contextError("Outpost registry worker approval fields are incomplete");
|
|
43
|
+
}
|
|
44
|
+
const context = {
|
|
45
|
+
schema_version: APPROVAL_CONTEXT_SCHEMA,
|
|
46
|
+
...input,
|
|
47
|
+
expected_senior: copySenior(input.expected_senior),
|
|
48
|
+
};
|
|
49
|
+
const encoded = Buffer.from(JSON.stringify(context), "utf8").toString("base64url");
|
|
50
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_CONTEXT_BYTES) {
|
|
51
|
+
throw contextError(`Registry worker approval context for lane ${input.lane_id} is too large`);
|
|
52
|
+
}
|
|
53
|
+
return encoded;
|
|
54
|
+
}
|
|
55
|
+
export function parseOutpostRegistryWorkerApprovalContext(encoded, expectedLaneId) {
|
|
56
|
+
if (encoded.length === 0 ||
|
|
57
|
+
encoded.length > MAX_CONTEXT_BYTES ||
|
|
58
|
+
!/^[A-Za-z0-9_-]+$/.test(encoded) ||
|
|
59
|
+
Buffer.from(encoded, "base64url").toString("base64url") !== encoded) {
|
|
60
|
+
throw contextError("Registry worker approval context must be bounded canonical base64url text");
|
|
61
|
+
}
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(encoded, "base64url"));
|
|
65
|
+
raw = JSON.parse(decoded);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw contextError("Registry worker approval context is not valid UTF-8 JSON");
|
|
69
|
+
}
|
|
70
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
71
|
+
throw contextError("Registry worker approval context must be a JSON object");
|
|
72
|
+
}
|
|
73
|
+
const record = raw;
|
|
74
|
+
const allowed = [
|
|
75
|
+
"schema_version", "lane_id", "expected_senior", "actor", "checkpoint", "reason",
|
|
76
|
+
"approval_id", "registry_worker_id", "worker_yaml_sha256", "at",
|
|
77
|
+
];
|
|
78
|
+
if (Object.keys(record).some((key) => !allowed.includes(key))) {
|
|
79
|
+
throw contextError("Registry worker approval context contains unknown fields");
|
|
80
|
+
}
|
|
81
|
+
const expectedSenior = parseSenior(record.expected_senior);
|
|
82
|
+
if (record.schema_version !== APPROVAL_CONTEXT_SCHEMA ||
|
|
83
|
+
record.lane_id !== expectedLaneId ||
|
|
84
|
+
expectedSenior === null ||
|
|
85
|
+
!nonEmpty(record.actor) ||
|
|
86
|
+
!nonEmpty(record.checkpoint) ||
|
|
87
|
+
!nonEmpty(record.reason) ||
|
|
88
|
+
!nonEmpty(record.approval_id) ||
|
|
89
|
+
!nonEmpty(record.registry_worker_id) ||
|
|
90
|
+
typeof record.worker_yaml_sha256 !== "string" ||
|
|
91
|
+
!/^sha256:[a-f0-9]{64}$/.test(record.worker_yaml_sha256) ||
|
|
92
|
+
typeof record.at !== "string" ||
|
|
93
|
+
!Number.isFinite(Date.parse(record.at))) {
|
|
94
|
+
throw contextError(`Registry worker approval context is invalid for lane ${expectedLaneId}`);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
schema_version: APPROVAL_CONTEXT_SCHEMA,
|
|
98
|
+
lane_id: expectedLaneId,
|
|
99
|
+
expected_senior: expectedSenior,
|
|
100
|
+
actor: record.actor,
|
|
101
|
+
checkpoint: record.checkpoint,
|
|
102
|
+
reason: record.reason,
|
|
103
|
+
approval_id: record.approval_id,
|
|
104
|
+
registry_worker_id: record.registry_worker_id,
|
|
105
|
+
worker_yaml_sha256: record.worker_yaml_sha256,
|
|
106
|
+
at: record.at,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=outpost-registry-worker-approval.js.map
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* must be re-read for every spawn.
|
|
8
8
|
*/
|
|
9
9
|
import { type LaneRecord, type LaneProvider } from "./types.js";
|
|
10
|
+
import type { RegistryWorkerRecord } from "./types.js";
|
|
11
|
+
import { type RegistryPersona } from "./registry-persona.js";
|
|
12
|
+
export declare const REGISTRY_PERSONA_PROMPT_MAX_CHARS = 12000;
|
|
10
13
|
export interface WorkerProfileMeta {
|
|
11
14
|
version?: string;
|
|
12
15
|
provider: LaneProvider;
|
|
@@ -18,6 +21,7 @@ export interface WorkerProfileMeta {
|
|
|
18
21
|
source?: string;
|
|
19
22
|
accounts?: string[];
|
|
20
23
|
keep_alive?: boolean;
|
|
24
|
+
registry_worker?: string;
|
|
21
25
|
}
|
|
22
26
|
export interface WorkerProfile {
|
|
23
27
|
id: string;
|
|
@@ -28,6 +32,7 @@ export interface WorkerProfile {
|
|
|
28
32
|
policyFiles: string[];
|
|
29
33
|
hookFiles: string[];
|
|
30
34
|
contentHash: string;
|
|
35
|
+
registryWorker?: RegistryPersona;
|
|
31
36
|
}
|
|
32
37
|
export interface WorkerProfileRuntime {
|
|
33
38
|
settingsPath?: string;
|
|
@@ -41,6 +46,11 @@ export declare function personalWorkerProfileDir(hqRoot: string, id: string): st
|
|
|
41
46
|
/** A meta.yaml file is the explicit marker for a personal worker profile. */
|
|
42
47
|
export declare function workerProfileMarker(hqRoot: string, id: string): "absent" | "present";
|
|
43
48
|
export declare function loadWorkerProfile(hqRoot: string, id: string, options?: LoadWorkerProfileOptions): WorkerProfile;
|
|
49
|
+
export declare function loadWorkerProfileWithRegistry(hqRoot: string, id: string, options?: {
|
|
50
|
+
laneCompany?: string;
|
|
51
|
+
registryWorkerRecord?: RegistryWorkerRecord;
|
|
52
|
+
onUnknownMetaFields?: (fields: readonly string[]) => void;
|
|
53
|
+
}): WorkerProfile;
|
|
44
54
|
export interface EffectiveLaneKeepAlive {
|
|
45
55
|
enabled: boolean;
|
|
46
56
|
source: "lane" | "none";
|
|
@@ -13,11 +13,13 @@ import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
|
13
13
|
import { WorkerResolutionError } from "./errors.js";
|
|
14
14
|
import { PROVIDER_EFFORT_LEVELS } from "./effort.js";
|
|
15
15
|
import { LANE_PROVIDERS, isLaneProvider, } from "./types.js";
|
|
16
|
+
import { loadRegistryPersonaFromRecord, registryWorkerRecord, resolveRegistryPersona, } from "./registry-persona.js";
|
|
16
17
|
const PROFILE_META = "meta.yaml";
|
|
17
18
|
const PROFILE_SYSTEM_PROMPT = "system-prompt.md";
|
|
18
19
|
const PROFILE_SETTINGS = "settings.json";
|
|
19
20
|
const PROFILE_POLICIES = "policies";
|
|
20
21
|
const PROFILE_HOOKS = "hooks";
|
|
22
|
+
export const REGISTRY_PERSONA_PROMPT_MAX_CHARS = 12_000;
|
|
21
23
|
const PROFILE_META_KEYS = new Set([
|
|
22
24
|
"version",
|
|
23
25
|
"provider",
|
|
@@ -29,6 +31,7 @@ const PROFILE_META_KEYS = new Set([
|
|
|
29
31
|
"source",
|
|
30
32
|
"accounts",
|
|
31
33
|
"keep_alive",
|
|
34
|
+
"registry_worker",
|
|
32
35
|
]);
|
|
33
36
|
function isPlainObject(value) {
|
|
34
37
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -214,6 +217,7 @@ function parseMeta(id, dir, raw, options = {}) {
|
|
|
214
217
|
}
|
|
215
218
|
accounts = raw.accounts.map((item) => item.trim());
|
|
216
219
|
}
|
|
220
|
+
const registryWorker = optionalString("registry_worker");
|
|
217
221
|
return {
|
|
218
222
|
...(version !== undefined ? { version } : {}),
|
|
219
223
|
provider: providerValue,
|
|
@@ -225,6 +229,7 @@ function parseMeta(id, dir, raw, options = {}) {
|
|
|
225
229
|
...(maxConcurrent !== undefined ? { max_concurrent: maxConcurrent } : {}),
|
|
226
230
|
...(source !== undefined ? { source } : {}),
|
|
227
231
|
...(accounts !== undefined ? { accounts } : {}),
|
|
232
|
+
...(registryWorker !== undefined ? { registry_worker: registryWorker } : {}),
|
|
228
233
|
};
|
|
229
234
|
}
|
|
230
235
|
function validatePolicyFrontmatter(id, profileDir, relative) {
|
|
@@ -373,6 +378,39 @@ export function loadWorkerProfile(hqRoot, id, options = {}) {
|
|
|
373
378
|
contentHash: hashProfileFiles(dir, hashFiles),
|
|
374
379
|
};
|
|
375
380
|
}
|
|
381
|
+
export function loadWorkerProfileWithRegistry(hqRoot, id, options = {}) {
|
|
382
|
+
const profile = loadWorkerProfile(hqRoot, id, options);
|
|
383
|
+
const requestedId = profile.meta.registry_worker;
|
|
384
|
+
if (requestedId === undefined) {
|
|
385
|
+
if (options.registryWorkerRecord !== undefined) {
|
|
386
|
+
throw new WorkerResolutionError("registry_worker_invalid", `Worker profile ${JSON.stringify(id)} no longer names its recorded registry worker`, { worker: id, registry_worker: options.registryWorkerRecord.id });
|
|
387
|
+
}
|
|
388
|
+
return profile;
|
|
389
|
+
}
|
|
390
|
+
const persona = options.registryWorkerRecord !== undefined
|
|
391
|
+
? (() => {
|
|
392
|
+
if (options.registryWorkerRecord?.id !== requestedId) {
|
|
393
|
+
throw new WorkerResolutionError("registry_worker_invalid", `Worker profile ${JSON.stringify(id)} names registry worker ${JSON.stringify(requestedId)}, but the lane records ${JSON.stringify(options.registryWorkerRecord?.id)}`, {
|
|
394
|
+
worker: id,
|
|
395
|
+
profile_registry_worker: requestedId,
|
|
396
|
+
lane_registry_worker: options.registryWorkerRecord?.id,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return loadRegistryPersonaFromRecord(hqRoot, options.registryWorkerRecord);
|
|
400
|
+
})()
|
|
401
|
+
: resolveRegistryPersona(hqRoot, requestedId, options.laneCompany);
|
|
402
|
+
const record = registryWorkerRecord(persona);
|
|
403
|
+
const hash = createHash("sha256")
|
|
404
|
+
.update(profile.contentHash)
|
|
405
|
+
.update("\0registry_worker\0")
|
|
406
|
+
.update(JSON.stringify({ id: record.id, path: record.path, worker_yaml_sha256: record.worker_yaml_sha256 }))
|
|
407
|
+
.digest("hex");
|
|
408
|
+
return {
|
|
409
|
+
...profile,
|
|
410
|
+
registryWorker: persona,
|
|
411
|
+
contentHash: `sha256:${hash}`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
376
414
|
/** Resolve keep-alive from the lane record; profile metadata only seeds new spawns. */
|
|
377
415
|
export function resolveLaneKeepAlive(_hqRoot, lane) {
|
|
378
416
|
return lane.keep_alive === undefined
|
|
@@ -381,6 +419,9 @@ export function resolveLaneKeepAlive(_hqRoot, lane) {
|
|
|
381
419
|
}
|
|
382
420
|
export function workerProfilePrompt(profile, delivery = "prompt") {
|
|
383
421
|
const parts = [profile.systemPrompt.trimEnd()];
|
|
422
|
+
if (profile.registryWorker !== undefined) {
|
|
423
|
+
parts.push(formatRegistryWorkerPrompt(profile.registryWorker));
|
|
424
|
+
}
|
|
384
425
|
if (delivery === "hook")
|
|
385
426
|
return parts.join("\n");
|
|
386
427
|
for (const relative of profile.policyFiles) {
|
|
@@ -389,6 +430,53 @@ export function workerProfilePrompt(profile, delivery = "prompt") {
|
|
|
389
430
|
}
|
|
390
431
|
return parts.join("\n");
|
|
391
432
|
}
|
|
433
|
+
function formatRegistryWorkerPrompt(persona) {
|
|
434
|
+
const lines = [
|
|
435
|
+
"## Registry worker",
|
|
436
|
+
`Description: ${persona.description}`,
|
|
437
|
+
"",
|
|
438
|
+
"Instructions:",
|
|
439
|
+
persona.instructions || "No additional instructions were supplied.",
|
|
440
|
+
];
|
|
441
|
+
if (persona.skillFiles.length > 0) {
|
|
442
|
+
lines.push("", "Skills (path references only; do not inline these files):");
|
|
443
|
+
for (const file of persona.skillFiles)
|
|
444
|
+
lines.push(`- ${file}`);
|
|
445
|
+
}
|
|
446
|
+
if (persona.postExecute.length > 0) {
|
|
447
|
+
lines.push("", "Done criteria:");
|
|
448
|
+
for (const command of persona.postExecute)
|
|
449
|
+
lines.push(`- ${command}`);
|
|
450
|
+
}
|
|
451
|
+
const safetyLines = [];
|
|
452
|
+
if (persona.humanCheckpoints.length > 0) {
|
|
453
|
+
safetyLines.push("Human checkpoints:");
|
|
454
|
+
for (const checkpoint of persona.humanCheckpoints) {
|
|
455
|
+
safetyLines.push(`- STOP before proceeding with ${checkpoint}. Report that this checkpoint is reached to the senior and wait for approval. Do not perform the action before it is approved.`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else if (persona.approvalRequired) {
|
|
459
|
+
safetyLines.push("Human checkpoint:", "- STOP before any external publish action. Report checkpoint external_publish to the senior and wait for approval. Do not perform the action before it is approved.");
|
|
460
|
+
}
|
|
461
|
+
if (persona.approvalRequired || persona.humanCheckpoints.length > 0) {
|
|
462
|
+
safetyLines.push("For an approved external action, include the exact approved checkpoint in the artifact's checkpoint field. Each approval applies to the next matching envelope only.");
|
|
463
|
+
}
|
|
464
|
+
if (safetyLines.length > 0)
|
|
465
|
+
lines.push("");
|
|
466
|
+
const complete = [...lines, ...safetyLines].join("\n");
|
|
467
|
+
if (complete.length <= REGISTRY_PERSONA_PROMPT_MAX_CHARS)
|
|
468
|
+
return complete;
|
|
469
|
+
const suffix = [
|
|
470
|
+
"",
|
|
471
|
+
`[Registry worker section truncated at ${REGISTRY_PERSONA_PROMPT_MAX_CHARS} characters; descriptive material, skill paths, or done criteria may be omitted.]`,
|
|
472
|
+
...(safetyLines.length > 0 ? ["", ...safetyLines] : []),
|
|
473
|
+
].join("\n");
|
|
474
|
+
const prefixBudget = REGISTRY_PERSONA_PROMPT_MAX_CHARS - suffix.length;
|
|
475
|
+
if (prefixBudget < 0) {
|
|
476
|
+
throw new WorkerResolutionError("registry_worker_invalid", `Registry worker ${JSON.stringify(persona.id)} checkpoint instructions exceed the bounded prompt limit`, { worker: persona.id, max_chars: REGISTRY_PERSONA_PROMPT_MAX_CHARS });
|
|
477
|
+
}
|
|
478
|
+
return `${lines.join("\n").slice(0, prefixBudget).trimEnd()}${suffix}`;
|
|
479
|
+
}
|
|
392
480
|
export function ignoredWorkerProfileFields(provider) {
|
|
393
481
|
return provider === "claude" ? [] : ["settings", "hooks"];
|
|
394
482
|
}
|