copillm 0.3.1 → 0.4.1

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
@@ -89,6 +89,10 @@ The **default account** is what every agent and the model endpoints use unless
89
89
  told otherwise. `copillm auth status` lists each account with its plan type and
90
90
  whether a credential is stored; tokens are never printed.
91
91
 
92
+ If the daemon is already running when you `auth switch`, copillm reminds you to
93
+ run `copillm restart` so the new default takes effect for the next agent launch.
94
+ (`auth logout` stops the daemon for you, so it needs no restart.)
95
+
92
96
  Different accounts can be entitled to different models, so each account keeps
93
97
  its own model list.
94
98
 
@@ -1,4 +1,4 @@
1
- import { writeFileSecureAtomic } from "../config/fsSecurity.js";
1
+ import { writeFilesSecureAtomic } from "../config/fsSecurity.js";
2
2
  import { loadAgentConfig } from "./load.js";
3
3
  import { planRender } from "./render.js";
4
4
  import { backupIfMismatch } from "./markerBlock.js";
@@ -22,13 +22,15 @@ export function applyAgentConfig(opts) {
22
22
  return { active: null, writes: [], envOverlay: {}, cliArgs: [], notes: [], sources: [], yolo: null };
23
23
  }
24
24
  const rendered = planRender(opts, load);
25
- // Phase 2: write. By this point all validation passed and the renderer
26
- // produced complete file bodies failures here are IO errors, not user
27
- // input problems.
25
+ // Phase 2: write. All validation passed and the renderer produced complete
26
+ // file bodies, so failures here are IO errors. Back up any user-edited
27
+ // targets first, then commit every file as one two-phase atomic batch: each
28
+ // file is staged to a temp path before any is renamed into place, so an IO
29
+ // failure mid-fan-out leaves nothing committed rather than a half-updated set.
28
30
  for (const write of rendered.writes) {
29
31
  backupIfMismatch(write.path, write.content);
30
- writeFileSecureAtomic(write.path, write.content, write.mode);
31
32
  }
