@cydm/happy-elves 0.1.0-beta.500 → 0.1.0-beta.501
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/README.md +2 -2
- package/apps/cli/package.json +1 -1
- package/apps/daemon/dist/orchestration/runner.js +22 -10
- package/apps/daemon/dist/orchestration/store.d.ts +5 -0
- package/apps/daemon/dist/orchestration/store.js +27 -0
- package/apps/daemon/dist/orchestration/watcher.js +26 -3
- package/apps/daemon/dist/relay/connection.js +44 -24
- package/apps/daemon/package.json +1 -1
- package/apps/updater/dist/daemon.js +3 -2
- package/apps/updater/dist/managed-environment.d.ts +11 -0
- package/apps/updater/dist/managed-environment.js +125 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ Public beta CLI for Happy Elves.
|
|
|
6
6
|
|
|
7
7
|
Run once in the existing user account; it detects %USERPROFILE%\.happy-elves\hosted, keeps the current daemon and sessions running, installs the independent updater, and registers per-user startup.
|
|
8
8
|
|
|
9
|
-
npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.
|
|
10
|
-
npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.
|
|
9
|
+
npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.501
|
|
10
|
+
npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.501 -- happy-elves-updater install --state-root $HOME\.happy-elves\hosted --autostart
|
|
11
11
|
|
|
12
12
|
The same updater entrypoint works on macOS after selecting a permanent user prefix.
|
|
13
13
|
|
package/apps/cli/package.json
CHANGED
|
@@ -10,7 +10,7 @@ import { rehydrateRuntimeSession } from "../session/lifecycle.js";
|
|
|
10
10
|
import { runtimeRead } from "../session/runtime-truth.js";
|
|
11
11
|
import { encryptedSessionMetadata } from "../session/metadata.js";
|
|
12
12
|
import { loadSessionProjections } from "../session/store.js";
|
|
13
|
-
import { appendAttention, expireInflightLeases, leaseNextAttention, latestAttentionForTask, markAttentionDelivered, markAttentionDeliveryFailed, markSubscriptionLiveness, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, readOrchestrationStore, } from "./store.js";
|
|
13
|
+
import { appendAttention, closeSubscriptionForTurn, expireInflightLeases, leaseNextAttention, latestAttentionForTask, markAttentionDelivered, markAttentionDeliveryFailed, markSubscriptionLiveness, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, readOrchestrationStore, } from "./store.js";
|
|
14
14
|
import { attentionFromReconcile, catchUpSubscriptionEvents } from "./watcher.js";
|
|
15
15
|
import { scheduleRemoteOrchestrationWork } from "./remote-work-budget.js";
|
|
16
16
|
import { resolveWakeIdentity } from "./wake-identity.js";
|
|
@@ -146,9 +146,6 @@ async function reconcileSubscriptionsOnce(config) {
|
|
|
146
146
|
if (reconciledSubscriptions.has(key))
|
|
147
147
|
continue;
|
|
148
148
|
reconciledSubscriptions.add(key);
|
|
149
|
-
const latest = await latestAttentionForTask(subscription.taskId);
|
|
150
|
-
if (latest?.eventTypes.some((type) => type === "terminal" || type === "failure"))
|
|
151
|
-
continue;
|
|
152
149
|
if (crewMachineId !== config.machineId) {
|
|
153
150
|
// Remote reconcile shares the same bounded/fair scheduler as transcript
|
|
154
151
|
// catch-up. Startup recovery must not create an unbounded second fleet
|
|
@@ -252,8 +249,20 @@ async function reconcileRemoteSubscription(config, subscription) {
|
|
|
252
249
|
return;
|
|
253
250
|
}
|
|
254
251
|
const attention = attentionFromReconcile(result);
|
|
255
|
-
if (!attention
|
|
252
|
+
if (!attention)
|
|
256
253
|
return;
|
|
254
|
+
const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
|
|
255
|
+
if (!subscription.eventTypes.includes(attention.eventType)) {
|
|
256
|
+
if (closesTurn) {
|
|
257
|
+
await closeSubscriptionForTurn({
|
|
258
|
+
taskId: subscription.taskId,
|
|
259
|
+
consumerSessionId: subscription.consumerSessionId,
|
|
260
|
+
crewSessionId: subscription.crewSessionId,
|
|
261
|
+
turnId: subscription.turnId,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
257
266
|
await appendAttention({
|
|
258
267
|
taskId: subscription.taskId,
|
|
259
268
|
consumerMachineId: subscription.consumerMachineId ?? config.machineId,
|
|
@@ -270,6 +279,7 @@ async function reconcileRemoteSubscription(config, subscription) {
|
|
|
270
279
|
turnId: subscription.turnId,
|
|
271
280
|
ref: result.status,
|
|
272
281
|
},
|
|
282
|
+
closeSubscription: closesTurn,
|
|
273
283
|
});
|
|
274
284
|
}
|
|
275
285
|
catch (error) {
|
|
@@ -307,14 +317,16 @@ async function markStaleSubscriptions(config) {
|
|
|
307
317
|
if (Number.isFinite(lastSeenAt) && now - lastSeenAt < staleMs)
|
|
308
318
|
continue;
|
|
309
319
|
const latest = await latestAttentionForTask(subscription.taskId);
|
|
310
|
-
if (latest?.
|
|
311
|
-
continue;
|
|
312
|
-
if (latest?.eventTypes.includes("stale") && latest.status !== "acked")
|
|
320
|
+
if (latest?.turnId === subscription.turnId && latest.eventTypes.includes("stale") && latest.status !== "acked")
|
|
313
321
|
continue;
|
|
314
322
|
if ((subscription.crewMachineId ?? config.machineId) === config.machineId) {
|
|
315
323
|
await reconcileLocalSubscription(config, subscription, "stale-check");
|
|
316
|
-
const
|
|
317
|
-
|
|
324
|
+
const reconciledStore = await readOrchestrationStore();
|
|
325
|
+
const currentSubscription = reconciledStore.subscriptions.find((record) => record.taskId === subscription.taskId &&
|
|
326
|
+
record.consumerSessionId === subscription.consumerSessionId &&
|
|
327
|
+
record.crewSessionId === subscription.crewSessionId &&
|
|
328
|
+
record.turnId === subscription.turnId);
|
|
329
|
+
if (!currentSubscription || currentSubscription.status !== "active")
|
|
318
330
|
continue;
|
|
319
331
|
if (await localSubscriptionTurnIsActive(subscription))
|
|
320
332
|
continue;
|
|
@@ -123,6 +123,8 @@ export type AttentionInput = {
|
|
|
123
123
|
eventType: TaskAttentionEventType;
|
|
124
124
|
reason: string;
|
|
125
125
|
payloadRef?: TaskAttentionPayloadRef;
|
|
126
|
+
/** Terminal/failure attention closes the exact turn subscription atomically. */
|
|
127
|
+
closeSubscription?: boolean;
|
|
126
128
|
now?: string;
|
|
127
129
|
};
|
|
128
130
|
export declare function readOrchestrationStore(): Promise<OrchestrationStore>;
|
|
@@ -249,6 +251,9 @@ export declare function markSubscriptionRemoteScan(taskId: string, input: {
|
|
|
249
251
|
at?: string;
|
|
250
252
|
}): Promise<void>;
|
|
251
253
|
export declare function appendAttention(input: AttentionInput): Promise<TaskAttentionRecord[]>;
|
|
254
|
+
export declare function closeSubscriptionForTurn(input: Pick<AttentionInput, "taskId" | "consumerSessionId" | "crewSessionId" | "turnId"> & {
|
|
255
|
+
at?: string;
|
|
256
|
+
}): Promise<void>;
|
|
252
257
|
export declare function listAttentions(input?: {
|
|
253
258
|
consumerSessionId?: string;
|
|
254
259
|
status?: TaskAttentionStatus[];
|
|
@@ -794,6 +794,9 @@ export async function appendAttention(input) {
|
|
|
794
794
|
let updated;
|
|
795
795
|
const store = await updateOrchestrationStore((current) => {
|
|
796
796
|
updated = appendAttentionRecord(current, { ...input, now: input.now ?? new Date().toISOString() });
|
|
797
|
+
if (input.closeSubscription && (input.eventType === "terminal" || input.eventType === "failure")) {
|
|
798
|
+
closeSubscriptionForTurnRecord(current, input);
|
|
799
|
+
}
|
|
797
800
|
return current;
|
|
798
801
|
});
|
|
799
802
|
if (!updated)
|
|
@@ -802,6 +805,30 @@ export async function appendAttention(input) {
|
|
|
802
805
|
record.consumerSessionId === updated?.consumerSessionId &&
|
|
803
806
|
record.eventCursor === updated?.eventCursor);
|
|
804
807
|
}
|
|
808
|
+
export async function closeSubscriptionForTurn(input) {
|
|
809
|
+
if (!input.turnId)
|
|
810
|
+
return;
|
|
811
|
+
const at = input.at ?? new Date().toISOString();
|
|
812
|
+
await updateOrchestrationStore((current) => {
|
|
813
|
+
closeSubscriptionForTurnRecord(current, { ...input, now: at });
|
|
814
|
+
return current;
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
function closeSubscriptionForTurnRecord(store, input) {
|
|
818
|
+
if (!input.turnId)
|
|
819
|
+
return;
|
|
820
|
+
const now = input.now ?? new Date().toISOString();
|
|
821
|
+
for (const subscription of store.subscriptions) {
|
|
822
|
+
if (subscription.status !== "active")
|
|
823
|
+
continue;
|
|
824
|
+
if (subscription.taskId !== input.taskId || subscription.consumerSessionId !== input.consumerSessionId)
|
|
825
|
+
continue;
|
|
826
|
+
if (subscription.crewSessionId !== input.crewSessionId || subscription.turnId !== input.turnId)
|
|
827
|
+
continue;
|
|
828
|
+
subscription.status = "deleted";
|
|
829
|
+
subscription.updatedAt = now;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
805
832
|
function appendAttentionRecord(store, input) {
|
|
806
833
|
const index = store.attentions.findIndex((record) => record.taskId === input.taskId && record.consumerSessionId === input.consumerSessionId);
|
|
807
834
|
const payloadRefs = input.payloadRef ? [input.payloadRef] : [];
|
|
@@ -2,7 +2,7 @@ import { randomId, } from "../../../../packages/shared/dist/index.js";
|
|
|
2
2
|
import { appendAudit } from "../audit.js";
|
|
3
3
|
import { RemoteCommandError, requestRemoteMachineResult } from "../relay/remote-command.js";
|
|
4
4
|
import { runtimeRead } from "../session/runtime-truth.js";
|
|
5
|
-
import { activeSubscriptionsForTurn, appendAttention, markSubscriptionLiveness, markSubscriptionRemoteScan, recordTurnRuntimeBinding, } from "./store.js";
|
|
5
|
+
import { activeSubscriptionsForTurn, appendAttention, closeSubscriptionForTurn, markSubscriptionLiveness, markSubscriptionRemoteScan, recordTurnRuntimeBinding, } from "./store.js";
|
|
6
6
|
import { scheduleRemoteOrchestrationWork } from "./remote-work-budget.js";
|
|
7
7
|
import { relayMachineRegistry } from "../state.js";
|
|
8
8
|
const remoteCatchupsInFlight = new Map();
|
|
@@ -21,8 +21,18 @@ export async function observeSessionEvent(input) {
|
|
|
21
21
|
}
|
|
22
22
|
const subscriptions = await activeSubscriptionsForTurn(input.sessionId, input.turnId);
|
|
23
23
|
for (const subscription of subscriptions) {
|
|
24
|
-
|
|
24
|
+
const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
|
|
25
|
+
if (!subscription.eventTypes.includes(attention.eventType)) {
|
|
26
|
+
if (closesTurn) {
|
|
27
|
+
await closeSubscriptionForTurn({
|
|
28
|
+
taskId: subscription.taskId,
|
|
29
|
+
consumerSessionId: subscription.consumerSessionId,
|
|
30
|
+
crewSessionId: subscription.crewSessionId,
|
|
31
|
+
turnId: subscription.turnId,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
25
34
|
continue;
|
|
35
|
+
}
|
|
26
36
|
await appendAttention({
|
|
27
37
|
taskId: subscription.taskId,
|
|
28
38
|
consumerMachineId: subscription.consumerMachineId ?? input.config.machineId,
|
|
@@ -39,6 +49,7 @@ export async function observeSessionEvent(input) {
|
|
|
39
49
|
turnId: input.turnId,
|
|
40
50
|
eventSeq: input.seq,
|
|
41
51
|
},
|
|
52
|
+
closeSubscription: closesTurn,
|
|
42
53
|
});
|
|
43
54
|
await appendOrchestrationAudit(input.config, "orchestration.attention.append", `Recorded ${attention.eventType} attention for ${subscription.taskId}`, {
|
|
44
55
|
taskId: subscription.taskId,
|
|
@@ -89,6 +100,7 @@ export async function observeTurnReconcile(input) {
|
|
|
89
100
|
turnId: input.result.turnId,
|
|
90
101
|
ref: input.result.status,
|
|
91
102
|
},
|
|
103
|
+
closeSubscription: attention.eventType === "terminal" || attention.eventType === "failure",
|
|
92
104
|
});
|
|
93
105
|
await appendOrchestrationAudit(input.config, "orchestration.attention.reconcile", `Recorded ${attention.eventType} reconcile attention for ${subscription.taskId}`, {
|
|
94
106
|
taskId: subscription.taskId,
|
|
@@ -360,8 +372,18 @@ async function observeSubscriptionPayload(input) {
|
|
|
360
372
|
}
|
|
361
373
|
return;
|
|
362
374
|
}
|
|
363
|
-
|
|
375
|
+
const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
|
|
376
|
+
if (!input.subscription.eventTypes.includes(attention.eventType)) {
|
|
377
|
+
if (closesTurn) {
|
|
378
|
+
await closeSubscriptionForTurn({
|
|
379
|
+
taskId: input.subscription.taskId,
|
|
380
|
+
consumerSessionId: input.subscription.consumerSessionId,
|
|
381
|
+
crewSessionId: input.subscription.crewSessionId,
|
|
382
|
+
turnId: input.subscription.turnId,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
364
385
|
return;
|
|
386
|
+
}
|
|
365
387
|
await appendAttention({
|
|
366
388
|
taskId: input.subscription.taskId,
|
|
367
389
|
consumerMachineId: input.subscription.consumerMachineId ?? input.config.machineId,
|
|
@@ -378,6 +400,7 @@ async function observeSubscriptionPayload(input) {
|
|
|
378
400
|
turnId: input.subscription.turnId,
|
|
379
401
|
eventSeq: input.seq,
|
|
380
402
|
},
|
|
403
|
+
closeSubscription: closesTurn,
|
|
381
404
|
});
|
|
382
405
|
await appendOrchestrationAudit(input.config, "orchestration.attention.append", `Recorded ${input.crewMachineId === input.config.machineId ? "" : "remote "}${attention.eventType} attention for ${input.subscription.taskId}`, {
|
|
383
406
|
taskId: input.subscription.taskId,
|
|
@@ -703,10 +703,37 @@ export async function connectOnce(config) {
|
|
|
703
703
|
let loopTimer;
|
|
704
704
|
let scheduleTimer;
|
|
705
705
|
let orchestrationTimer;
|
|
706
|
+
let authenticated = false;
|
|
707
|
+
const startAuthenticatedWork = () => {
|
|
708
|
+
if (!authenticated || ws.readyState !== WebSocket.OPEN)
|
|
709
|
+
return;
|
|
710
|
+
if (loopTimer || scheduleTimer || orchestrationTimer)
|
|
711
|
+
return;
|
|
712
|
+
const onlineSinceMs = Date.now();
|
|
713
|
+
loopTimer = setInterval(() => {
|
|
714
|
+
void tickLoops(ws, config).catch((error) => {
|
|
715
|
+
console.error(`Loop tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
716
|
+
});
|
|
717
|
+
}, 1000);
|
|
718
|
+
scheduleTimer = setInterval(() => {
|
|
719
|
+
void tickSchedules(config, onlineSinceMs).catch((error) => {
|
|
720
|
+
console.error(`Schedule tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
721
|
+
});
|
|
722
|
+
}, 1000);
|
|
723
|
+
orchestrationTimer = setInterval(() => {
|
|
724
|
+
void tickOrchestration(config).catch((error) => {
|
|
725
|
+
console.error(`Orchestration tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
726
|
+
});
|
|
727
|
+
}, 1000);
|
|
728
|
+
void recoverCrewQueues(config).catch((error) => {
|
|
729
|
+
console.error(`Crew queue relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
730
|
+
});
|
|
731
|
+
void drainPendingArtifactFeedbackAttentions(config).catch((error) => {
|
|
732
|
+
console.error(`Artifact feedback attention relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
733
|
+
});
|
|
734
|
+
};
|
|
706
735
|
await new Promise((resolve) => {
|
|
707
736
|
ws.addEventListener("open", () => {
|
|
708
|
-
const onlineSinceMs = Date.now();
|
|
709
|
-
setActiveRelaySocket(ws);
|
|
710
737
|
const hello = {
|
|
711
738
|
type: "hello",
|
|
712
739
|
token: config.machineToken,
|
|
@@ -714,27 +741,6 @@ export async function connectOnce(config) {
|
|
|
714
741
|
nodeId: config.machineId,
|
|
715
742
|
};
|
|
716
743
|
ws.send(JSON.stringify(hello));
|
|
717
|
-
loopTimer = setInterval(() => {
|
|
718
|
-
void tickLoops(ws, config).catch((error) => {
|
|
719
|
-
console.error(`Loop tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
720
|
-
});
|
|
721
|
-
}, 1000);
|
|
722
|
-
scheduleTimer = setInterval(() => {
|
|
723
|
-
void tickSchedules(config, onlineSinceMs).catch((error) => {
|
|
724
|
-
console.error(`Schedule tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
725
|
-
});
|
|
726
|
-
}, 1000);
|
|
727
|
-
orchestrationTimer = setInterval(() => {
|
|
728
|
-
void tickOrchestration(config).catch((error) => {
|
|
729
|
-
console.error(`Orchestration tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
730
|
-
});
|
|
731
|
-
}, 1000);
|
|
732
|
-
void recoverCrewQueues(config).catch((error) => {
|
|
733
|
-
console.error(`Crew queue relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
734
|
-
});
|
|
735
|
-
void drainPendingArtifactFeedbackAttentions(config).catch((error) => {
|
|
736
|
-
console.error(`Artifact feedback attention relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
737
|
-
});
|
|
738
744
|
});
|
|
739
745
|
ws.addEventListener("message", (event) => {
|
|
740
746
|
let message;
|
|
@@ -745,11 +751,24 @@ export async function connectOnce(config) {
|
|
|
745
751
|
console.error(`Invalid relay message: ${error instanceof Error ? error.message : String(error)}`);
|
|
746
752
|
return;
|
|
747
753
|
}
|
|
748
|
-
void
|
|
754
|
+
void (async () => {
|
|
755
|
+
if (message.type === "server:hello" && !authenticated) {
|
|
756
|
+
// Relay authentication is complete only after the server hello.
|
|
757
|
+
// Do not activate the socket earlier: a reconnect with a durable
|
|
758
|
+
// outbox would otherwise flush publishPacket before the relay has
|
|
759
|
+
// installed the machine's authenticated routing context.
|
|
760
|
+
authenticated = true;
|
|
761
|
+
setActiveRelaySocket(ws);
|
|
762
|
+
}
|
|
763
|
+
await handleRelayWireMessage(ws, config, message);
|
|
764
|
+
if (message.type === "server:hello")
|
|
765
|
+
startAuthenticatedWork();
|
|
766
|
+
})().catch(async (error) => {
|
|
749
767
|
await appendUnscopedRelayError(config, "DAEMON_COMMAND_FAILED", error);
|
|
750
768
|
});
|
|
751
769
|
});
|
|
752
770
|
ws.addEventListener("close", () => {
|
|
771
|
+
authenticated = false;
|
|
753
772
|
clearActiveRelaySocket(ws);
|
|
754
773
|
if (loopTimer)
|
|
755
774
|
clearInterval(loopTimer);
|
|
@@ -761,6 +780,7 @@ export async function connectOnce(config) {
|
|
|
761
780
|
resolve();
|
|
762
781
|
});
|
|
763
782
|
ws.addEventListener("error", () => {
|
|
783
|
+
authenticated = false;
|
|
764
784
|
clearActiveRelaySocket(ws);
|
|
765
785
|
if (loopTimer)
|
|
766
786
|
clearInterval(loopTimer);
|
package/apps/daemon/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
|
|
|
6
6
|
import { updaterPaths } from "./paths.js";
|
|
7
7
|
import { daemonControlRequest } from "./control.js";
|
|
8
8
|
import { readUpdaterMarker, readUpdaterOwner } from "./ownership.js";
|
|
9
|
+
import { managedProcessEnvironment } from "./managed-environment.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
export async function localDaemonStatus(stateRoot) {
|
|
11
12
|
const file = updaterPaths(stateRoot).daemonPidPath;
|
|
@@ -91,7 +92,7 @@ export async function startDaemon(input) {
|
|
|
91
92
|
const stderr = fsSync.openSync(paths.daemonStderrPath, "a", 0o600);
|
|
92
93
|
const child = spawn(input.nodeExecutable, [input.daemonEntrypoint, "start"], {
|
|
93
94
|
cwd: input.stateRoot,
|
|
94
|
-
env: { ...
|
|
95
|
+
env: { ...managedProcessEnvironment(), HAPPY_ELVES_HOME: input.stateRoot, HAPPY_ELVES_UPDATER_GENERATION: input.generation },
|
|
95
96
|
detached: true,
|
|
96
97
|
stdio: ["ignore", stdout, stderr],
|
|
97
98
|
windowsHide: true,
|
|
@@ -119,7 +120,7 @@ export function spawnUpdaterFromStableLauncher(input) {
|
|
|
119
120
|
const child = spawn(input.nodeExecutable, [runtimeScript, "updater", "run", "--state-root", input.stateRoot], {
|
|
120
121
|
cwd: input.stateRoot,
|
|
121
122
|
env: {
|
|
122
|
-
...
|
|
123
|
+
...managedProcessEnvironment(),
|
|
123
124
|
HAPPY_ELVES_HOME: input.stateRoot,
|
|
124
125
|
...(input.handoffFromPid ? { HAPPY_ELVES_UPDATER_HANDOFF_FROM: String(input.handoffFromPid) } : {}),
|
|
125
126
|
...(input.handoffToken ? { HAPPY_ELVES_UPDATER_HANDOFF_TOKEN: input.handoffToken } : {}),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the current user's Windows environment without making it part of any
|
|
3
|
+
* host-job request. A manager can itself be launched by a deliberately
|
|
4
|
+
* sanitized host job; in that case process.env no longer contains provider
|
|
5
|
+
* credentials that the normal user-launched daemon would inherit.
|
|
6
|
+
*
|
|
7
|
+
* The result is used only for manager-owned updater/daemon children. It is
|
|
8
|
+
* never logged, persisted, sent over Relay, or passed to arbitrary host jobs.
|
|
9
|
+
*/
|
|
10
|
+
export declare function managedProcessEnvironment(base?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, userEnvironment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
11
|
+
export declare function parseWindowsUserEnvironment(output: string, expansionEnvironment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
const environmentLookupTimeoutMs = 2_000;
|
|
3
|
+
/**
|
|
4
|
+
* Read the current user's Windows environment without making it part of any
|
|
5
|
+
* host-job request. A manager can itself be launched by a deliberately
|
|
6
|
+
* sanitized host job; in that case process.env no longer contains provider
|
|
7
|
+
* credentials that the normal user-launched daemon would inherit.
|
|
8
|
+
*
|
|
9
|
+
* The result is used only for manager-owned updater/daemon children. It is
|
|
10
|
+
* never logged, persisted, sent over Relay, or passed to arbitrary host jobs.
|
|
11
|
+
*/
|
|
12
|
+
export function managedProcessEnvironment(base = process.env, platform = process.platform, userEnvironment = platform === "win32" ? readWindowsUserEnvironment(base) : {}) {
|
|
13
|
+
if (platform !== "win32")
|
|
14
|
+
return { ...base };
|
|
15
|
+
const result = { ...base };
|
|
16
|
+
for (const [key, value] of Object.entries(userEnvironment)) {
|
|
17
|
+
if (!key || value === undefined || hasEnvironmentKey(result, key))
|
|
18
|
+
continue;
|
|
19
|
+
result[key] = value;
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
export function parseWindowsUserEnvironment(output, expansionEnvironment = {}) {
|
|
24
|
+
const entries = new Map();
|
|
25
|
+
const trimmed = output.trim();
|
|
26
|
+
if (trimmed.startsWith("{")) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(trimmed);
|
|
29
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
30
|
+
if (!key)
|
|
31
|
+
continue;
|
|
32
|
+
if (typeof value === "string")
|
|
33
|
+
entries.set(key, { value, expand: false });
|
|
34
|
+
else if (value && typeof value === "object" && "value" in value && typeof value.value === "string") {
|
|
35
|
+
const typedValue = value;
|
|
36
|
+
entries.set(key, { value: typedValue.value, expand: isExpandableRegistryType(typedValue.type) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return materializeEnvironment(entries, expansionEnvironment);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Fall through to the line-oriented reg.exe parser.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
46
|
+
// `reg query HKCU\\Environment` prints:
|
|
47
|
+
// NAME REG_SZ value
|
|
48
|
+
const match = /^\s{4,}([^\s]+)\s+(REG_[A-Z0-9_]+)\s*(.*)$/u.exec(line);
|
|
49
|
+
if (!match)
|
|
50
|
+
continue;
|
|
51
|
+
const [, key, type, rawValue] = match;
|
|
52
|
+
if (!key || !rawValue)
|
|
53
|
+
continue;
|
|
54
|
+
entries.set(key, { value: rawValue, expand: isExpandableRegistryType(type) });
|
|
55
|
+
}
|
|
56
|
+
return materializeEnvironment(entries, expansionEnvironment);
|
|
57
|
+
}
|
|
58
|
+
function isExpandableRegistryType(type) {
|
|
59
|
+
if (typeof type !== "string")
|
|
60
|
+
return false;
|
|
61
|
+
const normalized = type.trim().toUpperCase().replace(/[-_\s]/gu, "");
|
|
62
|
+
// PowerShell's RegistryValueKind.ToString() returns ExpandString, while
|
|
63
|
+
// reg.exe uses REG_EXPAND_SZ. Both describe the same registry semantics.
|
|
64
|
+
return normalized === "EXPANDSTRING" || normalized === "REGEXPANDSZ";
|
|
65
|
+
}
|
|
66
|
+
function readWindowsUserEnvironment(base) {
|
|
67
|
+
try {
|
|
68
|
+
const output = execFileSync("powershell.exe", [
|
|
69
|
+
"-NoLogo",
|
|
70
|
+
"-NoProfile",
|
|
71
|
+
"-NonInteractive",
|
|
72
|
+
"-Command",
|
|
73
|
+
"$ErrorActionPreference='Stop'; $OutputEncoding=New-Object System.Text.UTF8Encoding($false); [Console]::OutputEncoding=New-Object System.Text.UTF8Encoding($false); $k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment'); if ($null -eq $k) { '{}' } else { $o=[ordered]@{}; foreach ($n in $k.GetValueNames()) { $o[$n]=[ordered]@{ value=[string]$k.GetValue($n,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); type=$k.GetValueKind($n).ToString() } }; $o | ConvertTo-Json -Compress; $k.Dispose() }",
|
|
74
|
+
], {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
timeout: environmentLookupTimeoutMs,
|
|
79
|
+
});
|
|
80
|
+
return parseWindowsUserEnvironment(output, base);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
try {
|
|
84
|
+
const output = execFileSync("reg.exe", ["query", "HKCU\\Environment"], {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
87
|
+
windowsHide: true,
|
|
88
|
+
timeout: environmentLookupTimeoutMs,
|
|
89
|
+
});
|
|
90
|
+
return parseWindowsUserEnvironment(output, base);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// A missing registry helper or a locked-down profile must not prevent
|
|
94
|
+
// the updater from starting. The daemon will surface a provider error
|
|
95
|
+
// if it genuinely needs a credential that is unavailable in its env.
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function materializeEnvironment(entries, expansionEnvironment) {
|
|
101
|
+
const result = {};
|
|
102
|
+
const combined = { ...expansionEnvironment };
|
|
103
|
+
for (const [key, entry] of entries)
|
|
104
|
+
combined[key] = entry.value;
|
|
105
|
+
for (const [key, entry] of entries) {
|
|
106
|
+
result[key] = entry.expand ? expandWindowsVariables(entry.value, combined) : entry.value;
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
function hasEnvironmentKey(environment, key) {
|
|
111
|
+
if (Object.hasOwn(environment, key))
|
|
112
|
+
return true;
|
|
113
|
+
const normalized = key.toUpperCase();
|
|
114
|
+
return Object.keys(environment).some((candidate) => candidate.toUpperCase() === normalized);
|
|
115
|
+
}
|
|
116
|
+
function expandWindowsVariables(value, environment) {
|
|
117
|
+
return value.replace(/%([^%]+)%/gu, (match, name) => {
|
|
118
|
+
const direct = environment[name];
|
|
119
|
+
if (direct !== undefined)
|
|
120
|
+
return direct;
|
|
121
|
+
const existing = Object.entries(environment).find(([key]) => key.toUpperCase() === name.toUpperCase());
|
|
122
|
+
return existing?.[1] ?? match;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=managed-environment.js.map
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cydm/happy-elves",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.501",
|
|
4
4
|
"description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
|
|
5
|
-
"buildId": "
|
|
5
|
+
"buildId": "4edbb3ee3e6735ee0abb55e9b0612b17",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"happy-elves": "./apps/cli/dist/index.js",
|