impel-cli 0.20.40 → 0.20.42
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/RELEASE_NOTES.md +15 -0
- package/package.json +1 -1
- package/src/agents.js +367 -41
- package/src/apps.js +2 -1
- package/src/cli.js +6 -0
- package/src/commands/apps.js +4 -2
- package/src/commands/launch.js +26 -0
- package/src/commands/mcp.js +86 -3
- package/src/commands/native.js +285 -0
- package/src/commands/status.js +9 -4
- package/src/managedProfileVersion.js +4 -0
- package/src/nativeAgentTelemetry.js +4 -0
- package/src/nativeAgentTransport.js +138 -15
- package/src/nativeInterception.js +133 -0
- package/src/windowsApps.js +122 -10
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
CONFIG_DIR,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
} from "./selfInvocation.js";
|
|
28
29
|
import { normalizeTenantId } from "./tenants.js";
|
|
29
30
|
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
31
|
+
import { CURRENT_CONFIG_VERSION } from "./managedProfileVersion.js";
|
|
30
32
|
|
|
31
33
|
export {
|
|
32
34
|
NATIVE_AGENT_ANSWER_TOOL,
|
|
@@ -38,6 +40,9 @@ export {
|
|
|
38
40
|
export const NATIVE_AGENT_HANDLE_SCHEMA = "impel.native-agent-run.v1";
|
|
39
41
|
export const NATIVE_AGENT_RESULT_SCHEMA = "impel.native-agent-result.v1";
|
|
40
42
|
export const NATIVE_AGENT_RECOVERY_SCHEMA = "impel.native-agent-recovery.v1";
|
|
43
|
+
export const NATIVE_AGENT_CLIENT_BUILD = `cli/${JSON.parse(
|
|
44
|
+
fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"),
|
|
45
|
+
).version}+manifest.v${CURRENT_CONFIG_VERSION}`;
|
|
41
46
|
|
|
42
47
|
const STATE_SCHEMA = "impel.native-agent-state.v1";
|
|
43
48
|
const DELETION_SCHEMA = "impel.native-agent-deletion.v1";
|
|
@@ -49,6 +54,7 @@ const DEFAULT_WAIT_SECONDS = 20;
|
|
|
49
54
|
const MAX_WAIT_SECONDS = 35;
|
|
50
55
|
const DEFAULT_ATTACHMENT_WINDOW_MS = 40_000;
|
|
51
56
|
const DEFAULT_UPSTREAM_TIMEOUT_MS = 42_000;
|
|
57
|
+
const DEFAULT_ANSWER_HEDGE_MS = 38_000;
|
|
52
58
|
const DEFAULT_MAX_POLLS = 8;
|
|
53
59
|
const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
54
60
|
const DEFAULT_MAX_RETAINED_RUNS = 500;
|
|
@@ -68,6 +74,11 @@ const MANAGED_VIRTUAL_KEY_MISS_RETRY_DELAY_MS = 250;
|
|
|
68
74
|
const NEXT_AMBIGUOUS_ANSWER_CODE = "native_agent_answer_ambiguous";
|
|
69
75
|
const MCP_TOOL_RESULT_ERROR = Symbol("native-agent MCP tool result error");
|
|
70
76
|
const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled", "canceled"]);
|
|
77
|
+
const SAFE_CLIENT_BUILD_RE = /^[A-Za-z0-9._+/-]{1,128}$/u;
|
|
78
|
+
|
|
79
|
+
if (!SAFE_CLIENT_BUILD_RE.test(NATIVE_AGENT_CLIENT_BUILD)) {
|
|
80
|
+
throw new Error("native-agent client build header is invalid");
|
|
81
|
+
}
|
|
71
82
|
|
|
72
83
|
export function nativeAgentAttachmentWindowMs(environment = process.env) {
|
|
73
84
|
const configured = environment?.[IMPEL_NATIVE_AGENT_ATTACHMENT_WINDOW_ENV];
|
|
@@ -82,6 +93,30 @@ export function nativeAgentAttachmentWindowMs(environment = process.env) {
|
|
|
82
93
|
return parsed;
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
export function nativeAgentAnswerHedgeMs(environment = process.env, { warn = console.warn } = {}) {
|
|
97
|
+
const configured = environment?.IMPEL_NATIVE_ANSWER_HEDGE_MS;
|
|
98
|
+
if (configured === undefined) return DEFAULT_ANSWER_HEDGE_MS;
|
|
99
|
+
if (!/^\d{1,9}$/u.test(configured)) {
|
|
100
|
+
warn(`IMPEL_NATIVE_ANSWER_HEDGE_MS must be a non-negative integer; using ${DEFAULT_ANSWER_HEDGE_MS}ms`);
|
|
101
|
+
return DEFAULT_ANSWER_HEDGE_MS;
|
|
102
|
+
}
|
|
103
|
+
const parsed = Number(configured);
|
|
104
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
105
|
+
warn(`IMPEL_NATIVE_ANSWER_HEDGE_MS must be a safe integer; using ${DEFAULT_ANSWER_HEDGE_MS}ms`);
|
|
106
|
+
return DEFAULT_ANSWER_HEDGE_MS;
|
|
107
|
+
}
|
|
108
|
+
if (parsed === 0) return 0;
|
|
109
|
+
const maximum = Math.min(DEFAULT_ATTACHMENT_WINDOW_MS, DEFAULT_UPSTREAM_TIMEOUT_MS) - 1;
|
|
110
|
+
if (parsed >= DEFAULT_ATTACHMENT_WINDOW_MS || parsed >= DEFAULT_UPSTREAM_TIMEOUT_MS) {
|
|
111
|
+
warn(
|
|
112
|
+
`IMPEL_NATIVE_ANSWER_HEDGE_MS must be below ${DEFAULT_ATTACHMENT_WINDOW_MS}ms `
|
|
113
|
+
+ `and ${DEFAULT_UPSTREAM_TIMEOUT_MS}ms; clamping to ${maximum}ms`,
|
|
114
|
+
);
|
|
115
|
+
return maximum;
|
|
116
|
+
}
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
119
|
+
|
|
85
120
|
function stableValue(value) {
|
|
86
121
|
if (Array.isArray(value)) return value.map(stableValue);
|
|
87
122
|
if (!value || typeof value !== "object") return value;
|
|
@@ -704,6 +739,9 @@ export class NativeAgentUpstreamSession {
|
|
|
704
739
|
telemetry = () => {},
|
|
705
740
|
now = Date.now,
|
|
706
741
|
requestIdFactory = crypto.randomUUID,
|
|
742
|
+
answerHedgeMs = nativeAgentAnswerHedgeMs(),
|
|
743
|
+
setTimeoutImpl = setTimeout,
|
|
744
|
+
clearTimeoutImpl = clearTimeout,
|
|
707
745
|
}) {
|
|
708
746
|
this.endpoint = `${normalizeGatewayUrl(gatewayUrl)}/mcp`;
|
|
709
747
|
this.credential = credential;
|
|
@@ -714,6 +752,9 @@ export class NativeAgentUpstreamSession {
|
|
|
714
752
|
this.telemetry = telemetry;
|
|
715
753
|
this.now = now;
|
|
716
754
|
this.requestIdFactory = requestIdFactory;
|
|
755
|
+
this.answerHedgeMs = Math.max(0, answerHedgeMs);
|
|
756
|
+
this.setTimeoutImpl = setTimeoutImpl;
|
|
757
|
+
this.clearTimeoutImpl = clearTimeoutImpl;
|
|
717
758
|
this.sessionId = null;
|
|
718
759
|
this.nextId = 1;
|
|
719
760
|
}
|
|
@@ -729,7 +770,7 @@ export class NativeAgentUpstreamSession {
|
|
|
729
770
|
let timedOut = false;
|
|
730
771
|
const onAbort = () => controller.abort();
|
|
731
772
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
732
|
-
const timeout =
|
|
773
|
+
const timeout = this.setTimeoutImpl(() => {
|
|
733
774
|
timedOut = true;
|
|
734
775
|
controller.abort();
|
|
735
776
|
}, this.timeoutMs);
|
|
@@ -743,6 +784,7 @@ export class NativeAgentUpstreamSession {
|
|
|
743
784
|
"Content-Type": "application/json",
|
|
744
785
|
Accept: "application/json, text/event-stream",
|
|
745
786
|
"X-Impel-Client-Request-Id": correlationId,
|
|
787
|
+
"X-Impel-Client-Build": NATIVE_AGENT_CLIENT_BUILD,
|
|
746
788
|
...(this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {}),
|
|
747
789
|
},
|
|
748
790
|
body: JSON.stringify(message),
|
|
@@ -760,7 +802,7 @@ export class NativeAgentUpstreamSession {
|
|
|
760
802
|
const detail = timedOut ? "request timed out" : redactSecretText(error?.message || error);
|
|
761
803
|
throw new NativeAgentUpstreamError(`native-agent MCP request failed: ${detail}`, { ambiguous: true });
|
|
762
804
|
} finally {
|
|
763
|
-
|
|
805
|
+
this.clearTimeoutImpl(timeout);
|
|
764
806
|
signal?.removeEventListener("abort", onAbort);
|
|
765
807
|
}
|
|
766
808
|
this.telemetry("upstream_request_completed", {
|
|
@@ -814,17 +856,78 @@ export class NativeAgentUpstreamSession {
|
|
|
814
856
|
return toolsResponse.result.tools;
|
|
815
857
|
}
|
|
816
858
|
|
|
817
|
-
async
|
|
859
|
+
async ping({ signal } = {}) {
|
|
860
|
+
const id = this.nextId++;
|
|
861
|
+
const messages = await this.post({ jsonrpc: "2.0", id, method: "ping", params: {} }, { signal });
|
|
862
|
+
const response = messages.find((message) => message?.id === id);
|
|
863
|
+
if (response?.error || !response?.result || typeof response.result !== "object") {
|
|
864
|
+
throw new NativeAgentUpstreamError("native-agent MCP ping failed");
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async callAnswerWithHedge(message, { signal, onHedge } = {}) {
|
|
869
|
+
throwIfAborted(signal);
|
|
870
|
+
const startedAt = this.now();
|
|
871
|
+
const controller = new AbortController();
|
|
872
|
+
let hedgeFired = false;
|
|
873
|
+
let elapsedMs = 0;
|
|
874
|
+
let replayTerminal = false;
|
|
875
|
+
const onAbort = () => controller.abort();
|
|
876
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
877
|
+
const timer = this.setTimeoutImpl(() => {
|
|
878
|
+
if (controller.signal.aborted) return;
|
|
879
|
+
hedgeFired = true;
|
|
880
|
+
elapsedMs = Math.max(0, this.now() - startedAt);
|
|
881
|
+
onHedge?.();
|
|
882
|
+
controller.abort();
|
|
883
|
+
}, this.answerHedgeMs);
|
|
884
|
+
try {
|
|
885
|
+
try {
|
|
886
|
+
const messages = await this.post(message, { signal: controller.signal });
|
|
887
|
+
return toolPayload(messages.find((candidate) => candidate?.id === message.id));
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (!hedgeFired || signal?.aborted) throw error;
|
|
890
|
+
} finally {
|
|
891
|
+
this.clearTimeoutImpl(timer);
|
|
892
|
+
signal?.removeEventListener("abort", onAbort);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
const messages = await this.post(message, { signal });
|
|
897
|
+
const payload = toolPayload(messages.find((candidate) => candidate?.id === message.id));
|
|
898
|
+
replayTerminal = TERMINAL_STATUSES.has(payload?.status);
|
|
899
|
+
return payload;
|
|
900
|
+
} finally {
|
|
901
|
+
this.telemetry("hedge_fired", { elapsedMs, replayTerminal });
|
|
902
|
+
}
|
|
903
|
+
} finally {
|
|
904
|
+
this.clearTimeoutImpl(timer);
|
|
905
|
+
signal?.removeEventListener("abort", onAbort);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async call(name, args, { signal = this.signal, hedge = true } = {}) {
|
|
910
|
+
let hedgeFiredForCall = false;
|
|
818
911
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
819
912
|
const id = this.nextId++;
|
|
913
|
+
const message = {
|
|
914
|
+
jsonrpc: "2.0",
|
|
915
|
+
id,
|
|
916
|
+
method: "tools/call",
|
|
917
|
+
params: { name, arguments: args },
|
|
918
|
+
};
|
|
820
919
|
try {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
920
|
+
if (name === NATIVE_AGENT_UPSTREAM_ANSWER_TOOL
|
|
921
|
+
&& hedge
|
|
922
|
+
&& !hedgeFiredForCall
|
|
923
|
+
&& this.answerHedgeMs > 0) {
|
|
924
|
+
return await this.callAnswerWithHedge(message, {
|
|
925
|
+
signal,
|
|
926
|
+
onHedge: () => { hedgeFiredForCall = true; },
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
const messages = await this.post(message, { signal });
|
|
930
|
+
return toolPayload(messages.find((candidate) => candidate?.id === id));
|
|
828
931
|
} catch (error) {
|
|
829
932
|
if (attempt === 0 && retryableManagedVirtualKeyMiss(error)) {
|
|
830
933
|
// This gateway-authored miss happens before a native run can exist.
|
|
@@ -1700,7 +1803,7 @@ export class NativeAgentCompositeTransport {
|
|
|
1700
1803
|
}
|
|
1701
1804
|
}
|
|
1702
1805
|
|
|
1703
|
-
async prepareExistingSession(signal) {
|
|
1806
|
+
async prepareExistingSession(signal, { prewarmed = false } = {}) {
|
|
1704
1807
|
const reused = Boolean(this.preparedSession);
|
|
1705
1808
|
const startedAt = this.now();
|
|
1706
1809
|
if (!this.preparedSession) {
|
|
@@ -1717,15 +1820,16 @@ export class NativeAgentCompositeTransport {
|
|
|
1717
1820
|
this.telemetry("session_prepared", {
|
|
1718
1821
|
durationMs: this.now() - startedAt,
|
|
1719
1822
|
handshakeReused: reused,
|
|
1823
|
+
prewarmed,
|
|
1720
1824
|
});
|
|
1721
1825
|
return prepared;
|
|
1722
1826
|
}
|
|
1723
1827
|
|
|
1724
|
-
async prepareNewSession(signal) {
|
|
1725
|
-
const prepared = await this.prepareExistingSession(signal);
|
|
1828
|
+
async prepareNewSession(signal, { prewarmed = false } = {}) {
|
|
1829
|
+
const prepared = await this.prepareExistingSession(signal, { prewarmed });
|
|
1726
1830
|
const startedAt = this.now();
|
|
1727
1831
|
if (this.preparedAgent) {
|
|
1728
|
-
this.telemetry("binding_completed", { durationMs: 0, bindingReused: true });
|
|
1832
|
+
this.telemetry("binding_completed", { durationMs: 0, bindingReused: true, prewarmed });
|
|
1729
1833
|
return { ...prepared, agent: this.preparedAgent };
|
|
1730
1834
|
}
|
|
1731
1835
|
const catalog = normalizeNativeAgentCatalog(
|
|
@@ -1746,10 +1850,29 @@ export class NativeAgentCompositeTransport {
|
|
|
1746
1850
|
this.telemetry("binding_completed", {
|
|
1747
1851
|
durationMs: this.now() - startedAt,
|
|
1748
1852
|
bindingReused: false,
|
|
1853
|
+
prewarmed,
|
|
1749
1854
|
});
|
|
1750
1855
|
return { ...prepared, agent: this.preparedAgent };
|
|
1751
1856
|
}
|
|
1752
1857
|
|
|
1858
|
+
async refreshPreparedSession(signal, { prewarmed = true } = {}) {
|
|
1859
|
+
const prepared = await this.prepareNewSession(signal, { prewarmed });
|
|
1860
|
+
try {
|
|
1861
|
+
await prepared.session.ping({ signal });
|
|
1862
|
+
} catch (error) {
|
|
1863
|
+
const cached = this.preparedSession;
|
|
1864
|
+
if (cached) {
|
|
1865
|
+
try {
|
|
1866
|
+
if ((await cached).session === prepared.session) this.preparedSession = null;
|
|
1867
|
+
} catch {
|
|
1868
|
+
this.preparedSession = null;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
throw error;
|
|
1872
|
+
}
|
|
1873
|
+
return prepared;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1753
1876
|
async preparePersistedStartSession(signal, state) {
|
|
1754
1877
|
const prepared = await this.prepareExistingSession(signal);
|
|
1755
1878
|
const catalog = normalizeNativeAgentCatalog(
|
|
@@ -2189,7 +2312,7 @@ export class NativeAgentCompositeTransport {
|
|
|
2189
2312
|
started = await session.call(
|
|
2190
2313
|
answerMode ? NATIVE_AGENT_UPSTREAM_ANSWER_TOOL : NATIVE_AGENT_START_TOOL,
|
|
2191
2314
|
outboundArguments,
|
|
2192
|
-
{ signal },
|
|
2315
|
+
{ signal, hedge: answerMode && attempt === 0 },
|
|
2193
2316
|
);
|
|
2194
2317
|
if (answerMode) {
|
|
2195
2318
|
if (!started?.runId) {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { impelCliInvocation } from "./selfInvocation.js";
|
|
5
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
6
|
+
|
|
7
|
+
export const IMPEL_NATIVE_INTERCEPT_ENV = "IMPEL_NATIVE_INTERCEPT";
|
|
8
|
+
export const IMPEL_NATIVE_INTERCEPT_TIMEOUT_ENV = "IMPEL_NATIVE_INTERCEPT_TIMEOUT_MS";
|
|
9
|
+
export const DEFAULT_NATIVE_INTERCEPT_TIMEOUT_MS = 100_000;
|
|
10
|
+
export const MANAGED_NATIVE_INTERCEPT_FLAG = "impel-managed-native-intercept-v1";
|
|
11
|
+
|
|
12
|
+
export function nativeInterceptEnabled(environment = process.env) {
|
|
13
|
+
return ["1", "true"].includes(
|
|
14
|
+
String(environment?.[IMPEL_NATIVE_INTERCEPT_ENV] || "").toLowerCase(),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function nativeInterceptTimeoutMs(environment = process.env) {
|
|
19
|
+
const configured = environment?.[IMPEL_NATIVE_INTERCEPT_TIMEOUT_ENV];
|
|
20
|
+
if (configured === undefined || configured === "") return DEFAULT_NATIVE_INTERCEPT_TIMEOUT_MS;
|
|
21
|
+
if (!/^\d{1,6}$/u.test(configured)) {
|
|
22
|
+
throw new Error(`${IMPEL_NATIVE_INTERCEPT_TIMEOUT_ENV} must be an integer from 1000 through 300000`);
|
|
23
|
+
}
|
|
24
|
+
const timeoutMs = Number(configured);
|
|
25
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 300_000) {
|
|
26
|
+
throw new Error(`${IMPEL_NATIVE_INTERCEPT_TIMEOUT_ENV} must be an integer from 1000 through 300000`);
|
|
27
|
+
}
|
|
28
|
+
return timeoutMs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readJsonObject(filePath) {
|
|
32
|
+
if (!fs.existsSync(filePath)) return {};
|
|
33
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
34
|
+
if (!raw) return {};
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(raw);
|
|
37
|
+
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error();
|
|
38
|
+
return value;
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(`${filePath} exists but isn't a valid JSON object. Fix or remove it, then re-run.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writePrivateJson(filePath, value) {
|
|
45
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
46
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
49
|
+
renameWithWindowsRetry(temporaryPath, filePath);
|
|
50
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* Best effort on Windows. */ }
|
|
51
|
+
} finally {
|
|
52
|
+
try { fs.rmSync(temporaryPath, { force: true }); } catch { /* Rename removed it. */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handlerIsManaged(handler) {
|
|
57
|
+
return handler
|
|
58
|
+
&& typeof handler === "object"
|
|
59
|
+
&& Array.isArray(handler.args)
|
|
60
|
+
&& handler.args.includes(`--${MANAGED_NATIVE_INTERCEPT_FLAG}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function removeManagedHandlers(groups) {
|
|
64
|
+
if (!Array.isArray(groups)) return { groups, changed: false };
|
|
65
|
+
let changed = false;
|
|
66
|
+
const preserved = [];
|
|
67
|
+
for (const group of groups) {
|
|
68
|
+
if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
|
|
69
|
+
preserved.push(group);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const hooks = group.hooks.filter((handler) => {
|
|
73
|
+
if (!handlerIsManaged(handler)) return true;
|
|
74
|
+
changed = true;
|
|
75
|
+
return false;
|
|
76
|
+
});
|
|
77
|
+
if (hooks.length > 0) preserved.push({ ...group, hooks });
|
|
78
|
+
}
|
|
79
|
+
return { groups: preserved, changed };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function claudeNativeInterceptHandler(tenantId, timeoutMs, invocation = null) {
|
|
83
|
+
const command = invocation || impelCliInvocation([
|
|
84
|
+
"native", "intercept",
|
|
85
|
+
"--tenant", tenantId,
|
|
86
|
+
`--${MANAGED_NATIVE_INTERCEPT_FLAG}`,
|
|
87
|
+
]);
|
|
88
|
+
return {
|
|
89
|
+
type: "command",
|
|
90
|
+
command: command.command,
|
|
91
|
+
args: [...command.args],
|
|
92
|
+
timeout: Math.ceil(timeoutMs / 1000),
|
|
93
|
+
async: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Install or remove only Impel's synchronous UserPromptSubmit interception hook. */
|
|
98
|
+
export function ensureClaudeNativeInterceptHook(configDir, tenantId, {
|
|
99
|
+
enabled = false,
|
|
100
|
+
timeoutMs = DEFAULT_NATIVE_INTERCEPT_TIMEOUT_MS,
|
|
101
|
+
invocation = null,
|
|
102
|
+
} = {}) {
|
|
103
|
+
const settingsPath = path.join(configDir, "settings.json");
|
|
104
|
+
if (!enabled) {
|
|
105
|
+
try {
|
|
106
|
+
if (!fs.readFileSync(settingsPath, "utf8").includes(`--${MANAGED_NATIVE_INTERCEPT_FLAG}`)) {
|
|
107
|
+
return { settingsPath, enabled: false, changed: false };
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error?.code === "ENOENT") return { settingsPath, enabled: false, changed: false };
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const settings = readJsonObject(settingsPath);
|
|
115
|
+
const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
|
|
116
|
+
? { ...settings.hooks }
|
|
117
|
+
: {};
|
|
118
|
+
const removed = removeManagedHandlers(hooks.UserPromptSubmit);
|
|
119
|
+
if (!enabled && !removed.changed) {
|
|
120
|
+
return { settingsPath, enabled: false, changed: false };
|
|
121
|
+
}
|
|
122
|
+
if (enabled) {
|
|
123
|
+
removed.groups.push({
|
|
124
|
+
hooks: [claudeNativeInterceptHandler(tenantId, timeoutMs, invocation)],
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (removed.groups.length > 0) hooks.UserPromptSubmit = removed.groups;
|
|
128
|
+
else delete hooks.UserPromptSubmit;
|
|
129
|
+
if (Object.keys(hooks).length > 0) settings.hooks = hooks;
|
|
130
|
+
else delete settings.hooks;
|
|
131
|
+
writePrivateJson(settingsPath, settings);
|
|
132
|
+
return { settingsPath, enabled, changed: true };
|
|
133
|
+
}
|
package/src/windowsApps.js
CHANGED
|
@@ -90,6 +90,71 @@ function installedMsixChatGPT(environment, run = spawnSync, pin = null) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Register the already-staged ChatGPT Store package for the current user.
|
|
95
|
+
* winget resolves packages machine-wide, so on a machine where another user
|
|
96
|
+
* (or provisioning) installed the app, `winget install` reports
|
|
97
|
+
* UPDATE_NOT_APPLICABLE while the current user's Get-AppxPackage sees
|
|
98
|
+
* nothing. Add-AppxPackage -RegisterByFamilyName registers the existing
|
|
99
|
+
* staged bits for this user without downloading or elevating.
|
|
100
|
+
*/
|
|
101
|
+
export function registerProvisionedMsixChatGPT(environment, run = spawnSync) {
|
|
102
|
+
const familyName = `${WINDOWS_CHATGPT_PACKAGE_NAME}_${WINDOWS_CHATGPT_PUBLISHER_ID}`;
|
|
103
|
+
try {
|
|
104
|
+
const result = run("powershell.exe", [
|
|
105
|
+
"-NoProfile",
|
|
106
|
+
"-NonInteractive",
|
|
107
|
+
"-Command",
|
|
108
|
+
`Add-AppxPackage -RegisterByFamilyName -MainPackage '${familyName}' -ErrorAction Stop`,
|
|
109
|
+
], {
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
env: windowsPowerShellEnvironment(environment),
|
|
112
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
113
|
+
timeout: 120_000,
|
|
114
|
+
windowsHide: true,
|
|
115
|
+
});
|
|
116
|
+
return result?.status === 0 && !result?.error;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function compareDottedVersions(left, right) {
|
|
123
|
+
const parse = (value) => String(value).split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
124
|
+
const a = parse(left);
|
|
125
|
+
const b = parse(right);
|
|
126
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
127
|
+
const delta = (a[index] || 0) - (b[index] || 0);
|
|
128
|
+
if (delta !== 0) return delta;
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The version string of whatever ChatGPT Store package is installed,
|
|
135
|
+
* regardless of the reviewed pin. Diagnostic-only: it lets a pin mismatch
|
|
136
|
+
* name the actual installed version instead of a bare winget HRESULT.
|
|
137
|
+
*/
|
|
138
|
+
export function installedMsixChatGPTVersion(environment, run = spawnSync) {
|
|
139
|
+
const script = [
|
|
140
|
+
`$package = Get-AppxPackage -Name '${WINDOWS_CHATGPT_PACKAGE_NAME}' -ErrorAction SilentlyContinue`,
|
|
141
|
+
`$package = $package | Where-Object { $_.PublisherId -ceq '${WINDOWS_CHATGPT_PUBLISHER_ID}' } | Sort-Object Version -Descending | Select-Object -First 1`,
|
|
142
|
+
"if ($package) { $package.Version.ToString() }",
|
|
143
|
+
].join("; ");
|
|
144
|
+
try {
|
|
145
|
+
const result = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
146
|
+
encoding: "utf8",
|
|
147
|
+
env: windowsPowerShellEnvironment(environment),
|
|
148
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
149
|
+
timeout: 15_000,
|
|
150
|
+
windowsHide: true,
|
|
151
|
+
});
|
|
152
|
+
return result?.status === 0 ? String(result.stdout || "").trim() || null : null;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
93
158
|
/** Candidate paths for Anthropic's signed per-user Windows desktop install. */
|
|
94
159
|
export function windowsClaudeAppCandidates(environment = process.env, dependencies = {}) {
|
|
95
160
|
const io = { readDirectory: fs.readdirSync, ...dependencies };
|
|
@@ -148,7 +213,7 @@ export function findWindowsChatGPTApp(environment = process.env, dependencies =
|
|
|
148
213
|
return msix && exists(msix) ? path.win32.normalize(msix) : null;
|
|
149
214
|
}
|
|
150
215
|
|
|
151
|
-
function pinnedWindowsChatGPTApp(environment = process.env, dependencies = {}) {
|
|
216
|
+
export function pinnedWindowsChatGPTApp(environment = process.env, dependencies = {}) {
|
|
152
217
|
const exists = dependencies.isFile || isFile;
|
|
153
218
|
const pin = dependencies.pin || WINDOWS_CHATGPT_PIN;
|
|
154
219
|
const msix = (dependencies.queryMsix || installedMsixChatGPT)(environment, dependencies.run, pin);
|
|
@@ -434,16 +499,36 @@ export function ensureWindowsChatGPTApp({ update = false } = {}, dependencies =
|
|
|
434
499
|
result = { status: null, error };
|
|
435
500
|
}
|
|
436
501
|
let resultBinary = io.findPinned(io.environment, { pin: io.pin, run: io.run });
|
|
437
|
-
|
|
438
|
-
// with no newer Store release. That is a healthy idempotent update result,
|
|
439
|
-
// not an installation failure (APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE).
|
|
440
|
-
const alreadyUpToDate = Boolean(
|
|
441
|
-
shouldUpdate
|
|
442
|
-
&& resultBinary
|
|
443
|
-
&& !result?.error
|
|
502
|
+
const noApplicableUpgrade = !result?.error
|
|
444
503
|
&& Number.isInteger(result?.status)
|
|
445
|
-
&& (result.status >>> 0) === WINDOWS_WINGET_UPDATE_NOT_APPLICABLE
|
|
446
|
-
|
|
504
|
+
&& (result.status >>> 0) === WINDOWS_WINGET_UPDATE_NOT_APPLICABLE;
|
|
505
|
+
// winget resolves machine-wide: when it reports the package installed but
|
|
506
|
+
// the current user's probe sees nothing, the staged bits are registered to
|
|
507
|
+
// another user or provisioning. Registering them for this user is a local,
|
|
508
|
+
// download-free repair; the pin-exact probe afterwards keeps it fail-closed.
|
|
509
|
+
let registeredOffPin = false;
|
|
510
|
+
if (noApplicableUpgrade && !resultBinary) {
|
|
511
|
+
io.logger.log("ChatGPT/Codex: attempting to register the machine-wide Microsoft Store package for the current user…");
|
|
512
|
+
if ((io.registerProvisioned || registerProvisionedMsixChatGPT)(io.environment, io.run)) {
|
|
513
|
+
resultBinary = io.findPinned(io.environment, { pin: io.pin, run: io.run });
|
|
514
|
+
if (resultBinary) {
|
|
515
|
+
io.logger.log("ChatGPT/Codex: registered the already-staged Microsoft Store package for the current user.");
|
|
516
|
+
} else {
|
|
517
|
+
registeredOffPin = true;
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
io.logger.log("ChatGPT/Codex: the Store package could not be registered for the current user.");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// winget uses an error HRESULT when it finds an installed package with no
|
|
524
|
+
// newer Store release (APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE). That
|
|
525
|
+
// is a healthy idempotent result whenever the pin-exact re-query proves the
|
|
526
|
+
// reviewed package is present — during setup/install as much as update:
|
|
527
|
+
// `winget install` reports the same HRESULT for an already-installed
|
|
528
|
+
// package, and treating it as failure strands first-run machines in an
|
|
529
|
+
// unconvergeable recovery loop. Acceptance stays fail-closed through the
|
|
530
|
+
// pin-exact probe below.
|
|
531
|
+
const alreadyUpToDate = Boolean(resultBinary && noApplicableUpgrade);
|
|
447
532
|
if (alreadyUpToDate) {
|
|
448
533
|
io.logger.log(`ChatGPT/Codex: Microsoft Store package ${io.pin.packageVersion} already up to date.`);
|
|
449
534
|
}
|
|
@@ -456,6 +541,33 @@ export function ensureWindowsChatGPTApp({ update = false } = {}, dependencies =
|
|
|
456
541
|
),
|
|
457
542
|
};
|
|
458
543
|
}
|
|
544
|
+
// A no-applicable-upgrade result over a package that fails the pin-exact
|
|
545
|
+
// probe means the Store carries a different (usually newer) build the
|
|
546
|
+
// Store cannot downgrade. Name both versions so the operator learns this
|
|
547
|
+
// needs a CLI release with an updated ChatGPT pin, not another retry.
|
|
548
|
+
if (!commandSucceeded && !resultBinary && noApplicableUpgrade) {
|
|
549
|
+
const installedVersion = (io.installedVersion || installedMsixChatGPTVersion)(io.environment, io.run);
|
|
550
|
+
if (installedVersion) {
|
|
551
|
+
const newer = compareDottedVersions(installedVersion, io.pin.packageVersion) > 0;
|
|
552
|
+
result = {
|
|
553
|
+
...result,
|
|
554
|
+
error: new Error(
|
|
555
|
+
`installed ChatGPT Store package ${installedVersion} does not match the reviewed pin ${io.pin.packageVersion}; `
|
|
556
|
+
+ (newer
|
|
557
|
+
? "the Microsoft Store cannot downgrade, so this needs a CLI release with an updated ChatGPT pin"
|
|
558
|
+
: "the Store has not fulfilled the pinned build on this machine yet — retry once the Store catches up"),
|
|
559
|
+
),
|
|
560
|
+
};
|
|
561
|
+
} else if (registeredOffPin) {
|
|
562
|
+
result = {
|
|
563
|
+
...result,
|
|
564
|
+
error: new Error(
|
|
565
|
+
"a machine-wide ChatGPT Store package was registered for the current user but does not match "
|
|
566
|
+
+ `the reviewed pin ${io.pin.packageVersion}; the managed app stays uninstalled until a matching build is available`,
|
|
567
|
+
),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
459
571
|
const succeeded = commandSucceeded && Boolean(resultBinary);
|
|
460
572
|
if (!succeeded) resultBinary = null;
|
|
461
573
|
return {
|