impel-cli 0.16.4 → 0.16.5

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 CHANGED
@@ -25,6 +25,32 @@ of the protected Store directory and launches it with tenant-specific Codex and
25
25
  browser profiles. Each approach keeps the user's normal app profile and
26
26
  signed-in account untouched.
27
27
 
28
+ Every Impel-managed Claude Code, Codex CLI, Claude Desktop, and ChatGPT/Codex
29
+ Desktop profile also installs tenant-scoped lifecycle hooks for cloud session
30
+ persistence. Hook input is first written to a private local outbox, so an
31
+ offline network, interrupted hook, force-quit, or client restart cannot make a
32
+ provider turn fail. The next hook invocation retries any pending batches,
33
+ including batches left by an older session. Remote payloads are stored as
34
+ independent zstd frames by `impel-sessions`; the local collector sends bounded
35
+ newline-complete transcript deltas and a compact lifecycle ledger. Pending data
36
+ is capped at 256 MiB or 2,048 batches per session by default; corrupt or
37
+ permanently invalid batches are quarantined so they cannot block later data.
38
+ Set `IMPEL_SESSIONS_MAX_OUTBOX_BYTES` or `IMPEL_SESSIONS_MAX_OUTBOX_BATCHES` to
39
+ lower those local limits.
40
+
41
+ The service attributes each session to the PAT-resolved user and selected
42
+ tenant and records the provider, CLI/desktop surface, provider session ID, and
43
+ repository metadata. Set `IMPEL_TASK_ID` before launching a client to link new
44
+ sessions explicitly to an Impel task for cross-device discovery. The collector
45
+ never writes the configured PAT into hook configuration, and it redacts Impel
46
+ PAT/tenant credentials found in hook input or transcript content before
47
+ spooling. Task linkage is retried independently and cannot block payload
48
+ persistence when the current PAT lacks the `tasks` scope.
49
+
50
+ Codex hook trust is pinned to the exact generated handler and its array index.
51
+ If you manually reorder the managed group in `hooks.json`, rerun `impel setup`,
52
+ `impel app update`, or relaunch the isolated CLI to regenerate the trust entry.
53
+
28
54
  ## Gateway-only vendor package
29
55
 
30
56
  White-labelled gateway launchers import the deliberately narrow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.16.4",
3
+ "version": "0.16.5",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { normalizeTenantId } from "./tenants.js";
13
13
  import { impelCliInvocation } from "./selfInvocation.js";
14
14
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
15
+ import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
15
16
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
16
17
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
17
18
 
@@ -163,7 +164,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
163
164
 
164
165
  // Bump when the written config/manifest schema changes; a mismatch forces the
165
166
  // slow open path (and thus a full config rewrite) after a CLI update.
166
- export const CURRENT_CONFIG_VERSION = 15;
167
+ export const CURRENT_CONFIG_VERSION = 16;
167
168
 
