impel-cli 0.16.3 → 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/src/codexSetup.js CHANGED
@@ -49,8 +49,17 @@ const START_MARK = `# >>> impel-cli managed block (model_providers.${PROVIDER_ID
49
49
  const END_MARK = `# <<< impel-cli managed block <<<`;
50
50
 
51
51
  const PROVIDER_LINE_RE = /^model_provider[ \t]*=[ \t]*"([^"]*)"[ \t]*$/m;
52
+ const NETWORK_TABLE = "sandbox_workspace_write";
53
+ const NETWORK_KEY = "network_access";
54
+ const NETWORK_DOTTED_RE = /^(\s*)sandbox_workspace_write\.network_access\s*=.*$/u;
55
+ const NETWORK_HEADER_RE = /^\s*\[sandbox_workspace_write\]\s*(?:#.*)?$/u;
56
+ const NETWORK_KEY_RE = /^(\s*)network_access\s*=.*$/u;
57
+ const SANDBOX_MODE_RE = /^(\s*)sandbox_mode\s*=.*$/u;
52
58
 
53
- function providerTablesBlock(gatewayUrl) {
59
+ function providerTablesBlock(
60
+ gatewayUrl,
61
+ { includeNetworkAccess = false, includeSandboxMode = false } = {},
62
+ ) {
54
63
  const baseUrl = impelCodexBaseUrl(gatewayUrl);
55
64
  const auth = impelCliInvocation(["token"]);
56
65
  const mcp = impelCliInvocation(["mcp"]);
@@ -58,6 +67,8 @@ function providerTablesBlock(gatewayUrl) {
58
67
  START_MARK,
59
68
  "# Generated by `impel use gateway codex`. Safe to re-run; do not hand-edit",
60
69
  "# the lines between the markers above/below, they'll be overwritten.",
70
+ ...(includeSandboxMode ? ['sandbox_mode = "workspace-write"'] : []),
71
+ ...(includeNetworkAccess ? [`${NETWORK_TABLE}.${NETWORK_KEY} = true`, ""] : []),
61
72
  `[model_providers.${PROVIDER_ID}]`,
62
73
  `name = "Impel Gateway"`,
63
74
  `base_url = "${baseUrl}"`,
@@ -76,6 +87,123 @@ function providerTablesBlock(gatewayUrl) {
76
87
  ].join("\n");
77
88
  }
78
89
 
90
+ function applyCodexSandboxMode(text, configPath = CODEX_CONFIG_PATH) {
91
+ const lines = String(text || "").replace(/\r\n/gu, "\n").split("\n");
92
+ const firstTable = lines.findIndex((line) => /^\s*\[/u.test(line));
93
+ const preambleEnd = firstTable === -1 ? lines.length : firstTable;
94
+ const indices = lines
95
+ .slice(0, preambleEnd)
96
+ .flatMap((line, index) => (SANDBOX_MODE_RE.test(line) ? [index] : []));
97
+ if (indices.length > 1) {
98
+ throw new Error(`${configPath} defines sandbox_mode more than once; resolve it before re-running.`);
99
+ }
100
+ if (indices.length === 0) {
101
+ return { text, prior: { kind: "absent" }, includeInBlock: true };
102
+ }
103
+ const index = indices[0];
104
+ const line = lines[index];
105
+ const indent = line.match(SANDBOX_MODE_RE)?.[1] || "";
106
+ lines[index] = `${indent}sandbox_mode = "workspace-write"`;
107
+ return {
108
+ text: lines.join("\n"),
109
+ prior: { kind: "root", line },
110
+ includeInBlock: false,
111
+ };
112
+ }
113
+
114
+ function restoreCodexSandboxMode(text, snapshot) {
115
+ if (!snapshot || snapshot.kind === "absent") return { text, changed: false };
116
+ const lines = String(text || "").replace(/\r\n/gu, "\n").split("\n");
117
+ const firstTable = lines.findIndex((line) => /^\s*\[/u.test(line));
118
+ const preambleEnd = firstTable === -1 ? lines.length : firstTable;
119
+ const indices = lines
120
+ .slice(0, preambleEnd)
121
+ .flatMap((line, index) => (SANDBOX_MODE_RE.test(line) ? [index] : []));
122
+ if (indices.length !== 1 || !/^\s*sandbox_mode\s*=\s*"workspace-write"\s*(?:#.*)?$/u.test(lines[indices[0]])) {
123
+ return { text, changed: false };
124
+ }
125
+ lines[indices[0]] = snapshot.line;
126
+ return { text: lines.join("\n"), changed: true };
127
+ }
128
+
129
+ function locateCodexNetworkAccess(text, configPath = CODEX_CONFIG_PATH) {
130
+ const lines = String(text || "").replace(/\r\n/gu, "\n").split("\n");
131
+ const firstTable = lines.findIndex((line) => /^\s*\[/u.test(line));
132
+ const preambleEnd = firstTable === -1 ? lines.length : firstTable;
133
+ const dotted = lines
134
+ .slice(0, preambleEnd)
135
+ .flatMap((line, index) => (NETWORK_DOTTED_RE.test(line) ? [index] : []));
136
+ const headers = lines.flatMap((line, index) => (NETWORK_HEADER_RE.test(line) ? [index] : []));
137
+ if (dotted.length > 1 || headers.length > 1 || (dotted.length && headers.length)) {
138
+ throw new Error(`${configPath} defines ${NETWORK_TABLE}.${NETWORK_KEY} more than once; resolve it before re-running.`);
139
+ }
140
+ if (dotted.length === 1) {
141
+ const index = dotted[0];
142
+ return { lines, kind: "dotted", index, line: lines[index] };
143
+ }
144
+ if (headers.length === 1) {
145
+ const header = headers[0];
146
+ const nextTableOffset = lines.slice(header + 1).findIndex((line) => /^\s*\[/u.test(line));
147
+ const end = nextTableOffset === -1 ? lines.length : header + 1 + nextTableOffset;
148
+ const keys = lines
149
+ .slice(header + 1, end)
150
+ .flatMap((line, index) => (NETWORK_KEY_RE.test(line) ? [header + 1 + index] : []));
151
+ if (keys.length > 1) {
152
+ throw new Error(`${configPath} defines ${NETWORK_KEY} more than once in [${NETWORK_TABLE}].`);
153
+ }
154
+ if (keys.length === 1) {
155
+ const index = keys[0];
156
+ return { lines, kind: "table", index, line: lines[index] };
157
+ }
158
+ return { lines, kind: "table-missing", header };
159
+ }
160
+ if (lines.slice(0, preambleEnd).some((line) => /^\s*sandbox_workspace_write\s*=/u.test(line))) {
161
+ throw new Error(`${configPath} defines ${NETWORK_TABLE} as an inline table; convert it to [${NETWORK_TABLE}] before re-running.`);
162
+ }
163
+ return { lines, kind: "absent" };
164
+ }
165
+
166
+ function applyCodexNetworkAccess(text, configPath = CODEX_CONFIG_PATH) {
167
+ const state = locateCodexNetworkAccess(text, configPath);
168
+ if (state.kind === "absent") {
169
+ return { text, prior: { kind: "absent" }, includeInBlock: true };
170
+ }
171
+ if (state.kind === "table-missing") {
172
+ state.lines.splice(state.header + 1, 0, `${NETWORK_KEY} = true`);
173
+ return {
174
+ text: state.lines.join("\n"),
175
+ prior: { kind: "table-missing" },
176
+ includeInBlock: false,
177
+ };
178
+ }
179
+ const indent = state.line.match(state.kind === "dotted" ? NETWORK_DOTTED_RE : NETWORK_KEY_RE)?.[1] || "";
180
+ state.lines[state.index] = state.kind === "dotted"
181
+ ? `${indent}${NETWORK_TABLE}.${NETWORK_KEY} = true`
182
+ : `${indent}${NETWORK_KEY} = true`;
183
+ return {
184
+ text: state.lines.join("\n"),
185
+ prior: { kind: state.kind, line: state.line },
186
+ includeInBlock: false,
187
+ };
188
+ }
189
+
190
+ function restoreCodexNetworkAccess(text, snapshot, configPath = CODEX_CONFIG_PATH) {
191
+ if (!snapshot || snapshot.kind === "absent") return { text, changed: false };
192
+ const state = locateCodexNetworkAccess(text, configPath);
193
+ if (!["dotted", "table"].includes(state.kind) || !/=\s*true\s*(?:#.*)?$/u.test(state.line)) {
194
+ return { text, changed: false };
195
+ }
196
+ if (snapshot.kind === "table-missing" && state.kind === "table") {
197
+ state.lines.splice(state.index, 1);
198
+ return { text: state.lines.join("\n"), changed: true };
199
+ }
200
+ if (snapshot.kind !== state.kind || typeof snapshot.line !== "string") {
201
+ return { text, changed: false };
202
+ }
203
+ state.lines[state.index] = snapshot.line;
204
+ return { text: state.lines.join("\n"), changed: true };
205
+ }
206
+
79
207
  function readConfig() {
80
208
  const exists = fs.existsSync(CODEX_CONFIG_PATH);
81
209
  const text = exists ? fs.readFileSync(CODEX_CONFIG_PATH, "utf8") : "";
@@ -155,7 +283,10 @@ export function applyCodexGateway(gatewayUrl) {
155
283
  );
156
284
  }
157
285
 
158
- const { preamble, rest } = splitPreamble(withoutOurBlock);
286
+ const sandboxMode = applyCodexSandboxMode(withoutOurBlock);
287
+ const networkAccess = applyCodexNetworkAccess(sandboxMode.text);
288
+
289
+ const { preamble, rest } = splitPreamble(networkAccess.text);
159
290
  const priorProviderValue = preamble.match(PROVIDER_LINE_RE)?.[1] ?? null;
160
291
  const newProviderLine = `model_provider = "${PROVIDER_ID}"`;
161
292
 
@@ -167,7 +298,10 @@ export function applyCodexGateway(gatewayUrl) {
167
298
  newPreamble = trimmed ? `${trimmed}\n${newProviderLine}\n` : `${newProviderLine}\n`;
168
299
  }
169
300
 
170
- const block = providerTablesBlock(gatewayUrl);
301
+ const block = providerTablesBlock(gatewayUrl, {
302
+ includeNetworkAccess: networkAccess.includeInBlock,
303
+ includeSandboxMode: sandboxMode.includeInBlock,
304
+ });
171
305
  const restTrimmed = rest.trim();
172
306
  const newText = `${newPreamble.trimEnd()}\n\n${block}\n` + (restTrimmed ? `\n${restTrimmed}\n` : "");
173
307
 
@@ -177,6 +311,8 @@ export function applyCodexGateway(gatewayUrl) {
177
311
  path: CODEX_CONFIG_PATH,
178
312
  baseUrl: impelCodexBaseUrl(gatewayUrl),
179
313
  priorProviderValue,
314
+ priorNetworkAccess: networkAccess.prior,
315
+ priorSandboxMode: sandboxMode.prior,
180
316
  };
181
317
  }
182
318
 
@@ -196,6 +332,11 @@ export function revertCodexGateway(backup = {}) {
196
332
  const removedBlock = original.includes(START_MARK);
197
333
  let text = stripManagedBlock(original);
198
334
 
335
+ const networkAccess = restoreCodexNetworkAccess(text, backup.network_access);
336
+ text = networkAccess.text;
337
+ const sandboxMode = restoreCodexSandboxMode(text, backup.sandbox_mode);
338
+ text = sandboxMode.text;
339
+
199
340
  let resetProvider = false;
200
341
  let restoredProvider = null;
201
342
  if (currentProviderValue(text) === PROVIDER_ID) {
@@ -220,5 +361,7 @@ export function revertCodexGateway(backup = {}) {
220
361
  removedBlock,
221
362
  resetProvider,
222
363
  restoredProvider,
364
+ restoredNetworkAccess: networkAccess.changed,
365
+ restoredSandboxMode: sandboxMode.changed,
223
366
  };
224
367
  }
@@ -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
+ }
@@ -78,6 +78,11 @@ async function useGateway({ target, app }) {
78
78
  }
79
79
  if (!Object.hasOwn(backup, "sandbox")) {
80
80
  backup.sandbox = result.priorSandbox;
81
+ } else if (
82
+ !Object.hasOwn(backup.sandbox, "permissions")
83
+ && Object.hasOwn(result.priorSandbox, "permissions")
84
+ ) {
85
+ backup.sandbox.permissions = result.priorSandbox.permissions;
81
86
  }
82
87
  console.log(`Claude Code -> GATEWAY (${result.path})`);
83
88
  console.log(` apiKeyHelper = "${result.apiKeyHelper}"`);
@@ -99,12 +104,19 @@ async function useGateway({ target, app }) {
99
104
  if (result.priorProviderValue !== PROVIDER_ID) {
100
105
  backup.model_provider = result.priorProviderValue ?? null;
101
106
  }
107
+ if (!Object.hasOwn(backup, "network_access")) {
108
+ backup.network_access = result.priorNetworkAccess;
109
+ }
110
+ if (!Object.hasOwn(backup, "sandbox_mode")) {
111
+ backup.sandbox_mode = result.priorSandboxMode;
112
+ }
102
113
 
103
114
  console.log(`Codex ${app ? "app/IDE" : "CLI"} -> GATEWAY (${result.path})`);
104
115
  console.log(` model_provider = "${PROVIDER_ID}"`);
105
116
  console.log(` [model_providers.${PROVIDER_ID}] base_url = "${result.baseUrl}"`);
106
117
  console.log(` [model_providers.${PROVIDER_ID}.auth] = direct Impel CLI invocation`);
107
118
  console.log(` [mcp_servers.${PROVIDER_ID}] = direct Impel CLI invocation`);
119
+ console.log(` sandbox workspace-write network = unrestricted`);
108
120
  }
109
121
 
110
122
  saveConfig(config);
@@ -194,6 +206,8 @@ function useAccount({ target, app }) {
194
206
  : ` removed model_provider line (Codex falls back to its default)`
195
207
  );
196
208
  }
209
+ if (result.restoredNetworkAccess) console.log(` restored prior Codex sandbox network setting`);
210
+ if (result.restoredSandboxMode) console.log(` restored prior Codex sandbox mode`);
197
211
  }
198
212
  if (config?.backups) delete config.backups.codex;
199
213
  }
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
  }
@@ -6,6 +6,7 @@ import readline from "node:readline";
6
6
 
7
7
  import { nativeCommandInvocation } from "../nativeProcess.js";
8
8
  import { applyImpelClaudeSandbox } from "../claudeSandbox.js";
9
+ import { enableManagedCodexNetworkAccess } from "../codexSecurity.js";
9
10
 
10
11
  const SAFE_ID = /^[a-z][a-z0-9-]{2,31}$/u;
11
12
  const SAFE_NAMESPACE = /^[a-z][a-z0-9_-]{1,31}$/u;
@@ -355,7 +356,7 @@ export function createGatewayCli(options) {
355
356
  const withoutProvider = outside.replace(/^\s*model_provider\s*=.*$/mu, "").trim();
356
357
  const next = [`model_provider = ${JSON.stringify(brand.cli.providerId)}`, codexManagedBlock(gatewayUrl), withoutProvider]
357
358
  .filter(Boolean).join("\n\n").concat("\n");
358
- writePrivateFile(filePath, next);
359
+ writePrivateFile(filePath, enableManagedCodexNetworkAccess(next, filePath));
359
360
  return { codexHome, filePath };
360
361
  }
361
362