impel-cli 0.12.1 → 0.13.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/README.md +39 -12
- package/package.json +1 -1
- package/src/apps.js +69 -26
- package/src/codesign.js +246 -0
- package/src/commands/apps.js +22 -2
package/README.md
CHANGED
|
@@ -246,6 +246,14 @@ followed by `impel skills sync all` and `impel agents sync all` across every
|
|
|
246
246
|
native and isolated CLI profile. Each tenant's app step syncs that tenant's app
|
|
247
247
|
profiles; the dedicated steps exclude apps, so every profile is synced once.
|
|
248
248
|
|
|
249
|
+
Most updates are in place — the desktop apps are **not** closed. A vendored
|
|
250
|
+
bundle is only rebuilt (and therefore only quit) when the bundle-building logic
|
|
251
|
+
or the pinned vendor version actually changes, not on every CLI release, so a
|
|
252
|
+
routine update just rewrites configs, skills, and agents while the apps keep
|
|
253
|
+
running. When a rebuild genuinely is required, `impel app update` closes only
|
|
254
|
+
the app whose bundle it will swap and **reopens it afterward**, so the swap
|
|
255
|
+
lands without you relaunching by hand. Apps you had already closed stay closed.
|
|
256
|
+
|
|
249
257
|
Update discovery compares the installed package version with npm's public
|
|
250
258
|
`latest` metadata, cached for 6 hours in
|
|
251
259
|
`~/.config/impel/update-check.json`. It does not invoke Git or contact GitHub.
|
|
@@ -746,13 +754,16 @@ that have never been installed. The older global `Impel Claude.app` /
|
|
|
746
754
|
`Impel ChatGPT.app` launcher is removed after the first successful
|
|
747
755
|
tenant-specific rebuild.
|
|
748
756
|
|
|
749
|
-
`impel app update` compares the installed bundle's
|
|
750
|
-
version, identity, profile paths, and ASAR compatibility
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
757
|
+
`impel app update` compares the installed bundle's build fingerprint, signing
|
|
758
|
+
mode, pinned vendor version, identity, profile paths, and ASAR compatibility
|
|
759
|
+
hash before touching it. The build fingerprint tracks the bundle-BUILDING logic
|
|
760
|
+
(asar patches, plist rewrites, signing), not the CLI version, so a CLI release
|
|
761
|
+
that doesn't change bundle bytes leaves installed bundles — and their macOS
|
|
762
|
+
permission grants — alone. A current bundle is not closed, cloned, patched, or
|
|
763
|
+
codesigned; its config and model catalog are still refreshed. If an interrupted
|
|
764
|
+
update left a machine in a bad state, `impel app update --force` removes the
|
|
765
|
+
selected launcher and performs a clean rebuild while sweeping abandoned
|
|
766
|
+
`.tmp-*` and `.previous-*` staging directories.
|
|
756
767
|
|
|
757
768
|
Skills plugin subprocesses run asynchronously, so the TTY progress indicator
|
|
758
769
|
continues animating while Claude or Codex refreshes a marketplace. Skill status
|
|
@@ -776,7 +787,7 @@ For ChatGPT/Codex, the gateway controls which models and capabilities are
|
|
|
776
787
|
enabled while the installer merges those entries with the vendor app's native
|
|
777
788
|
catalog. The Impel copy relaxes the vendor renderer's ChatGPT-account-only Fast
|
|
778
789
|
gate because gateway auth supplies the entitlement instead. The outer wrapper
|
|
779
|
-
is then
|
|
790
|
+
is then signed locally (see below) without replacing the nested OpenAI signing
|
|
780
791
|
identity. This preserves model-specific instructions and exposes
|
|
781
792
|
the same reasoning-effort and service-tier controls as the normal app, including
|
|
782
793
|
Ultra where supported and Fast only on models that advertise the `priority`
|
|
@@ -786,10 +797,26 @@ Impel PAT. Config refreshes replace only the Impel-managed gateway block; plugin
|
|
|
786
797
|
desktop preferences, projects, and other settings written by the isolated app
|
|
787
798
|
are kept.
|
|
788
799
|
|
|
789
|
-
Impel Claude and the outer Impel ChatGPT wrapper are
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
800
|
+
Impel Claude and the outer Impel ChatGPT wrapper are signed locally after their
|
|
801
|
+
bundle identities and launch wrappers are installed. The nested ChatGPT bundle
|
|
802
|
+
retains OpenAI's signature.
|
|
803
|
+
|
|
804
|
+
By default the CLI signs each rebuild with a **stable per-machine identity** — a
|
|
805
|
+
self-signed certificate created once in a dedicated keychain under
|
|
806
|
+
`~/.config/impel/codesign` (no Apple Developer account is involved). macOS ties
|
|
807
|
+
every privacy (TCC) grant — microphone, screen recording, folder access,
|
|
808
|
+
Automation, accessibility — to the app's code-signing designated requirement. A
|
|
809
|
+
stable identity keeps that requirement constant across rebuilds, so the grants
|
|
810
|
+
you approve survive `impel app update` the way they do for a normal app instead
|
|
811
|
+
of re-prompting after every rebuild. The one-time switch from the old ad-hoc
|
|
812
|
+
signature to the stable identity triggers a single final round of prompts. If
|
|
813
|
+
the local signing toolchain is unavailable the CLI logs a notice and falls back
|
|
814
|
+
to ad-hoc signing (the previous behavior). Two environment overrides are
|
|
815
|
+
available: `IMPEL_CODESIGN_ADHOC=1` forces ad-hoc signing, and
|
|
816
|
+
`IMPEL_CODESIGN_IDENTITY=<name>` signs with a specific existing identity (e.g. a
|
|
817
|
+
real `Developer ID Application: …`).
|
|
818
|
+
|
|
819
|
+
The Claude 3P config necessarily contains the PAT because that app accepts a
|
|
793
820
|
gateway API key rather than a token-helper command. It is stored in the same
|
|
794
821
|
owner-only Impel config tree as the primary credential. ChatGPT/Codex uses a
|
|
795
822
|
command-backed helper and does not copy the PAT into TOML. The installable app
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./codexSecurity.js";
|
|
12
12
|
import { normalizeTenantId } from "./tenants.js";
|
|
13
13
|
import { crossAppModelsEnabled, redactSecretText } from "./config.js";
|
|
14
|
+
import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
|
|
14
15
|
|
|
15
16
|
export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
|
|
16
17
|
const CHATGPT_CONFIG_START = "# >>> impel app managed gateway >>>";
|
|
@@ -144,6 +145,15 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
144
145
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
145
146
|
export const CURRENT_CONFIG_VERSION = 10;
|
|
146
147
|
|
|
148
|
+
// Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
|
|
149
|
+
// helper rebranding, and signing. A vendored bundle is rebuilt only when this
|
|
150
|
+
// changes, NOT on every CLI release, so routine CLI updates (bug fixes, new
|
|
151
|
+
// commands, config tweaks) no longer quit and re-sign the running desktop apps
|
|
152
|
+
// — which is what made every `impel update` re-trigger macOS permission
|
|
153
|
+
// prompts. Bump this ONLY when a code change alters the bytes of a built
|
|
154
|
+
// bundle; leave it alone for changes that don't touch bundle contents.
|
|
155
|
+
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-16.1";
|
|
156
|
+
|
|
147
157
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
148
158
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
149
159
|
try {
|
|
@@ -169,17 +179,21 @@ export function readBundleCompatibility(launcher) {
|
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
/**
|
|
172
|
-
* A vendored bundle needs no rebuild when it was built by
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
182
|
+
* A vendored bundle needs no rebuild when it was built by the SAME bundle
|
|
183
|
+
* logic (BUNDLE_BUILD_FINGERPRINT) and signing mode, from the vendor app
|
|
184
|
+
* version currently on disk. Keying on the bundle fingerprint rather than the
|
|
185
|
+
* CLI version is deliberate: rebuilds are the expensive part of install/open
|
|
186
|
+
* (bundle clone + asar patch + deep codesign) AND they force the running apps
|
|
187
|
+
* closed, so a routine CLI update that doesn't change bundle bytes must leave
|
|
188
|
+
* the installed bundles — and their macOS permission grants — untouched.
|
|
176
189
|
*/
|
|
177
|
-
export function bundleIsCurrent(status) {
|
|
190
|
+
export function bundleIsCurrent(status, { signingMode = desiredSigningMode() } = {}) {
|
|
178
191
|
if (!status.launcherInstalled || !status.vendorPath || !status.vendorVersion) return false;
|
|
179
192
|
const compatibility = readBundleCompatibility(status.launcher);
|
|
180
193
|
return Boolean(
|
|
181
194
|
compatibility
|
|
182
|
-
&& compatibility.
|
|
195
|
+
&& compatibility.bundleFingerprint === BUNDLE_BUILD_FINGERPRINT
|
|
196
|
+
&& compatibility.signingMode === signingMode
|
|
183
197
|
&& compatibility.vendorVersion === status.vendorVersion
|
|
184
198
|
&& compatibility.bundleIdentifier === status.bundleIdentifier
|
|
185
199
|
&& compatibility.displayName === status.label
|
|
@@ -251,7 +265,10 @@ const QUIT_POLL_MS = 500;
|
|
|
251
265
|
/**
|
|
252
266
|
* Closes every running app whose bundle the given targets will replace:
|
|
253
267
|
* graceful AppleScript quit first, SIGTERM if it lingers, and a hard error
|
|
254
|
-
* telling the user to close it themselves if even that fails.
|
|
268
|
+
* telling the user to close it themselves if even that fails. Returns the app
|
|
269
|
+
* names that were actually running and got closed, so callers can reopen the
|
|
270
|
+
* managed launchers afterward (an update the user did not ask to also relaunch
|
|
271
|
+
* leaves already-closed apps closed).
|
|
255
272
|
*/
|
|
256
273
|
export async function quitBlockingApps(targets, {
|
|
257
274
|
tenantId = null,
|
|
@@ -280,16 +297,28 @@ export async function quitBlockingApps(targets, {
|
|
|
280
297
|
return !isRunning(name);
|
|
281
298
|
}
|
|
282
299
|
|
|
300
|
+
const closed = [];
|
|
283
301
|
for (const name of blockingAppNames(targets, { tenantId, tenantName })) {
|
|
284
302
|
if (!isRunning(name)) continue;
|
|
285
303
|
log(`${name} is running; closing it before touching app bundles…`);
|
|
286
304
|
requestQuit(name);
|
|
287
|
-
if (await goneWithin(name, quitWaitMs)) continue;
|
|
305
|
+
if (await goneWithin(name, quitWaitMs)) { closed.push(name); continue; }
|
|
288
306
|
log(`${name} did not quit gracefully; terminating it…`);
|
|
289
307
|
forceQuit(name);
|
|
290
|
-
if (await goneWithin(name, killWaitMs)) continue;
|
|
308
|
+
if (await goneWithin(name, killWaitMs)) { closed.push(name); continue; }
|
|
291
309
|
throw new Error(`${name} is still running; close it manually, then re-run`);
|
|
292
310
|
}
|
|
311
|
+
return closed;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The managed (Impel-branded) launcher name for a target, as it appears in
|
|
316
|
+
* blockingAppNames / quitBlockingApps output. Used to decide which just-closed
|
|
317
|
+
* apps to reopen after an update — only the managed launchers, never a user's
|
|
318
|
+
* separate vendor Claude/ChatGPT.
|
|
319
|
+
*/
|
|
320
|
+
export function managedLauncherName(target, { tenantId = null, tenantName = null } = {}) {
|
|
321
|
+
return managedAppIdentity(target, tenantId, tenantName).launcherName.replace(/\.app$/u, "");
|
|
293
322
|
}
|
|
294
323
|
|
|
295
324
|
export function appPaths(homeDir = os.homedir(), tenantId = null, {
|
|
@@ -1016,8 +1045,10 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1016
1045
|
|
|
1017
1046
|
const macos = path.join(staging, "Contents", "MacOS");
|
|
1018
1047
|
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
1019
|
-
schemaVersion:
|
|
1048
|
+
schemaVersion: 3,
|
|
1020
1049
|
cliVersion: CLI_VERSION,
|
|
1050
|
+
bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
|
|
1051
|
+
signingMode: desiredSigningMode(),
|
|
1021
1052
|
vendorVersion: bundleVersion(vendorPath),
|
|
1022
1053
|
sourceBundle: vendorPath,
|
|
1023
1054
|
bundleIdentifier: paths.claude.bundleIdentifier,
|
|
@@ -1100,8 +1131,10 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1100
1131
|
0o644,
|
|
1101
1132
|
);
|
|
1102
1133
|
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
1103
|
-
schemaVersion:
|
|
1134
|
+
schemaVersion: 3,
|
|
1104
1135
|
cliVersion: CLI_VERSION,
|
|
1136
|
+
bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
|
|
1137
|
+
signingMode: desiredSigningMode(),
|
|
1105
1138
|
vendorVersion: bundleVersion(vendorPath),
|
|
1106
1139
|
sourceBundle: vendorPath,
|
|
1107
1140
|
bundleIdentifier: paths.chatgpt.bundleIdentifier,
|
|
@@ -1559,35 +1592,43 @@ if (process.type === "browser" && displayName) {
|
|
|
1559
1592
|
`;
|
|
1560
1593
|
}
|
|
1561
1594
|
|
|
1562
|
-
function signVendoredApp(bundle, vendorExecutable, label) {
|
|
1595
|
+
function signVendoredApp(bundle, vendorExecutable, label, identity = null) {
|
|
1563
1596
|
if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return;
|
|
1597
|
+
// Resolve (and, on first use, create) the signing identity only once we know
|
|
1598
|
+
// we will actually sign — this keeps the guard above side-effect-free.
|
|
1599
|
+
identity ||= resolveSigningIdentity();
|
|
1564
1600
|
spawnSync("/usr/bin/xattr", ["-dr", "com.apple.quarantine", bundle], { stdio: "ignore" });
|
|
1565
|
-
let signed = codesign(vendoredAppSigningArgs(bundle, label));
|
|
1601
|
+
let signed = codesign(vendoredAppSigningArgs(bundle, label, identity));
|
|
1566
1602
|
if (signed.status !== 0) throw new Error(`failed to sign vendored Impel ${label} app: ${signed.stderr.trim()}`);
|
|
1567
1603
|
if (label === "Claude") {
|
|
1568
|
-
signed = signVendoredClaudeCapabilities(bundle);
|
|
1604
|
+
signed = signVendoredClaudeCapabilities(bundle, identity);
|
|
1569
1605
|
if (signed.status !== 0) throw new Error(`failed to restore vendored Impel Claude capabilities: ${signed.stderr.trim()}`);
|
|
1570
1606
|
}
|
|
1571
1607
|
const verified = spawnSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", bundle], { encoding: "utf8" });
|
|
1572
1608
|
if (verified.status !== 0) throw new Error(`vendored Impel ${label} signature verification failed: ${verified.stderr.trim()}`);
|
|
1573
1609
|
}
|
|
1574
1610
|
|
|
1575
|
-
export function vendoredAppSigningArgs(bundle, label) {
|
|
1611
|
+
export function vendoredAppSigningArgs(bundle, label, identity = ADHOC_IDENTITY) {
|
|
1612
|
+
// A stable local signing identity keeps the bundle's code-signing designated
|
|
1613
|
+
// requirement constant across rebuilds, so macOS retains the user's TCC
|
|
1614
|
+
// permission grants after every `impel app update` (see src/codesign.js).
|
|
1615
|
+
//
|
|
1576
1616
|
// Browser control authenticates the ChatGPT process by OpenAI's code-signing
|
|
1577
|
-
// identity. Deep
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1617
|
+
// identity. Deep signing replaces that identity on every nested code object,
|
|
1618
|
+
// including Contents/MacOS/ChatGPT, so sign only the modified outer bundle
|
|
1619
|
+
// and preserve the vendor signatures within it. Claude deliberately retains
|
|
1620
|
+
// its separate deep-signing and entitlement-restoration flow below.
|
|
1581
1621
|
return label === "ChatGPT"
|
|
1582
|
-
? ["--force",
|
|
1583
|
-
: ["--force", "--deep",
|
|
1622
|
+
? ["--force", ...codesignIdentityArgs(identity), bundle]
|
|
1623
|
+
: ["--force", "--deep", ...codesignIdentityArgs(identity), bundle];
|
|
1584
1624
|
}
|
|
1585
1625
|
|
|
1586
|
-
function signVendoredClaudeCapabilities(bundle) {
|
|
1626
|
+
function signVendoredClaudeCapabilities(bundle, identity = ADHOC_IDENTITY) {
|
|
1587
1627
|
const fullEntitlements = entitlementPlist({
|
|
1588
1628
|
"com.apple.security.cs.allow-jit": true,
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1629
|
+
// A local self-signed (or ad-hoc) signature shares no Apple Team ID with
|
|
1630
|
+
// Anthropic's separately signed Electron framework, so hardened runtime
|
|
1631
|
+
// otherwise refuses to map it. Library validation stays disabled for both.
|
|
1591
1632
|
"com.apple.security.cs.disable-library-validation": true,
|
|
1592
1633
|
"com.apple.security.device.audio-input": true,
|
|
1593
1634
|
"com.apple.security.device.bluetooth": true,
|
|
@@ -1618,13 +1659,15 @@ function signVendoredClaudeCapabilities(bundle) {
|
|
|
1618
1659
|
for (const helper of helpers) {
|
|
1619
1660
|
const entitlements = helper.name.endsWith(" Helper.app") ? fullPath : jitPath;
|
|
1620
1661
|
const result = codesign([
|
|
1621
|
-
"--force", "--deep", "--options", "runtime", "--entitlements", entitlements,
|
|
1662
|
+
"--force", "--deep", "--options", "runtime", "--entitlements", entitlements,
|
|
1663
|
+
...codesignIdentityArgs(identity),
|
|
1622
1664
|
path.join(frameworks, helper.name),
|
|
1623
1665
|
]);
|
|
1624
1666
|
if (result.status !== 0) return result;
|
|
1625
1667
|
}
|
|
1626
1668
|
return codesign([
|
|
1627
|
-
"--force", "--options", "runtime", "--entitlements", fullPath,
|
|
1669
|
+
"--force", "--options", "runtime", "--entitlements", fullPath,
|
|
1670
|
+
...codesignIdentityArgs(identity), bundle,
|
|
1628
1671
|
]);
|
|
1629
1672
|
} finally {
|
|
1630
1673
|
fs.rmSync(fullPath, { force: true });
|
package/src/codesign.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Stable local code-signing identity for the managed macOS app bundles.
|
|
2
|
+
//
|
|
3
|
+
// macOS ties every TCC privacy grant (microphone, screen recording, folder
|
|
4
|
+
// access, Automation/AppleScript, accessibility, …) to the app's code-signing
|
|
5
|
+
// "designated requirement". An ad-hoc signature (`codesign --sign -`) has no
|
|
6
|
+
// stable identity: its requirement degenerates to the binary's own cdhash,
|
|
7
|
+
// which changes on every rebuild. So each time `impel app update` re-signed a
|
|
8
|
+
// rebuilt bundle, macOS saw a brand-new app and re-prompted for every
|
|
9
|
+
// permission the user had already granted.
|
|
10
|
+
//
|
|
11
|
+
// Signing rebuilds with a stable per-machine self-signed certificate instead
|
|
12
|
+
// gives the bundle a constant designated requirement, so TCC grants survive
|
|
13
|
+
// updates the way they do for a normal app. The certificate lives in a
|
|
14
|
+
// dedicated keychain under the Impel config dir; no Apple Developer account is
|
|
15
|
+
// involved (these bundles are patched and built on the user's own machine, so
|
|
16
|
+
// Developer ID signing is impossible anyway).
|
|
17
|
+
//
|
|
18
|
+
// Everything here is best-effort: if any step fails — no `security`/`openssl`,
|
|
19
|
+
// a locked keychain, an unusual environment — we fall back to ad-hoc signing,
|
|
20
|
+
// which is exactly today's behavior. Updates never fail because signing could
|
|
21
|
+
// not be upgraded.
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import crypto from "node:crypto";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
|
|
29
|
+
import { CONFIG_DIR } from "./config.js";
|
|
30
|
+
|
|
31
|
+
export const SIGNING_IDENTITY_NAME = "Impel Local Codesigning";
|
|
32
|
+
|
|
33
|
+
// Ad-hoc: what every managed bundle was signed with before stable identities.
|
|
34
|
+
export const ADHOC_IDENTITY = Object.freeze({
|
|
35
|
+
mode: "adhoc",
|
|
36
|
+
signArg: "-",
|
|
37
|
+
keychainArgs: Object.freeze([]),
|
|
38
|
+
descriptor: "adhoc",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** `codesign` arguments that select the given identity (and its keychain). */
|
|
42
|
+
export function codesignIdentityArgs(identity = ADHOC_IDENTITY) {
|
|
43
|
+
return [...identity.keychainArgs, "--sign", identity.signArg];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The coarse signing mode the current environment WANTS, without creating or
|
|
48
|
+
* touching any certificate. `bundleIsCurrent` compares this against the mode
|
|
49
|
+
* recorded when a bundle was built, so a change in signing preference (or an
|
|
50
|
+
* old ad-hoc bundle that predates stable signing) forces exactly one rebuild.
|
|
51
|
+
* Kept cheap on purpose: resolving the concrete local identity would create a
|
|
52
|
+
* keychain, which must not happen during a read-only currency check.
|
|
53
|
+
*/
|
|
54
|
+
export function desiredSigningMode(env = process.env, platform = process.platform) {
|
|
55
|
+
if (platform !== "darwin") return "adhoc";
|
|
56
|
+
if (truthyEnv(env.IMPEL_CODESIGN_ADHOC)) return "adhoc";
|
|
57
|
+
if (env.IMPEL_CODESIGN_IDENTITY) return `external:${env.IMPEL_CODESIGN_IDENTITY}`;
|
|
58
|
+
return "local";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function truthyEnv(value) {
|
|
62
|
+
return value === "1" || value === "true" || value === "yes";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultRun(command, args, { input } = {}) {
|
|
66
|
+
return spawnSync(command, args, { encoding: "utf8", input });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function escapeRegExp(value) {
|
|
70
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// find-identity prints lines like ` 1) 0A1B…40hex "Impel Local Codesigning"`.
|
|
74
|
+
function parseIdentitySha(output, name) {
|
|
75
|
+
const pattern = new RegExp(`\\)\\s+([0-9A-Fa-f]{40})\\s+"${escapeRegExp(name)}"`, "u");
|
|
76
|
+
const match = String(output || "").match(pattern);
|
|
77
|
+
return match ? match[1] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let cachedIdentity = null;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the signing identity to use for managed bundles, creating the stable
|
|
84
|
+
* local certificate on first use. Cached per process. Returns an identity
|
|
85
|
+
* object whose `signArg`/`keychainArgs` feed straight into `codesign`.
|
|
86
|
+
*
|
|
87
|
+
* Env overrides:
|
|
88
|
+
* IMPEL_CODESIGN_ADHOC=1 force ad-hoc signing (the pre-1.0 behavior)
|
|
89
|
+
* IMPEL_CODESIGN_IDENTITY=<id> sign with a specific existing identity
|
|
90
|
+
* (e.g. a real "Developer ID Application: …")
|
|
91
|
+
*/
|
|
92
|
+
export function resolveSigningIdentity(deps = {}) {
|
|
93
|
+
const {
|
|
94
|
+
env = process.env,
|
|
95
|
+
platform = process.platform,
|
|
96
|
+
run = defaultRun,
|
|
97
|
+
configDir = CONFIG_DIR,
|
|
98
|
+
fsImpl = fs,
|
|
99
|
+
warn = (message) => console.warn(message),
|
|
100
|
+
randomBytes = crypto.randomBytes,
|
|
101
|
+
cache = true,
|
|
102
|
+
} = deps;
|
|
103
|
+
|
|
104
|
+
if (cache && cachedIdentity) return cachedIdentity;
|
|
105
|
+
if (platform !== "darwin") return ADHOC_IDENTITY;
|
|
106
|
+
if (truthyEnv(env.IMPEL_CODESIGN_ADHOC)) return ADHOC_IDENTITY;
|
|
107
|
+
|
|
108
|
+
if (env.IMPEL_CODESIGN_IDENTITY) {
|
|
109
|
+
const identity = {
|
|
110
|
+
mode: "external",
|
|
111
|
+
signArg: env.IMPEL_CODESIGN_IDENTITY,
|
|
112
|
+
keychainArgs: [],
|
|
113
|
+
descriptor: `external:${env.IMPEL_CODESIGN_IDENTITY}`,
|
|
114
|
+
};
|
|
115
|
+
if (cache) cachedIdentity = identity;
|
|
116
|
+
return identity;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const identity = ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes });
|
|
121
|
+
if (cache) cachedIdentity = identity;
|
|
122
|
+
return identity;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
warn(`impel: falling back to ad-hoc app signing (${error?.message || error}); permission prompts may repeat after updates.`);
|
|
125
|
+
if (cache) cachedIdentity = ADHOC_IDENTITY;
|
|
126
|
+
return ADHOC_IDENTITY;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** For tests: drop the per-process memo so env overrides take effect again. */
|
|
131
|
+
export function resetSigningIdentityCache() {
|
|
132
|
+
cachedIdentity = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes }) {
|
|
136
|
+
const dir = path.join(configDir, "codesign");
|
|
137
|
+
const keychain = path.join(dir, "impel-codesign.keychain-db");
|
|
138
|
+
const passwordFile = path.join(dir, "keychain.pass");
|
|
139
|
+
fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
140
|
+
|
|
141
|
+
// Reuse an already-created identity: the whole point is that the cert (and
|
|
142
|
+
// therefore the designated requirement) stays stable across updates.
|
|
143
|
+
if (fsImpl.existsSync(keychain) && fsImpl.existsSync(passwordFile)) {
|
|
144
|
+
const password = fsImpl.readFileSync(passwordFile, "utf8").trim();
|
|
145
|
+
unlockKeychain(run, keychain, password);
|
|
146
|
+
const existing = findLocalIdentitySha(run, keychain);
|
|
147
|
+
if (existing) return localIdentity(existing, keychain);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const password = randomBytes(24).toString("hex");
|
|
151
|
+
createLocalSigningIdentity({ run, dir, keychain, password, fsImpl });
|
|
152
|
+
fsImpl.writeFileSync(passwordFile, `${password}\n`, { mode: 0o600 });
|
|
153
|
+
|
|
154
|
+
const sha = findLocalIdentitySha(run, keychain);
|
|
155
|
+
if (!sha) throw new Error("created signing identity was not found in its keychain");
|
|
156
|
+
ensureKeychainInSearchList(run, keychain);
|
|
157
|
+
return localIdentity(sha, keychain);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function localIdentity(sha, keychain) {
|
|
161
|
+
return {
|
|
162
|
+
mode: "local",
|
|
163
|
+
signArg: sha,
|
|
164
|
+
keychainArgs: ["--keychain", keychain],
|
|
165
|
+
descriptor: `local:${sha}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function findLocalIdentitySha(run, keychain) {
|
|
170
|
+
const result = run("/usr/bin/security", ["find-identity", "-v", "-p", "codesigning", keychain]);
|
|
171
|
+
if (result.status !== 0) return null;
|
|
172
|
+
return parseIdentitySha(result.stdout, SIGNING_IDENTITY_NAME);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
|
|
176
|
+
const work = fsImpl.mkdtempSync(path.join(os.tmpdir(), "impel-codesign-"));
|
|
177
|
+
const keyPath = path.join(work, "key.pem");
|
|
178
|
+
const certPath = path.join(work, "cert.pem");
|
|
179
|
+
const p12Path = path.join(work, "identity.p12");
|
|
180
|
+
const p12Pass = crypto.randomBytes(18).toString("hex");
|
|
181
|
+
try {
|
|
182
|
+
// A self-signed leaf certificate with the codeSigning EKU. It is never
|
|
183
|
+
// trusted for distribution; it only needs to exist so codesign can bind a
|
|
184
|
+
// stable identity into the signature. Locally built, de-quarantined apps
|
|
185
|
+
// launch regardless of certificate trust (Gatekeeper only gates quarantined
|
|
186
|
+
// downloads), exactly as the ad-hoc bundles do today.
|
|
187
|
+
runOrThrow(run, "/usr/bin/openssl", [
|
|
188
|
+
"req", "-x509", "-newkey", "rsa:2048",
|
|
189
|
+
"-keyout", keyPath, "-out", certPath,
|
|
190
|
+
"-days", "3650", "-nodes",
|
|
191
|
+
"-subj", `/CN=${SIGNING_IDENTITY_NAME}`,
|
|
192
|
+
"-addext", "basicConstraints=critical,CA:false",
|
|
193
|
+
"-addext", "keyUsage=critical,digitalSignature",
|
|
194
|
+
"-addext", "extendedKeyUsage=critical,codeSigning",
|
|
195
|
+
], "generate signing certificate");
|
|
196
|
+
runOrThrow(run, "/usr/bin/openssl", [
|
|
197
|
+
"pkcs12", "-export", "-out", p12Path,
|
|
198
|
+
"-inkey", keyPath, "-in", certPath,
|
|
199
|
+
"-passout", `pass:${p12Pass}`,
|
|
200
|
+
"-name", SIGNING_IDENTITY_NAME,
|
|
201
|
+
], "package signing certificate");
|
|
202
|
+
|
|
203
|
+
if (!fsImpl.existsSync(keychain)) {
|
|
204
|
+
runOrThrow(run, "/usr/bin/security", ["create-keychain", "-p", password, keychain], "create signing keychain");
|
|
205
|
+
}
|
|
206
|
+
// No auto-lock: codesign must reach the private key non-interactively.
|
|
207
|
+
run("/usr/bin/security", ["set-keychain-settings", keychain]);
|
|
208
|
+
unlockKeychain(run, keychain, password);
|
|
209
|
+
runOrThrow(run, "/usr/bin/security", [
|
|
210
|
+
"import", p12Path, "-k", keychain, "-P", p12Pass, "-T", "/usr/bin/codesign",
|
|
211
|
+
], "import signing identity");
|
|
212
|
+
// Grant codesign non-interactive access to the key so signing never pops a
|
|
213
|
+
// "codesign wants to use a key" GUI prompt. We own the keychain password.
|
|
214
|
+
runOrThrow(run, "/usr/bin/security", [
|
|
215
|
+
"set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
|
|
216
|
+
], "authorize codesign for the signing key");
|
|
217
|
+
} finally {
|
|
218
|
+
fsImpl.rmSync(work, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function unlockKeychain(run, keychain, password) {
|
|
223
|
+
run("/usr/bin/security", ["unlock-keychain", "-p", password, keychain]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// codesign resolves an identity by searching the user's keychain search list,
|
|
227
|
+
// so the dedicated keychain has to be on it. Append without disturbing the
|
|
228
|
+
// user's existing keychains (a bare `-s <one>` would replace the whole list).
|
|
229
|
+
function ensureKeychainInSearchList(run, keychain) {
|
|
230
|
+
const listed = run("/usr/bin/security", ["list-keychains", "-d", "user"]);
|
|
231
|
+
const current = String(listed.stdout || "")
|
|
232
|
+
.split("\n")
|
|
233
|
+
.map((line) => line.trim().replace(/^"|"$/gu, ""))
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
if (current.includes(keychain)) return;
|
|
236
|
+
run("/usr/bin/security", ["list-keychains", "-d", "user", "-s", ...current, keychain]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function runOrThrow(run, command, args, description) {
|
|
240
|
+
const result = run(command, args);
|
|
241
|
+
if (result.status !== 0 || result.error) {
|
|
242
|
+
const detail = result.error?.message || String(result.stderr || "").trim() || `exit ${result.status}`;
|
|
243
|
+
throw new Error(`${description} failed: ${detail}`);
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
package/src/commands/apps.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
ensureVendorApp,
|
|
32
32
|
fetchGatewayModels,
|
|
33
33
|
installManagedAppFiles,
|
|
34
|
+
managedLauncherName,
|
|
34
35
|
normalizeAppTarget,
|
|
35
36
|
quitBlockingApps,
|
|
36
37
|
readTenantManifest,
|
|
@@ -548,6 +549,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
548
549
|
syncAgents: syncAgentProfilesSafe,
|
|
549
550
|
secureCodexHome: secureManagedCodexHome,
|
|
550
551
|
removeLauncher: (launcher) => fs.rmSync(launcher, { recursive: true, force: true }),
|
|
552
|
+
openLauncher: openManagedLauncher,
|
|
551
553
|
log: (message) => console.log(message),
|
|
552
554
|
...overrides,
|
|
553
555
|
};
|
|
@@ -589,13 +591,22 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
589
591
|
);
|
|
590
592
|
}
|
|
591
593
|
|
|
592
|
-
// A running vendor or Impel app only needs to close when its bundle will be
|
|
594
|
+
// A running vendor or Impel app only needs to close when its bundle will be
|
|
595
|
+
// swapped. Remember which managed launchers we closed so the update can
|
|
596
|
+
// reopen them afterward and feel in-place (the fast path never quits at all).
|
|
597
|
+
let closedApps = [];
|
|
593
598
|
if (staleBundleTargets.length > 0) {
|
|
594
|
-
await io.quitApps(staleBundleTargets, {
|
|
599
|
+
const quitResult = await io.quitApps(staleBundleTargets, {
|
|
595
600
|
tenantId: config.tenantId,
|
|
596
601
|
tenantName: config.tenantName,
|
|
597
602
|
});
|
|
603
|
+
if (Array.isArray(quitResult)) closedApps = quitResult;
|
|
598
604
|
}
|
|
605
|
+
const reopenTargets = new Set(
|
|
606
|
+
staleBundleTargets.filter((target) => closedApps.includes(
|
|
607
|
+
managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
|
|
608
|
+
)),
|
|
609
|
+
);
|
|
599
610
|
if (force) {
|
|
600
611
|
for (const status of statuses) io.removeLauncher(status.launcher);
|
|
601
612
|
}
|
|
@@ -638,6 +649,15 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
638
649
|
tenantId: config.tenantId,
|
|
639
650
|
}));
|
|
640
651
|
}
|
|
652
|
+
|
|
653
|
+
// Reopen the managed apps we had to close for the bundle swap, so the update
|
|
654
|
+
// lands in place from the user's point of view. Apps that were already
|
|
655
|
+
// closed (not in reopenTargets) stay closed.
|
|
656
|
+
for (const item of installed) {
|
|
657
|
+
if (!reopenTargets.has(item.target)) continue;
|
|
658
|
+
io.log(`Reopening ${item.launcher} after the update…`);
|
|
659
|
+
io.openLauncher(item.launcher);
|
|
660
|
+
}
|
|
641
661
|
}
|
|
642
662
|
|
|
643
663
|
/**
|