168
169
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
169
170
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -617,8 +618,12 @@ export function installManagedAppFiles({
617
618
  writeClaudeDefaultApp(paths);
618
619
  writeClaudeCodeSettings(paths);
619
620
  writeClaudeConfig(paths, config, models);
621
+ ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
622
+ }
623
+ else {
624
+ writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
625
+ ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
620
626
  }
621
- else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
622
627
  // Non-bundle targets are the background-refresh / fast-open path: configs,
623
628
  // token helper, catalog, and manifest only. Bundle swaps require the app
624
629
  // to be closed (see quitBlockingApps), so those paths never attempt one.
@@ -1024,6 +1029,7 @@ function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocation
1024
1029
  const managedToml = [
1025
1030
  CHATGPT_CONFIG_START,
1026
1031
  "# Managed by `impel app update`. This profile is isolated from ~/.codex.",
1032
+ "# Impel session-hook trust is pinned separately by exact handler hash.",
1027
1033
  `model = ${tomlString(selectedModel.slug)}`,
1028
1034
  'model_provider = "impel"',
1029
1035
  `chatgpt_base_url = ${tomlString(`${config.gatewayUrl}/chatgpt_passthrough/backend-api`)}`,
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ import { cmdAgents } from "./commands/agents.js";
15
15
  import { cmdTenant } from "./commands/tenant.js";
16
16
  import { cmdDoctor } from "./commands/doctor.js";
17
17
  import { cmdSetup } from "./commands/setup.js";
18
+ import { cmdSessions } from "./commands/sessions.js";
18
19
  import { cmdUpdate } from "./commands/update.js";
19
20
  import { cmdExperimental } from "./commands/experimental.js";
20
21
 
@@ -121,6 +122,10 @@ export async function main(argv) {
121
122
  case "mcp":
122
123
  return cmdMcp(rest);
123
124
 
125
+ // Hidden transport used by managed Claude/Codex lifecycle hooks.
126
+ case "sessions":
127
+ return cmdSessions(rest);
128
+
124
129
  case "claude":
125
130
  case "codex":
126
131
  return cmdLaunch(cmd, rest);
@@ -12,6 +12,7 @@ import { CONFIG_DIR } from "./config.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
13
  import { impelCliInvocation, impelMcpInvocation } from "./selfInvocation.js";
14
14
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
15
+ import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
15
16
 
16
17
  export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
17
18
 
@@ -131,6 +132,7 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
131
132
 
132
133
  writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
133
134
  writePrivateFile(userConfigPath, `${JSON.stringify(userConfig, null, 2)}\n`);
135
+ ensureClaudeSessionHooks(configDir, tenantId, "claude_cli");
134
136
 
135
137
  return { configDir, settingsPath, userConfigPath };
136
138
  }
@@ -203,9 +205,10 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
203
205
 
204
206
  const { preamble, rest } = splitTomlPreamble(withoutManagedBlock);
205
207
  const providerLine = 'model_provider = "impel"';
206
- const nextPreamble = CODEX_PROVIDER_LINE_RE.test(preamble)
208
+ let nextPreamble = CODEX_PROVIDER_LINE_RE.test(preamble)
207
209
  ? preamble.replace(CODEX_PROVIDER_LINE_RE, providerLine)
208
210
  : `${preamble.trimEnd()}${preamble.trim() ? "\n" : ""}${providerLine}\n`;
211
+ nextPreamble = nextPreamble.replace(/^bypass_hook_trust[ \t]*=[ \t]*(?:true|false)[ \t]*\n?/gm, "");
209
212
  const restText = rest.trim();
210
213
  const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId), restText]
211
214
  .filter(Boolean)
@@ -213,6 +216,7 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
213
216
  .concat("\n");
214
217
 
215
218
  writePrivateFile(configPath, hardenManagedCodexToml(next, configPath));
219
+ ensureCodexSessionHooks(codexHome, tenantId, "codex_cli");
216
220
  secureManagedCodexHome(codexHome);
217
221
  return { codexHome, configPath };
218
222
  }