33
+ writeFilesSecureAtomic(rendered.writes);
32
34
  return {
33
35
  active: load.active,
34
36
  writes: rendered.writes,
@@ -227,10 +227,16 @@ export async function runAuthSwitch(opts, accountId) {
227
227
  try {
228
228
  assertValidAccountId(accountId);
229
229
  const index = switchDefaultAccount(accountId);
230
- writeCommandOutput(opts, `Default account is now "${index.defaultAccount}".`, {
230
+ // A running daemon snapshots the default account at startup, so a switch
231
+ // only affects new agent launches after the daemon is restarted. (`auth
232
+ // logout` doesn't need this hint — it already stops the daemon.)
233
+ const daemonRunning = inspectLock().state === "running";
234
+ const hint = daemonRunning ? " Restart the daemon for this to take effect: copillm restart." : "";
235
+ writeCommandOutput(opts, `Default account is now "${index.defaultAccount}".${hint}`, {
231
236
  status: "ok",
232
237
  action: "switch",
233
- default_account: index.defaultAccount
238
+ default_account: index.defaultAccount,
239
+ restart_required: daemonRunning
234
240
  });
235
241
  }
236
242
  catch (error) {
@@ -19,6 +19,8 @@ import { isDevModeActive } from "../shared/devMode.js";
19
19
  import { writeCommandOutput, writeHealthOutput } from "../shared/output.js";
20
20
  import { spawnDetachedDaemon } from "../daemon/spawnDetached.js";
21
21
  import { resolveRestartDecision } from "../daemon/restart.js";
22
+ import { selfUpdateToLatest, describeSelfUpdate } from "../daemon/selfUpdate.js";
23
+ import { getPackageInfo } from "../packageInfo.js";
22
24
  export function register(program) {
23
25
  program
24
26
  .command("start")
@@ -182,6 +184,16 @@ export function register(program) {
182
184
  const detectedDebug = lockSnapshot.state === "running" ? await probeDebugEndpoint(lockSnapshot.port) : false;
183
185
  const decision = resolveRestartDecision({ lock: lockSnapshot, detectedDebug, forceDebug });
184
186
  enableRuntimeDebug(decision.debug);
187
+ // Self-update to the latest published version before respawning. The old
188
+ // daemon is still serving here, so an `npm install -g` (which overwrites
189
+ // the files the new daemon will be spawned from) only updates the code
190
+ // the *next* daemon runs, never the one currently up. Best-effort: a
191
+ // failure or offline registry just restarts on the installed version.
192
+ const selfUpdate = await selfUpdateToLatest(getPackageInfo());
193
+ const selfUpdateNote = describeSelfUpdate(selfUpdate, getPackageInfo().name);
194
+ if (selfUpdateNote && !opts.json) {
195
+ process.stderr.write(`${selfUpdateNote}\n`);
196
+ }
185
197
  if (decision.action === "restart" && decision.previousPid !== null) {
186
198
  await stopByPid(decision.previousPid);
187
199
  }
@@ -194,7 +206,8 @@ export function register(program) {
194
206
  const started = await spawnDetachedDaemon({ debug: decision.debug, forcePort: decision.forcePort });
195
207
  await emitDetachedStartOutput(opts, started, decision.debug, "restarted", {
196
208
  previousPid: decision.previousPid,
197
- claudeCache: cache
209
+ claudeCache: cache,
210
+ selfUpdate
198
211
  });
199
212
  });
200
213
  program
@@ -425,6 +438,9 @@ async function emitDetachedStartOutput(opts, started, debug, mode, extra) {
425
438
  if (extra?.claudeCache !== undefined) {
426
439
  payload.claude_cache = extra.claudeCache;
427
440
  }
441
+ if (extra?.selfUpdate !== undefined) {
442
+ payload.self_update = extra.selfUpdate;
443
+ }
428
444
  writeCommandOutput(opts, banner, payload);
429
445
  }
430
446
  /** Narrow the lock inspection down to the shape `resolveRestartDecision` needs. */
@@ -0,0 +1,87 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { fetchLatestNpmVersion, isNewerVersion } from "../updateNotifier.js";
4
+ export async function selfUpdateToLatest(packageInfo, deps = {}) {
5
+ const env = deps.env ?? process.env;
6
+ const moduleUrl = deps.moduleUrl ?? import.meta.url;
7
+ if (!isGlobalNpmRuntime(moduleUrl, packageInfo.name)) {
8
+ return { status: "skipped", reason: "not-global" };
9
+ }
10
+ const fetchLatest = deps.fetchLatest ??
11
+ ((name) => fetchLatestNpmVersion(name, { registryUrl: env.COPILLM_UPDATE_REGISTRY_URL }));
12
+ let latest = null;
13
+ try {
14
+ latest = await fetchLatest(packageInfo.name);
15
+ }
16
+ catch {
17
+ latest = null;
18
+ }
19
+ if (!latest) {
20
+ return { status: "skipped", reason: "no-latest" };
21
+ }
22
+ if (!isNewerVersion(latest, packageInfo.version)) {
23
+ return { status: "up-to-date", version: packageInfo.version };
24
+ }
25
+ const runInstall = deps.runInstall ?? defaultRunInstall;
26
+ let outcome;
27
+ try {
28
+ outcome = runInstall(packageInfo.name, latest);
29
+ }
30
+ catch (error) {
31
+ outcome = { ok: false, detail: error instanceof Error ? error.message : "npm install failed" };
32
+ }
33
+ if (!outcome.ok) {
34
+ return { status: "failed", from: packageInfo.version, to: latest, detail: outcome.detail };
35
+ }
36
+ return { status: "updated", from: packageInfo.version, to: latest };
37
+ }
38
+ /** One-line human summary, or null when there's nothing worth printing. */
39
+ export function describeSelfUpdate(result, packageName) {
40
+ switch (result.status) {
41
+ case "updated":
42
+ return `Updated ${packageName} ${result.from} -> ${result.to}.`;
43
+ case "up-to-date":
44
+ return `${packageName} is already up to date (${result.version}).`;
45
+ case "failed":
46
+ return `Self-update to ${result.to} failed; continuing on ${result.from}. (${result.detail})`;
47
+ case "skipped":
48
+ // `no-latest` means the registry was unreachable — worth a quiet note;
49
+ // `not-global` (dev/dist runs) stays silent.
50
+ return result.reason === "no-latest"
51
+ ? "Could not check for updates; continuing on the installed version."
52
+ : null;
53
+ }
54
+ }
55
+ function defaultRunInstall(packageName, version) {
56
+ const isWindows = process.platform === "win32";
57
+ const result = spawnSync("npm", ["install", "-g", `${packageName}@${version}`], {
58
+ encoding: "utf8",
59
+ timeout: 120_000,
60
+ stdio: ["ignore", "ignore", "pipe"],
61
+ shell: isWindows
62
+ });
63
+ if (result.error) {
64
+ return { ok: false, detail: result.error.message };
65
+ }
66
+ if (result.status === 0) {
67
+ return { ok: true, detail: "" };
68
+ }
69
+ const stderr = (result.stderr ?? "").trim();
70
+ const tail = stderr ? stderr.split("\n").slice(-2).join(" ") : `npm exited with code ${result.status ?? "null"}`;
71
+ return { ok: false, detail: tail };
72
+ }
73
+ function isGlobalNpmRuntime(moduleUrl, packageName) {
74
+ // Prefer a real filesystem path, but fall back to the raw URL when
75
+ // `fileURLToPath` can't convert it (e.g. a non-Windows-style `file://` URL on
76
+ // Windows). We only need to scan for the `node_modules/<pkg>` marker, and the
77
+ // regex handles both `\` and `/` separators so the check is platform-agnostic.
78
+ let candidate;
79
+ try {
80
+ candidate = fileURLToPath(moduleUrl);
81
+ }
82
+ catch {
83
+ candidate = moduleUrl;
84
+ }
85
+ const normalized = candidate.replace(/\\/g, "/").toLowerCase();
86
+ return normalized.includes(`/node_modules/${packageName.toLowerCase()}/`);
87
+ }
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.3.1"
4
+ version: "0.4.1"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -14,6 +14,42 @@ export function writeFileSecureAtomic(filePath, content, mode) {
14
14
  replaceFile(tempPath, filePath);
15
15
  applyModeIfSupported(filePath, mode);
16
16
  }
17
+ /**
18
+ * Atomically write a batch of files with two-phase commit semantics. Phase 1
19
+ * stages every file to a temp path; if any staging write throws, all temps
20
+ * written so far are removed and the error is rethrown, so nothing is
21
+ * committed. Phase 2 renames each staged temp into place — by then all content
22
+ * is safely on disk, so a partial commit is far less likely than a mid-write
23
+ * failure (the failure mode this replaces). Use this when several files must
24
+ * land together (e.g. an agent config fan-out).
25
+ */
26
+ export function writeFilesSecureAtomic(writes) {
27
+ const staged = [];
28
+ try {
29
+ for (const write of writes) {
30
+ ensureSecureDirectory(path.dirname(write.path));
31
+ const tempPath = `${write.path}.tmp-${process.pid}-${Date.now()}-${staged.length}`;
32
+ fs.writeFileSync(tempPath, write.content, { mode: write.mode });
33
+ applyModeIfSupported(tempPath, write.mode);
34
+ staged.push({ tempPath, finalPath: write.path, mode: write.mode });
35
+ }
36
+ }
37
+ catch (error) {
38
+ for (const item of staged) {
39
+ try {
40
+ fs.unlinkSync(item.tempPath);
41
+ }
42
+ catch {
43
+ // best effort — nothing was committed, leftover temps are harmless
44
+ }
45
+ }
46
+ throw error;
47
+ }
48
+ for (const item of staged) {
49
+ replaceFile(item.tempPath, item.finalPath);
50
+ applyModeIfSupported(item.finalPath, item.mode);
51
+ }
52
+ }
17
53
  export function applyModeIfSupported(targetPath, mode) {
18
54
  if (process.platform === "win32") {
19
55
  return;
@@ -12,6 +12,14 @@ export class InvalidRequestShapeError extends Error {
12
12
  this.name = "InvalidRequestShapeError";
13
13
  }
14
14
  }
15
+ export class RequestBodyTooLargeError extends Error {
16
+ maxBytes;
17
+ constructor(maxBytes) {
18
+ super(`Request body exceeds the maximum allowed size of ${maxBytes} bytes.`);
19
+ this.maxBytes = maxBytes;
20
+ this.name = "RequestBodyTooLargeError";
21
+ }
22
+ }
15
23
  export async function readUpstreamError(response) {
16
24
  const contentType = response.headers.get("content-type");
17
25
  let text;
@@ -2,7 +2,7 @@ import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { singleAccountResolver } from "./accountResolver.js";
4
4
  import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson } from "./requestLifecycle.js";
5
- import { InvalidRequestShapeError, JsonRequestParseError } from "./errors.js";
5
+ import { InvalidRequestShapeError, JsonRequestParseError, RequestBodyTooLargeError } from "./errors.js";
6
6
  import { ProtocolTranslationError } from "../translation/openaiAnthropic.js";
7
7
  import { handleHealthz, handleLivez } from "./routes/health.js";
8
8
  import { handleModels } from "./routes/models.js";
@@ -119,6 +119,10 @@ export async function startProxyServer(input) {
119
119
  safeSendJson(res, 400, { error: "invalid_request_json", detail: error.message });
120
120
  return;
121
121
  }
122
+ if (error instanceof RequestBodyTooLargeError) {
123
+ safeSendJson(res, 413, { error: "payload_too_large", detail: error.message });
124
+ return;
125
+ }
122
126
  if (error instanceof InvalidRequestShapeError) {
123
127
  safeSendJson(res, 400, { error: "invalid_request_shape", detail: error.message });
124
128
  return;
@@ -1,10 +1,37 @@
1
1
  import { stripOneMillionAlias } from "../../translation/openaiAnthropic.js";
2
2
  import { isValidAccountId } from "../../config/accountId.js";
3
- import { JsonRequestParseError } from "../errors.js";
4
- export async function readJson(req) {
3
+ import { JsonRequestParseError, RequestBodyTooLargeError } from "../errors.js";
4
+ /**
5
+ * Default cap on a single request body. The daemon buffers the whole body in
6
+ * memory before forwarding, so an unbounded read is an OOM vector even on a
7
+ * loopback-only socket (a runaway agent or a pathological context). 32 MiB is
8
+ * far above any real chat/completions payload while still bounding memory.
9
+ * Override with `COPILLM_MAX_REQUEST_BYTES` (a positive integer count of bytes).
10
+ */
11
+ export const DEFAULT_MAX_REQUEST_BYTES = 32 * 1024 * 1024;
12
+ export function maxRequestBytes() {
13
+ const raw = process.env.COPILLM_MAX_REQUEST_BYTES;
14
+ if (raw === undefined || raw.trim().length === 0) {
15
+ return DEFAULT_MAX_REQUEST_BYTES;
16
+ }
17
+ const parsed = Number(raw.trim());
18
+ if (!Number.isInteger(parsed) || parsed <= 0) {
19
+ return DEFAULT_MAX_REQUEST_BYTES;
20
+ }
21
+ return parsed;
22
+ }
23
+ export async function readJson(req, maxBytes = maxRequestBytes()) {
5
24
  const chunks = [];
25
+ let total = 0;
6
26
  for await (const chunk of req) {
7
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
27
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
28
+ total += buffer.length;
29
+ if (total > maxBytes) {
30
+ // Stop accumulating immediately so an oversized body can't be buffered
31
+ // into memory; throwing ends the async iteration and tears down the read.
32
+ throw new RequestBodyTooLargeError(maxBytes);
33
+ }
34
+ chunks.push(buffer);
8
35
  }
9
36
  if (chunks.length === 0) {
10
37
  return {};
@@ -25,6 +25,14 @@ export function anthropicToOpenAI(body) {
25
25
  if (request.temperature !== undefined) {
26
26
  translated.temperature = request.temperature;
27
27
  }
28
+ if (request.top_p !== undefined) {
29
+ translated.top_p = request.top_p;
30
+ }
31
+ if (request.stop_sequences !== undefined) {
32
+ // Anthropic `stop_sequences` (array of strings) maps 1:1 onto OpenAI's
33
+ // `stop` field, which Copilot's chat/completions endpoint accepts.
34
+ translated.stop = request.stop_sequences;
35
+ }
28
36
  if (request.stream !== undefined) {
29
37
  translated.stream = request.stream;
30
38
  }
@@ -102,6 +110,12 @@ function parseAnthropicRequest(body) {
102
110
  if (body.temperature !== undefined) {
103
111
  parsed.temperature = body.temperature;
104
112
  }
113
+ if (body.top_p !== undefined) {
114
+ parsed.top_p = body.top_p;
115
+ }
116
+ if (body.stop_sequences !== undefined) {
117
+ parsed.stop_sequences = body.stop_sequences;
118
+ }
105
119
  if (body.stream !== undefined) {
106
120
  parsed.stream = body.stream;
107
121
  }
@@ -195,11 +209,11 @@ function translateAssistantMessageBlocks(blocks) {
195
209
  }
196
210
  function translateUserMessageBlocks(blocks) {
197
211
  const translated = [];
198
- const textSegments = [];
199
- const flushText = () => {
200
- if (textSegments.length > 0) {
201
- translated.push({ role: "user", content: textSegments.join("\n") });
202
- textSegments.length = 0;
212
+ let parts = [];
213
+ const flushParts = () => {
214
+ if (parts.length > 0) {
215
+ translated.push({ role: "user", content: collapseUserParts(parts) });
216
+ parts = [];
203
217
  }
204
218
  };
205
219
  for (const block of blocks) {
@@ -210,14 +224,18 @@ function translateUserMessageBlocks(blocks) {
210
224
  if (typeof block.text !== "string") {
211
225
  throw new ProtocolTranslationError("invalid_text_block", "Anthropic text block must include string text.");
212
226
  }
213
- textSegments.push(block.text);
227
+ parts.push({ type: "text", text: block.text });
228
+ continue;
229
+ }
230
+ if (block.type === "image") {
231
+ parts.push({ type: "image_url", image_url: { url: translateImageSource(block.source) } });
214
232
  continue;
215
233
  }
216
234
  if (block.type === "tool_result") {
217
235
  if (typeof block.tool_use_id !== "string" || block.tool_use_id.length === 0) {
218
236
  throw new ProtocolTranslationError("invalid_tool_result", "Anthropic tool_result requires non-empty tool_use_id.");
219
237
  }
220
- flushText();
238
+ flushParts();
221
239
  const rawContent = translateToolResultContent(block.content);
222
240
  const content = block.is_error ? `[tool_error] ${rawContent}` : rawContent;
223
241
  translated.push({
@@ -232,12 +250,53 @@ function translateUserMessageBlocks(blocks) {
232
250
  }
233
251
  throw new ProtocolTranslationError("unsupported_block", `Unsupported Anthropic user block type: ${block.type}.`);
234
252
  }
235
- flushText();
253
+ flushParts();
236
254
  if (translated.length === 0) {
237
255
  translated.push({ role: "user", content: "" });
238
256
  }
239
257
  return translated;
240
258
  }
259
+ /**
260
+ * Collapse the accumulated content parts into an OpenAI message `content`.
261
+ * Text-only messages stay a plain newline-joined string (the historical shape
262
+ * every text request produced); once an image is present we must emit the
263
+ * OpenAI multi-part content array so the `image_url` parts survive to upstream.
264
+ */
265
+ function collapseUserParts(parts) {
266
+ const hasImage = parts.some((part) => part.type === "image_url");
267
+ if (!hasImage) {
268
+ return parts.map((part) => (part.type === "text" ? part.text : "")).join("\n");
269
+ }
270
+ return parts;
271
+ }
272
+ /**
273
+ * Translate an Anthropic image `source` into an OpenAI `image_url.url`. A
274
+ * base64 source becomes a `data:` URL; a url source passes its URL through.
275
+ * Copilot's chat/completions endpoint accepts both for vision-capable models;
276
+ * sending an image to a non-vision model surfaces as an upstream error, which
277
+ * is the correct place for that signal — translation stays capability-agnostic.
278
+ */
279
+ function translateImageSource(source) {
280
+ if (!isObject(source) || typeof source.type !== "string") {
281
+ throw new ProtocolTranslationError("invalid_image_source", "Anthropic image block requires a source object with a type.");
282
+ }
283
+ if (source.type === "base64") {
284
+ if (typeof source.media_type !== "string" || source.media_type.length === 0) {
285
+ throw new ProtocolTranslationError("invalid_image_source", "Anthropic base64 image source requires a media_type.");
286
+ }
287
+ if (typeof source.data !== "string" || source.data.length === 0) {
288
+ throw new ProtocolTranslationError("invalid_image_source", "Anthropic base64 image source requires data.");
289
+ }
290
+ return `data:${source.media_type};base64,${source.data}`;
291
+ }
292
+ if (source.type === "url") {
293
+ if (typeof source.url !== "string" || source.url.length === 0) {
294
+ throw new ProtocolTranslationError("invalid_image_source", "Anthropic url image source requires a non-empty url.");
295
+ }
296
+ return source.url;
297
+ }
298
+ throw new ProtocolTranslationError("unsupported_image_source", `Unsupported Anthropic image source type: ${String(source.type)}.`);
299
+ }
241
300
  function translateToolResultContent(content) {
242
301
  if (typeof content === "string") {
243
302
  return content;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",