copillm 0.1.4 → 0.2.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/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { acquireLock, inspectLock, LockAlreadyRunningError, releaseLock } from "
16
16
  import { startProxyServer } from "./server/proxy.js";
17
17
  import { defaultOutputDir, generateCodexHome } from "./codex/init.js";
18
18
  import { defaultOutputDir as defaultPiOutputDir, generatePiHome } from "./pi/init.js";
19
- import { getCopillmHome } from "./config/home.js";
19
+ import { debugLogPath, getCopillmHome } from "./config/home.js";
20
20
  import { clearClaudeGatewayCache } from "./claude/cache.js";
21
21
  import { detectClaudeSettingsConflicts, formatSettingsConflictWarning } from "./claude/settingsConflict.js";
22
22
  import { buildClaudeExportCommand as buildClaudeExport, computeAnthropicDefaults, readModelIdsFromCache } from "./models/anthropicDefaults.js";
@@ -25,10 +25,12 @@ import { buildClaudeEnvBundle, buildCodexEnvBundle, buildPiEnvBundle } from "./c
25
25
  import { launchAgent } from "./cli/launchAgent.js";
26
26
  import { applyAgentConfig, formatApplyNotes } from "./agentconfig/apply.js";
27
27
  import { registerConfigCommands } from "./cli/configCommands.js";
28
+ import { installProcessSafetyNet } from "./cli/processSafetyNet.js";
28
29
  const logger = createLogger();
29
30
  const program = new Command();
30
31
  program.name("copillm").description("Local Copilot proxy").version("0.1.0");
31
32
  program.enablePositionalOptions();
33
+ program.option("--debug", "Enable copillm debug mode (debug endpoint plus verbose daemon diagnostics)");
32
34
  program
33
35
  .command("login")
34
36
  .description("[deprecated] Use `copillm auth login`")
@@ -128,6 +130,8 @@ program
128
130
  .option("--no-pi", "Skip generating ~/.pi/agent/models.json for pi coding agent")
129
131
  .option("--json", "JSON output")
130
132
  .action(async (opts) => {
133
+ const debug = resolveCopillmDebug(opts.debug);
134
+ enableRuntimeDebug(debug);
131
135
  if (opts.detach) {
132
136
  // Fail fast on missing credentials rather than letting the detached
133
137
  // child die silently and surface as a generic "start timed out" error.
@@ -137,6 +141,7 @@ program
137
141
  }
138
142
  const existingLock = await readLiveLock();
139
143
  if (existingLock) {
144
+ const activeDebug = await warnIfDebugRequestedButInactive(debug, existingLock.port);
140
145
  const codex = opts.codex === false ? null : await refreshCodexHome(existingLock.port, opts.codexModel ?? null);
141
146
  const pi = opts.pi === false ? null : await refreshPiHome(existingLock.port);
142
147
  const claude = buildClaudeExportCommand(existingLock.port, null);
@@ -144,7 +149,8 @@ program
144
149
  port: existingLock.port,
145
150
  pid: existingLock.pid,
146
151
  mode: "already_running",
147
- debug: false,
152
+ debug: activeDebug,
153
+ debugLogPath: null,
148
154
  codex,
149
155
  pi
150
156
  });
@@ -152,6 +158,7 @@ program
152
158
  status: "already_running",
153
159
  pid: existingLock.pid,
154
160
  port: existingLock.port,
161
+ debug: activeDebug,
155
162
  url: `http://127.0.0.1:${existingLock.port}`,
156
163
  codex_home: codex?.outDir ?? null,
157
164
  codex_export_command: codex?.exportCommand ?? null,
@@ -168,12 +175,13 @@ program
168
175
  return;
169
176
  }
170
177
  const daemonArgs = [process.argv[1], "daemon"];