@@ -0,0 +1,114 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import { parseFlags } from "../args.js";
4
+ import { collectSessionHook, flushCollectedSession, readHookInput, sessionOutboxStatus } from "../sessionCollector.js";
5
+ import { loadConfig, redactSecretText } from "../config.js";
6
+ import { impelCliInvocation } from "../selfInvocation.js";
7
+
8
+ const SPEC = {
9
+ provider: { type: "string" },
10
+ surface: { type: "string" },
11
+ tenant: { type: "string" },
12
+ session: { type: "string" },
13
+ "impel-managed-session-hook-v1": { type: "boolean" },
14
+ };
15
+
16
+ function startDetachedFlush({ provider, tenant, session }) {
17
+ const invocation = impelCliInvocation([
18
+ "sessions",
19
+ "flush",
20
+ "--provider",
21
+ provider,
22
+ "--tenant",
23
+ tenant,
24
+ "--session",
25
+ session,
26
+ "--impel-managed-session-hook-v1",
27
+ ]);
28
+ const child = spawn(invocation.command, invocation.args, {
29
+ detached: true,
30
+ stdio: "ignore",
31
+ env: { ...process.env, IMPEL_SESSIONS_FLUSH_CHILD: "1" },
32
+ windowsHide: true,
33
+ });
34
+ child.once("error", () => {});
35
+ child.unref();
36
+ }
37
+
38
+ export async function cmdSessions(argv) {
39
+ const [action, ...rest] = argv;
40
+ if (action === "hook") {
41
+ try {
42
+ const { flags, positionals } = parseFlags(rest, SPEC);
43
+ if (positionals.length > 0) throw new Error("unexpected positional arguments");
44
+ const input = await readHookInput();
45
+ let config = null;
46
+ try {
47
+ config = loadConfig();
48
+ } catch {
49
+ // Capture remains available even while the user's config needs repair.
50
+ }
51
+ await collectSessionHook({
52
+ provider: flags.provider,
53
+ surface: flags.surface,
54
+ tenantId: flags.tenant,
55
+ input,
56
+ config,
57
+ flush: false,
58
+ });
59
+ if (config && (config.tenantId === flags.tenant || process.env.IMPEL_SESSIONS_DEV_ORG_ID)) {
60
+ startDetachedFlush({
61
+ provider: flags.provider,
62
+ tenant: flags.tenant,
63
+ session: String(input.session_id || ""),
64
+ });
65
+ }
66
+ } catch (error) {
67
+ // Session persistence is observational. A collector outage must never
68
+ // block a model turn or change a provider hook's decision semantics.
69
+ if (process.env.IMPEL_SESSIONS_DEBUG === "1") {
70
+ console.error(`impel sessions hook: ${redactSecretText(error?.message || error)}`);
71
+ }
72
+ }
73
+ return;
74
+ }
75
+ const { flags, positionals } = parseFlags(rest, SPEC);
76
+ if (positionals.length > 0) {
77
+ console.error("impel sessions: unexpected positional arguments");
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ const config = loadConfig();
82
+ if (action === "flush") {
83
+ if (!flags.provider || !flags.session || !flags.tenant) return;
84
+ if (!["claude_code", "codex"].includes(flags.provider)) return;
85
+ if (!config || flags.tenant !== config.tenantId && !process.env.IMPEL_SESSIONS_DEV_ORG_ID) return;
86
+ const deadline = Date.now() + 90_000;
87
+ while (Date.now() < deadline) {
88
+ const result = await flushCollectedSession({
89
+ tenantId: flags.tenant,
90
+ provider: flags.provider,
91
+ sessionKey: flags.session,
92
+ config,
93
+ });
94
+ if (result.pending === 0) break;
95
+ await new Promise((resolve) => setTimeout(resolve, result.error || result.busy ? 1000 : 250));
96
+ }
97
+ return;
98
+ }
99
+ if (action === "status") {
100
+ if (!flags.provider || !flags.session || !flags.tenant) {
101
+ console.error("impel sessions status requires --provider, --session, and --tenant");
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ console.log(JSON.stringify(sessionOutboxStatus({
106
+ tenantId: flags.tenant,
107
+ provider: flags.provider,
108
+ sessionKey: flags.session,
109
+ }), null, 2));
110
+ return;
111
+ }
112
+ console.error("impel sessions: use `hook` (managed internally) or `status`");
113
+ process.exitCode = 1;
114
+ }
package/src/config.js CHANGED
@@ -89,11 +89,16 @@ const PAT_RE = /impel_pat_[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?/gu;
89
89
  const ANSI_ESCAPE_RE = /\u001B(?:\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\[[0-?]*[ -/]*[@-~]|[@-_])/gu;
90
90
  const TERMINAL_CONTROL_RE = /[\u0000-\u001F\u007F-\u009F]/gu;
91
91
 
92
- /** Remove credentials and terminal control sequences from untrusted text. */
93
- export function redactSecretText(value) {
92
+ /** Redact Impel bearer credentials without changing JSON/NDJSON whitespace. */
93
+ export function redactCredentialText(value) {
94
94
  return String(value ?? "")
95
95
  .replace(TENANT_CREDENTIAL_RE, "[REDACTED IMPEL CREDENTIAL]")
96
- .replace(PAT_RE, "[REDACTED IMPEL CREDENTIAL]")
96
+ .replace(PAT_RE, "[REDACTED IMPEL CREDENTIAL]");
97
+ }
98
+
99
+ /** Remove credentials and terminal control sequences from untrusted text. */
100
+ export function redactSecretText(value) {
101
+ return redactCredentialText(value)
97
102
  .replace(ANSI_ESCAPE_RE, "")
98
103
  .replace(TERMINAL_CONTROL_RE, " ");
99
104
  }