171
- if (opts.debug) {
178
+ if (debug) {
172
179
  daemonArgs.push("--debug");
173
180
  }
174
181
  const child = spawn(process.execPath, daemonArgs, {
175
182
  detached: true,
176
- stdio: "ignore"
183
+ stdio: "ignore",
184
+ env: daemonSpawnEnv(debug)
177
185
  });
178
186
  child.unref();
179
187
  const started = await waitForDaemonReady(child.pid ?? null, 8_000);
@@ -187,7 +195,8 @@ program
187
195
  port: started.port,
188
196
  pid: started.pid,
189
197
  mode: "detached",
190
- debug: Boolean(opts.debug),
198
+ debug,
199
+ debugLogPath: currentDebugLogPath(debug),
191
200
  codex,
192
201
  pi
193
202
  });
@@ -196,7 +205,8 @@ program
196
205
  mode: "detached",
197
206
  pid: started.pid,
198
207
  port: started.port,
199
- debug: Boolean(opts.debug),
208
+ debug,
209
+ debug_log_path: currentDebugLogPath(debug),
200
210
  url: `http://127.0.0.1:${started.port}`,
201
211
  codex_home: codex?.outDir ?? null,
202
212
  codex_export_command: codex?.exportCommand ?? null,
@@ -216,8 +226,9 @@ program
216
226
  }
217
227
  // Foreground path: interactively prompt for login if needed.
218
228
  await ensureAuthenticatedInteractive();
219
- const started = await runDaemon({ debug: Boolean(opts.debug) });
229
+ const started = await runDaemon({ debug });
220
230
  if (started.kind === "already_running") {
231
+ const activeDebug = await warnIfDebugRequestedButInactive(debug, started.lock.port);
221
232
  const codex = opts.codex === false ? null : await refreshCodexHome(started.lock.port, opts.codexModel ?? null);
222
233
  const pi = opts.pi === false ? null : await refreshPiHome(started.lock.port);
223
234
  const claude = buildClaudeExportCommand(started.lock.port, null);
@@ -225,7 +236,8 @@ program
225
236
  port: started.lock.port,
226
237
  pid: started.lock.pid,
227
238
  mode: "already_running",
228
- debug: false,
239
+ debug: activeDebug,
240
+ debugLogPath: null,
229
241
  codex,
230
242
  pi
231
243
  });
@@ -233,6 +245,7 @@ program
233
245
  status: "already_running",
234
246
  pid: started.lock.pid,
235
247
  port: started.lock.port,
248
+ debug: activeDebug,
236
249
  url: `http://127.0.0.1:${started.lock.port}`,
237
250
  codex_home: codex?.outDir ?? null,
238
251
  codex_export_command: codex?.exportCommand ?? null,
@@ -255,7 +268,8 @@ program
255
268
  port: started.port,
256
269
  pid: process.pid,
257
270
  mode: "foreground",
258
- debug: Boolean(opts.debug),
271
+ debug,
272
+ debugLogPath: currentDebugLogPath(debug),
259
273
  codex,
260
274
  pi
261
275
  });
@@ -264,7 +278,8 @@ program
264
278
  mode: "foreground",
265
279
  pid: process.pid,
266
280
  port: started.port,
267
- debug: Boolean(opts.debug),
281
+ debug,
282
+ debug_log_path: currentDebugLogPath(debug),
268
283
  url: `http://127.0.0.1:${started.port}`,
269
284
  caller_secret: started.callerSecret,
270
285
  codex_home: codex?.outDir ?? null,
@@ -287,11 +302,13 @@ program
287
302
  .description("Internal background command")
288
303
  .option("--debug", "Enable debug endpoints")
289
304
  .action(async (opts) => {
290
- const started = await runDaemon({ debug: Boolean(opts.debug) });
305
+ const debug = resolveCopillmDebug(opts.debug);
306
+ enableRuntimeDebug(debug);
307
+ const started = await runDaemon({ debug });
291
308
  if (started.kind === "already_running") {
292
309
  process.exit(0);
293
310
  }
294
- process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${opts.debug ? " [debug]" : ""}\n`);
311
+ process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${debug ? " [debug]" : ""}\n`);
295
312
  });
296
313
  program
297
314
  .command("stop")
@@ -654,7 +671,9 @@ program
654
671
  .helpOption(false)
655
672
  .argument("[args...]", "Args forwarded to codex")
656
673
  .action(async (forwardedArgs, opts) => {
657
- const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
674
+ const debug = resolveCopillmDebug(opts.copillmDebug);
675
+ enableRuntimeDebug(debug);
676
+ const lock = await ensureDaemonRunningForLauncher({ debug });
658
677
  const codex = await refreshCodexHome(lock.port, null);
659
678
  if (!codex) {
660
679
  throw new Error("Failed to prepare Codex home (see warning above).");
@@ -692,7 +711,9 @@ program
692
711
  .helpOption(false)
693
712
  .argument("[args...]", "Args forwarded to claude")
694
713
  .action(async (forwardedArgs, opts) => {
695
- const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
714
+ const debug = resolveCopillmDebug(opts.copillmDebug);
715
+ enableRuntimeDebug(debug);
716
+ const lock = await ensureDaemonRunningForLauncher({ debug });
696
717
  const claude = buildClaudeExportCommand(lock.port, null);
697
718
  const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_CLAUDE_VERSION ?? undefined;
698
719
  const conflicts = detectClaudeSettingsConflicts(claude.bundle.env);
@@ -729,7 +750,9 @@ program
729
750
  .helpOption(false)
730
751
  .argument("[args...]", "Args forwarded to pi")
731
752
  .action(async (forwardedArgs, opts) => {
732
- const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
753
+ const debug = resolveCopillmDebug(opts.copillmDebug);
754
+ enableRuntimeDebug(debug);
755
+ const lock = await ensureDaemonRunningForLauncher({ debug });
733
756
  const pi = await refreshPiHome(lock.port);
734
757
  if (!pi) {
735
758
  throw new Error("Failed to prepare pi models.json (see warning above).");
@@ -754,6 +777,49 @@ program
754
777
  });
755
778
  process.exit(exitCode);
756
779
  });
780
+ program
781
+ .command("copilot")
782
+ .description("Launch GitHub Copilot CLI reusing copillm's stored GitHub token (no second device flow)")
783
+ .option("--copillm-use <spec>", "Pin copilot package version (e.g. 1.0.52 or @github/copilot@1.0.52)")
784
+ .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
785
+ .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
786
+ .allowUnknownOption(true)
787
+ .passThroughOptions()
788
+ .helpOption(false)
789
+ .argument("[args...]", "Args forwarded to copilot")
790
+ .action(async (forwardedArgs, opts) => {
791
+ const credential = await loadStoredCredential();
792
+ if (!credential) {
793
+ process.stderr.write("copillm: no stored GitHub credential — run `copillm auth login` first.\n");
794
+ process.exit(1);
795
+ return;
796
+ }
797
+ const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_COPILOT_VERSION ?? undefined;
798
+ const applyResult = applyAgentConfig({
799
+ agent: "copilot",
800
+ cwd: process.cwd(),
801
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
802
+ skip: Boolean(opts.copillmNoConfig)
803
+ });
804
+ for (const line of formatApplyNotes(applyResult, "copilot")) {
805
+ process.stderr.write(`${line}\n`);
806
+ }
807
+ // Inject the stored GitHub OAuth token into the child env only — never
808
+ // export to the parent shell and never persist. Copilot CLI honours
809
+ // COPILOT_GITHUB_TOKEN ahead of its own stored credentials, so this
810
+ // short-circuits its device-flow login when copillm already has a token.
811
+ const env = {
812
+ ...applyResult.envOverlay,
813
+ COPILOT_GITHUB_TOKEN: credential.token
814
+ };
815
+ const exitCode = await launchAgent({
816
+ agent: "copilot",
817
+ args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
818
+ env,
819
+ pinnedSpec
820
+ });
821
+ process.exit(exitCode);
822
+ });
757
823
  registerConfigCommands(program);
758
824
  program.parseAsync(process.argv).catch((error) => {
759
825
  if (error instanceof Error) {
@@ -814,6 +880,7 @@ async function runDaemon(options) {
814
880
  tokenManager.clear();
815
881
  throw new Error(`No available port in configured range (${ports[0]}-${ports[ports.length - 1]}).`);
816
882
  }
883
+ installProcessSafetyNet(logger);
817
884
  let shuttingDown = false;
818
885
  const shutdown = async () => {
819
886
  if (shuttingDown) {
@@ -940,6 +1007,32 @@ function writeCommandOutput(opts, humanLine, payload) {
940
1007
  }
941
1008
  process.stdout.write(`${humanLine}\n`);
942
1009
  }
1010
+ function resolveCopillmDebug(commandDebug) {
1011
+ return Boolean(commandDebug) || Boolean(program.opts().debug);
1012
+ }
1013
+ function enableRuntimeDebug(debug) {
1014
+ if (!debug) {
1015
+ return;
1016
+ }
1017
+ process.env.COPILLM_LOG_LEVEL = "debug";
1018
+ logger.level = "debug";
1019
+ }
1020
+ function currentDebugLogPath(debug) {
1021
+ if (!debug) {
1022
+ return null;
1023
+ }
1024
+ return process.env.COPILLM_LOG_FILE ?? debugLogPath();
1025
+ }
1026
+ function daemonSpawnEnv(debug) {
1027
+ if (!debug) {
1028
+ return process.env;
1029
+ }
1030
+ return {
1031
+ ...process.env,
1032
+ COPILLM_LOG_LEVEL: "debug",
1033
+ COPILLM_LOG_FILE: currentDebugLogPath(true) ?? debugLogPath()
1034
+ };
1035
+ }
943
1036
  function formatStopHumanLine(primary, cache) {
944
1037
  if (cache.cleared) {
945
1038
  return `${primary} Cleared Claude Code gateway cache.`;
@@ -1012,6 +1105,9 @@ function formatStartBanner(input) {
1012
1105
  if (input.codex) {
1013
1106
  lines.push(` ${input.codex.modelCount} Copilot models discovered \u00B7 default: ${input.codex.defaultModel}`);
1014
1107
  }
1108
+ if (input.debugLogPath) {
1109
+ lines.push(` debug log: ${displayHomePath(input.debugLogPath)}`);
1110
+ }
1015
1111
  if (input.pi) {
1016
1112
  lines.push(` pi: wrote ${input.pi.modelCount} models to ${displayHomePath(input.pi.configPath)}${input.pi.backupPath ? ` (backed up prior config to ${displayHomePath(input.pi.backupPath)})` : ""}`);
1017
1113
  }
@@ -1051,6 +1147,25 @@ async function probeLivez(port) {
1051
1147
  return false;
1052
1148
  }
1053
1149
  }
1150
+ async function warnIfDebugRequestedButInactive(debugRequested, port) {
1151
+ if (!debugRequested) {
1152
+ return false;
1153
+ }
1154
+ const active = await probeDebugEndpoint(port);
1155
+ if (!active) {
1156
+ process.stderr.write(`warning: copillm is already running without debug mode; run \`copillm stop\` then \`copillm --debug start --detach\` to enable daemon diagnostics.\n`);
1157
+ }
1158
+ return active;
1159
+ }
1160
+ async function probeDebugEndpoint(port) {
1161
+ try {
1162
+ const response = await fetch(`http://127.0.0.1:${port}/_debug`, { signal: AbortSignal.timeout(1_200) });
1163
+ return response.ok;
1164
+ }
1165
+ catch {
1166
+ return false;
1167
+ }
1168
+ }
1054
1169
  async function probeHealth(port) {
1055
1170
  try {
1056
1171
  const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1_500) });
@@ -1155,15 +1270,21 @@ function parseAgentName(raw) {
1155
1270
  }
1156
1271
  async function ensureDaemonRunningForLauncher(opts) {
1157
1272
  const live = await readLiveLock();
1158
- if (live)
1273
+ if (live) {
1274
+ await warnIfDebugRequestedButInactive(opts.debug, live.port);
1159
1275
  return live;
1160
- process.stderr.write(`Starting copillm in background...\n`);
1276
+ }
1277
+ const debugLog = currentDebugLogPath(opts.debug);
1278
+ process.stderr.write(opts.debug && debugLog
1279
+ ? `Starting copillm in background with debug logging at ${displayHomePath(debugLog)}...\n`
1280
+ : `Starting copillm in background...\n`);
1161
1281
  const daemonArgs = [process.argv[1], "daemon"];
1162
1282
  if (opts.debug)
1163
1283
  daemonArgs.push("--debug");
1164
1284
  const child = spawn(process.execPath, daemonArgs, {
1165
1285
  detached: true,
1166
- stdio: "ignore"
1286
+ stdio: "ignore",
1287
+ env: daemonSpawnEnv(opts.debug)
1167
1288
  });
1168
1289
  child.unref();
1169
1290
  const started = await waitForDaemonReady(child.pid ?? null, 10_000);
@@ -32,6 +32,9 @@ export function modelsCachePath() {
32
32
  export function modelsCacheReadPath() {
33
33
  return resolveReadablePath("models.cache.json");
34
34
  }
35
+ export function debugLogPath() {
36
+ return path.join(getCopillmHome(), "debug.log");
37
+ }
35
38
  function resolveReadablePath(fileName) {
36
39
  const canonical = path.join(getCopillmHome(), fileName);
37
40
  if (fs.existsSync(canonical)) {
@@ -1,7 +1,11 @@
1
1
  import pino from "pino";
2
- export function createLogger() {
3
- return pino({
4
- level: process.env.COPILLM_LOG_LEVEL ?? "info",
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { applyModeIfSupported } from "./fsSecurity.js";
5
+ export function createLogger(input) {
6
+ const destinationPath = input?.destinationPath ?? process.env.COPILLM_LOG_FILE;
7
+ const options = {
8
+ level: input?.level ?? process.env.COPILLM_LOG_LEVEL ?? "info",
5
9
  redact: {
6
10
  paths: [
7
11
  "req.headers.authorization",
@@ -27,7 +31,25 @@ export function createLogger() {
27
31
  remove: true
28
32
  },
29
33
  transport: process.env.COPILLM_LOG_PRETTY === "1"
30
- ? { target: "pino-pretty", options: { colorize: true } }
34
+ ? { target: "pino-pretty", options: { colorize: destinationPath ? false : true, destination: destinationPath ?? 2 } }
31
35
  : undefined
32
- });
36
+ };
37
+ if (process.env.COPILLM_LOG_PRETTY === "1") {
38
+ if (destinationPath) {
39
+ prepareLogFile(destinationPath);
40
+ }
41
+ return pino(options);
42
+ }
43
+ if (destinationPath) {
44
+ prepareLogFile(destinationPath);
45
+ return pino(options, pino.destination(destinationPath));
46
+ }
47
+ return pino(options, pino.destination(2));
48
+ }
49
+ function prepareLogFile(filePath) {
50
+ const resolvedPath = path.resolve(filePath);
51
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true, mode: 0o700 });
52
+ const fd = fs.openSync(resolvedPath, "a", 0o600);
53
+ fs.closeSync(fd);
54
+ applyModeIfSupported(resolvedPath, 0o600);
33
55
  }
@@ -8,6 +8,7 @@ const SUFFIX_BLOCKLIST = [
8
8
  "-low",
9
9
  "-min",
10
10
  "-1m",
11
+ "[1m]",
11
12
  "-internal",
12
13
  "-preview",
13
14
  "-beta",