@karma42/agent 0.1.0-beta.12 → 0.1.0-beta.13

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
@@ -1,13 +1,13 @@
1
1
  # Karma42 MCP connector
2
2
 
3
- `0.1.0-beta.12` is a **beta release**. It connects an existing MCP-capable AI client to a Karma42 project. Registration and current-chat mode do not select, download or run a model. An explicitly approved local worker is a separate work mode. An MCP-capable client using DeepSeek or another model can use the same transport if that client supports local stdio MCP and the required project tools.
3
+ `0.1.0-beta.13` is a **beta release**. It connects an existing MCP-capable AI client to a Karma42 project. Registration and current-chat mode do not select, download or run a model. An explicitly approved local worker is a separate work mode. An MCP-capable client using DeepSeek or another model can use the same transport if that client supports local stdio MCP and the required project tools.
4
4
 
5
5
  ## First-time setup
6
6
 
7
7
  Once per computer and client, print its registration instructions:
8
8
 
9
9
  ```sh
10
- npx --ignore-scripts @karma42/agent@0.1.0-beta.12 mcp-config --client claude
10
+ npx --ignore-scripts @karma42/agent@0.1.0-beta.13 mcp-config --client claude
11
11
  ```
12
12
 
13
13
  Use `--client codex` for Codex, or `--client other` for the generic stdio configuration. **Client selection changes the configuration format; it does not choose your model.**
@@ -58,11 +58,15 @@ The lightweight supervisor holds no model open while waiting. `WORK_NEXT: runnab
58
58
 
59
59
  Worker lifecycle and adapter-session tests use fake models and clocks. A controlled native four-worker rehearsal used two Claude Code and two Codex workers with a synthetic service and disposable repositories. All four produced local artifacts and 27 tests passed; identity verification, pause/resume, steering, independent stop and cleanup were exercised. The [rehearsal receipt](https://github.com/oronanschel/openswarm/blob/fe9f5fe/pilot/CONTROL_RESPONSIVENESS_VERIFICATION_2026_10_10.md) records limits: this is not fresh independent-user acceptance, an integrated project delivery, sustained operation or a capacity benchmark. Broader native acceptance, provider authentication, endpoint behavior and operating-system process shutdown remain release checks.
60
60
 
61
+ ## Windows
62
+
63
+ Setup, the one-time computer approval, adding and connecting agents, and work in the current conversation run on Windows. `mcp-config` prints a registration that starts `npx` through `cmd /c`, as Windows clients require. Supervised workers do not run on Windows yet: the process guard stops a whole process group, which Windows does not have. `start_project_worker` refuses there before anything is prepared, and an agent set to run on its own works in the conversation instead. Windows support is covered by path-rule tests and a Windows check in CI; it has not had independent native acceptance.
64
+
61
65
  ## Transport and private state
62
66
 
63
67
  The client runs `mcp-serve`, which serves JSON-RPC over stdin/stdout. Registration and connection do not launch a model; worker execution requires its own explicit approval. Node 22+ is required. `disconnect_project_agent` releases only the specified binding; EOF or transport termination releases all bindings in that process; completed external actions are not undone. Run fences, membership, revocation and project-state checks remain enforced by the service.
64
68
 
65
- Private records use `~/.karma42/mcp` or `--state-dir /absolute/private/directory`. Storage must be private and outside the working folder. Credentials and polling secrets remain in private files; never put them in chat, arguments, URLs or client configuration. The pending approval of a computer can be resumed; cancellation or expiry preserves what is already saved. A connected agent keeps its connection; nothing is replaced.
69
+ Private records use `~/.karma42/mcp` or `--state-dir /absolute/private/directory`. Storage must be private and outside the working folder. On Windows, storage must be inside your user folder (`C:\Users\<name>`): Windows has no permission bits to check, so the connector relies on the access rules Windows applies to that folder. Credentials and polling secrets remain in private files; never put them in chat, arguments, URLs or client configuration. The pending approval of a computer can be resumed; cancellation or expiry preserves what is already saved. A connected agent keeps its connection; nothing is replaced.
66
70
 
67
71
  This source contains only the current MCP connector. Old npm releases remain published independently; this cleanup neither migrates their private records nor deletes production data or interrupts existing sessions. Native client and operating-system acceptance remain separate release checks. Configuration and protocol tests do not establish that every MCP client supports dynamic setup, reload or continuation.
68
72
 
@@ -108,6 +112,6 @@ CLI equivalents: `project-agents --project PROJECT_ID` and `agents-register --pr
108
112
 
109
113
  ## Bundled orchestration skill
110
114
 
111
- `skills/karma42/SKILL.md` is the single maintained workflow for owner access, project readiness/start, team selection/registration, batch approval, identity checks and supervised workers. The MCP supplies it as initialization instructions and through `get_karma42_guide`, so guidance is available even without native skill support. `npx --ignore-scripts @karma42/agent@0.1.0-beta.12 skill` prints the same file; it never edits settings. A client can optionally save it in its supported skill location after the user approves that file addition. Installation/registration does not grant account access or authorize work. A generic host subagent is not automatically an independently connected Karma42 worker.
115
+ `skills/karma42/SKILL.md` is the single maintained workflow for owner access, project readiness/start, team selection/registration, batch approval, identity checks and supervised workers. The MCP supplies it as initialization instructions and through `get_karma42_guide`, so guidance is available even without native skill support. `npx --ignore-scripts @karma42/agent@0.1.0-beta.13 skill` prints the same file; it never edits settings. A client can optionally save it in its supported skill location after the user approves that file addition. Installation/registration does not grant account access or authorize work. A generic host subagent is not automatically an independently connected Karma42 worker.
112
116
 
113
117
  First-time `mcp-config` instructions now offer the skill alongside registration. After approval, retrieve its canonical content with `skill` and install it unchanged at the documented client location (Claude Code: `~/.claude/skills/karma42/SKILL.md`; Codex: `~/.agents/skills/karma42/SKILL.md`). Existing different skills require separate replacement approval. Other clients use their documented skill support or `get_karma42_guide`. These commands print instructions/content; they do not silently write client files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karma42/agent",
3
- "version": "0.1.0-beta.12",
3
+ "version": "0.1.0-beta.13",
4
4
  "description": "Model-independent Karma42 MCP connector for project agents",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -39,7 +39,7 @@ A prompt copied from Karma42, or the human saying “let’s create an agent in
39
39
 
40
40
  **Ask once before starting.** Only when work can start. `get_project` returns `you.workSettings`: `involvement` (`with_me` or `on_own`) and `minutes`. These are the default limits. Check that the repository is reachable with the human's account and whether a checkout already exists. Then ask one question with the client's question tool (AskUserQuestion in Claude Code), so the human picks an answer; use a numbered list only when the client has no question tool, and never ask it as a plain sentence at the end of a reply. `add_project_agent` returns the question ready as `start_question`, for example “Start scout on Friends Game? It runs on its own, for up to 1 hour.” Its options are: 1. Yes, start. 2. Change the limits. 3. Not now. For several agents added in one request, ask once for all of them. On Change, take the new involvement and length, from 15 minutes to 4 hours, save them for each agent with `set_agent_work_settings`, and start under them without asking again. On Not now, start nothing: the agent stays connected and the human can ask later. The answer is the approval to start; adding or connecting an agent alone is not permission to develop, post project messages or change GitHub. Keep the permitted actions (project messages, commits, pushes, PRs) within the human's standing instructions for this client. Setup time does not count toward the session. Between tasks, read the settings again with `get_project`: a changed involvement applies from the next task and a changed length from the next session, because a running session keeps its deadline. My agents shows the limits and offers a prompt to change them. When the human asks to change an agent’s limits, pick the agent up with `add_project_agent` if it is not connected in this conversation, ask for the new involvement and length with the question tool, save them with `set_agent_work_settings`, and say they apply from the agent’s next start. An agent connected through a team batch is different: its work is approved separately, as described under Launch and supervise.
41
41
 
42
- **Work.** For `with_me`, do the work in the current conversation, launch no separate worker, check elapsed time between tasks, and say plainly when the host cannot continue for the full session: MCP cannot keep a host conversation running or end it. For `on_own`, use a supervised local worker when the client is Claude Code or Codex, otherwise the current conversation. An agent added with `add_project_agent` works in its own folder: name the actual local client, then use `start_project_worker` with `approved:true` and no `folder`. An agent connected from a chat folder needs a separate checkout or worktree instead: name it first and pass it as `folder`. Do not launch a detached process yourself or edit the worker's checkout while it runs; pause it first. Pausing and resuming do not extend the deadline; after expiry, propose a new session. Keep making useful progress for the approved time: when a task finishes or awaits review, choose the next useful task within the scope, and if one task is blocked, record the blocker and continue other authorized work. Always stop for a real blocker, a needed permission or a change of scope. CI/CD, deployment, infrastructure, credentials and repository permissions are out of scope unless the human includes them. A failing check is not permission to weaken or bypass it. Never invent work or spend model turns polling. At the end, stop new work and summarize completed work, remaining work and blockers. `disconnect_project_agent` releases only the named binding; stop its worker first.
42
+ **Work.** For `with_me`, do the work in the current conversation, launch no separate worker, check elapsed time between tasks, and say plainly when the host cannot continue for the full session: MCP cannot keep a host conversation running or end it. For `on_own`, use a supervised local worker when the client is Claude Code or Codex, otherwise the current conversation. On Windows there is no supervised worker yet: work in the current conversation and say so once. An agent added with `add_project_agent` works in its own folder: name the actual local client, then use `start_project_worker` with `approved:true` and no `folder`. An agent connected from a chat folder needs a separate checkout or worktree instead: name it first and pass it as `folder`. Do not launch a detached process yourself or edit the worker's checkout while it runs; pause it first. Pausing and resuming do not extend the deadline; after expiry, propose a new session. Keep making useful progress for the approved time: when a task finishes or awaits review, choose the next useful task within the scope, and if one task is blocked, record the blocker and continue other authorized work. Always stop for a real blocker, a needed permission or a change of scope. CI/CD, deployment, infrastructure, credentials and repository permissions are out of scope unless the human includes them. A failing check is not permission to weaken or bypass it. Never invent work or spend model turns polling. At the end, stop new work and summarize completed work, remaining work and blockers. `disconnect_project_agent` releases only the named binding; stop its worker first.
43
43
 
44
44
  ## Launch and supervise
45
45
 
@@ -1,9 +1,9 @@
1
- import { readdirSync, realpathSync, statSync, unlinkSync, readFileSync } from 'node:fs';
1
+ import { readdirSync, statSync, unlinkSync, readFileSync } from 'node:fs';
2
2
  import { userInfo } from 'node:os';
3
- import { join, relative, isAbsolute } from 'node:path';
3
+ import { join, isAbsolute } from 'node:path';
4
4
  import { randomUUID, createHash } from 'node:crypto';
5
5
  import { execFileSync } from 'node:child_process';
6
- import { ensureHome, readPrivate, writePrivate, assertStateOutsideWorkspace } from './state.mjs';
6
+ import { ensureHome, readPrivate, writePrivate, assertStateOutsideWorkspace, pathInside, physicalPath } from './state.mjs';
7
7
 
8
8
  // This is deliberately independent of --state-dir and HOME overrides. All connector
9
9
  // processes for the same OS user arbitrate in one private, local filesystem registry.
@@ -30,10 +30,7 @@ const read = (root, name) => {
30
30
  try { return readPrivate(join(root, name), true); }
31
31
  catch (error) { if (error.code === 'ENOENT') return null; throw new Error(`Worker ownership cannot inspect ${join(root, name)}. Restore the private record before retrying; no existing worker was stopped.`); }
32
32
  };
33
- const overlap = (left, right) => {
34
- const inside = (a, b) => { const part = relative(a, b); return !part || (part !== '..' && !part.startsWith('../') && !isAbsolute(part)); };
35
- return inside(left, right) || inside(right, left);
36
- };
33
+ const overlap = (left, right) => pathInside(left, right) || pathInside(right, left);
37
34
  const pause = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
38
35
 
39
36
  // Lamport bakery arbitration: publish choosing before observing tickets, then wait
@@ -70,9 +67,9 @@ function validateClaim(claim, path) {
70
67
  }
71
68
 
72
69
  export async function claimWorkerCheckout({ folder, agentId, project, registryRoot = checkoutOwnershipHome(), timeoutMs = 3000 }) {
73
- const checkout = realpathSync(folder);
70
+ const checkout = physicalPath(folder);
74
71
  if (!statSync(checkout).isDirectory()) throw new Error('Worker checkout must be an existing directory.');
75
- ensureHome(registryRoot); const root = realpathSync(registryRoot);
72
+ ensureHome(registryRoot); const root = physicalPath(registryRoot);
76
73
  assertStateOutsideWorkspace(root, checkout);
77
74
  const id = randomUUID(), path = join(root, `claim-${id}.json`);
78
75
  const claim = { schema: 1, id, pid: process.pid, machine: machine(), folder: checkout, agentId: agentId ?? null, project: project ?? null, createdAt: Date.now(), phase: 'idle', children: [] };
package/src/cli.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { parseArgs } from 'node:util';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { realpathSync } from 'node:fs';
5
+ import { isAbsolute } from 'node:path';
5
6
  import { readFileSync } from 'node:fs';
6
7
  import { createOwnerClient } from './owner-client.mjs';
7
8
  import { VERSION } from './version.mjs';
@@ -70,7 +71,7 @@ export function main(args, out = console.log, err = console.error) {
70
71
  const names = parsed.tokens.filter(token => token.kind === 'option').map(token => token.name);
71
72
  if (new Set(names).size !== names.length) throw new Error('Supply each option once.');
72
73
  const values = parsed.values;
73
- if (values['state-dir'] && (!values['state-dir'].startsWith('/') || /[\x00-\x1f\x7f-\x9f]/.test(values['state-dir']))) throw new Error('Choose an absolute private state directory.');
74
+ if (values['state-dir'] && (!isAbsolute(values['state-dir']) || /[\x00-\x1f\x7f-\x9f]/.test(values['state-dir']))) throw new Error('Choose an absolute private state directory.');
74
75
  if (command === 'skill') { out(readFileSync(new URL('../skills/karma42/SKILL.md',import.meta.url),'utf8')); return 0; }
75
76
  if (command === 'mcp-config') { out(renderReusableConfiguration(reusableConfiguration({ client: values.client, home: values['state-dir'] }))); return 0; }
76
77
  if (command !== 'mcp-serve') return runManagement(command, values).then(value => { out(JSON.stringify(value, null, 2)); return 0; }).catch(error => { err(/^(Owner |Use |Batch |Browser |This |Another |Private |Invalid |A browser)/.test(error.message ?? '') ? error.message : 'Management could not complete. Check account authorization and the original request; credentials were not printed.'); return 2; });
@@ -4,18 +4,23 @@ import { sessionHome } from './session.mjs';
4
4
  import { canonicalPath } from './state.mjs';
5
5
 
6
6
  const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
7
+ // Windows has three common shells. Double quotes mean the same in cmd, PowerShell and Git Bash.
8
+ const quoteWindows = value => /^[\w.:/=+-]+$/.test(value) ? value : `"${value}"`;
7
9
  // Client selection changes registration format only. The transport and project identity are model agnostic.
8
- export function reusableConfiguration({ client, home = sessionHome() } = {}) {
10
+ export function reusableConfiguration({ client, home = sessionHome(), platform = process.platform } = {}) {
9
11
  if (!['claude', 'codex', 'other'].includes(client)) throw new Error('Choose --client claude, codex or other. This selects your MCP client, not your model.');
10
12
  if (typeof home !== 'string' || !isAbsolute(home) || /[\x00-\x1f\x7f-\x9f]/.test(home)) throw new Error('Choose an absolute private state directory.');
11
13
  home = canonicalPath(home);
12
14
  const name = 'karma42';
13
- const args = ['--yes', '--ignore-scripts', `@karma42/agent@${VERSION}`, 'mcp-serve', '--state-dir', home];
14
- const prefix = client === 'claude' ? ['claude', 'mcp', 'add', '--scope', 'user', name, '--', 'npx'] : client === 'codex' ? ['codex', 'mcp', 'add', name, '--', 'npx'] : null;
15
- const entry = { command: 'npx', args };
15
+ const windows = platform === 'win32';
16
+ if (windows && /["%$`]/.test(home)) throw new Error('Choose a private state directory without quotes, percent, dollar or backtick characters.');
17
+ const npx = ['--yes', '--ignore-scripts', `@karma42/agent@${VERSION}`, 'mcp-serve', '--state-dir', home];
18
+ // On Windows npx is a batch file, which a client can start only through cmd.
19
+ const entry = windows ? { command: 'cmd', args: ['/c', 'npx', ...npx] } : { command: 'npx', args: npx };
20
+ const prefix = client === 'claude' ? ['claude', 'mcp', 'add', '--scope', 'user', name, '--'] : client === 'codex' ? ['codex', 'mcp', 'add', name, '--'] : null;
16
21
  return { name, client, scope: client === 'other' ? 'client-settings' : 'user',
17
- command: prefix ? [...prefix, ...args].map(quote).join(' ') : null,
18
- entry, generic: { mcpServers: { [name]: entry } },
22
+ command: prefix ? [...prefix, entry.command, ...entry.args].map(windows ? quoteWindows : quote).join(' ') : null,
23
+ entry, generic: { mcpServers: { [name]: entry } }, windows,
19
24
  };
20
25
  }
21
26
  export function renderReusableConfiguration(config) {
@@ -26,6 +31,7 @@ export function renderReusableConfiguration(config) {
26
31
  config.client === 'other' ? 'Add the karma42 stdio connection using your MCP client’s settings and configuration format. The JSON below describes the command and arguments; adapt its wrapper if your client uses a different schema.' : `Register karma42 once in ${config.client === 'claude' ? 'Claude Code’s user-level MCP settings' : 'Codex’s user-level ~/.codex/config.toml'}.`,
27
32
  'Approve adding this entry before changing settings. If karma42 already exists, inspect it first. Preserve other entries and existing permissions.',
28
33
  config.command ?? JSON.stringify(config.generic, null, 2),
34
+ ...(config.windows && config.command ? ['Windows: this line is for PowerShell or cmd. In Git Bash write //c in place of /c.'] : []),
29
35
  '',
30
36
  'Also offer to install the bundled Karma42 skill. Ask before writing it unless the human already approved both MCP registration and skill installation.',
31
37
  `Read its canonical content with: npx --ignore-scripts @karma42/agent@${VERSION} skill`,
@@ -32,10 +32,11 @@ export const OWNER_TOOLS = [
32
32
  { name: 'disconnect_owner_account', description: 'Revoke this saved human management connection. Agent bindings and running agents are separate and remain unchanged.', inputSchema: object({ owner_ref: ownerRef }, ['owner_ref']) },
33
33
  ];
34
34
  // The one question asked before an agent starts, in the shape a client's question tool takes.
35
- export function startQuestion(agentName, projectName, settings) {
35
+ export function startQuestion(agentName, projectName, settings, platform = process.platform) {
36
36
  const minutes = Number.isInteger(settings?.minutes) ? settings.minutes : 60, hours = minutes / 60;
37
37
  const length = minutes < 60 ? `${minutes} minutes` : minutes % 60 ? `${Math.floor(hours)} h ${minutes % 60} min` : hours === 1 ? '1 hour' : `${hours} hours`;
38
- const how = settings?.involvement === 'with_me' ? 'works with you in this conversation' : 'runs on its own';
38
+ // Windows has no supervised worker yet, so an agent there works in the conversation whatever its saved setting.
39
+ const how = settings?.involvement === 'with_me' || platform === 'win32' ? 'works with you in this conversation' : 'runs on its own';
39
40
  return { question: `Start ${agentName} on ${projectName ?? 'this project'}? It ${how}, for up to ${length}.`, options: ['Yes, start', 'Change the limits', 'Not now'] };
40
41
  }
41
42
  export function ownerOrigin(site) {
@@ -36,7 +36,7 @@ const result = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }
36
36
 
37
37
  // MCP hosts may share a server across conversations. Every tool carries an explicit binding
38
38
  // reference; neither a process nor a shared state file ever has a default active project.
39
- export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, boundServer = serveSession, workerFactory, discoveryOptions, ownerFactory = createOwnerClient, ownerOptions, approvalWait = ms => new Promise(resolve => setTimeout(resolve, ms)), openApproval = () => false, ...boundOptions }) {
39
+ export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, boundServer = serveSession, workerFactory, discoveryOptions, ownerFactory = createOwnerClient, ownerOptions, approvalWait = ms => new Promise(resolve => setTimeout(resolve, ms)), openApproval = () => false, platform = process.platform, ...boundOptions }) {
40
40
  const bundled = verifyBundle();
41
41
  const projectTools = JSON.parse(bundled.files['project-tools.json'].toString('utf8')).tools.map(tool => ({
42
42
  ...tool, description: `${tool.description ?? ''} Always supply this project agent’s connection_ref.`,
@@ -211,6 +211,8 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
211
211
  async function startWorker(args) {
212
212
  if (Object.keys(args).some(key => !['connection_ref', 'client', 'model', 'minutes', 'scope', 'folder', 'approved', 'recovery_ref'].includes(key))) throw new Error('Use only the documented worker options.');
213
213
  const context = verifiedContext(args), minutes = args.minutes === undefined ? 60 : args.minutes;
214
+ // The process guard stops a whole process group, which Windows does not have. Refuse before anything is prepared.
215
+ if (platform === 'win32') throw new Error('Supervised workers are not available on Windows yet. Work in the current conversation instead; nothing was started.');
214
216
  if (args.approved !== true) throw new Error('Worker start requires your human’s explicit approval for this worker, duration, folder and work scope. Connection approval is insufficient.');
215
217
  if (!['claude', 'codex'].includes(args.client) || !Number.isInteger(minutes) || minutes < 15 || minutes > 240 || minutes % 15 !== 0) throw new Error('Choose a supported worker client and 15–240 minutes in 15-minute increments.');
216
218
  if (typeof args.scope !== 'string' || !args.scope.trim() || args.scope.length > 4000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(args.scope)) throw new Error('Use a clear, human-approved work scope of at most 4000 characters.');
package/src/state.mjs CHANGED
@@ -1,40 +1,56 @@
1
1
  import { constants, lstatSync, realpathSync, fstatSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
- import { join, dirname, basename, resolve, relative, isAbsolute } from 'node:path';
2
+ import nodePath, { join, dirname, basename, resolve } from 'node:path';
3
+ import { userInfo } from 'node:os';
3
4
  import { randomUUID, createHash } from 'node:crypto';
4
5
 
6
+ // Windows has no permission bits: every file reports mode 666 and the folder's access list decides who
7
+ // reads it. Private storage there must be inside the person's own user folder, which Windows keeps to
8
+ // that account, the system and administrators. The other checks are the same on every system.
9
+ const windows = process.platform === 'win32';
10
+ const exposed = stat => !windows && (stat.mode & 0o077);
11
+ // The native call also expands Windows short names (RUNNER~1), so one folder has one spelling.
12
+ export const physicalPath = windows ? realpathSync.native : realpathSync;
13
+
5
14
  export function canonicalPath(value) {
6
15
  let path = resolve(value);
7
16
  const tail = [];
8
17
  while (!lstatExists(path)) { tail.unshift(basename(path)); path = dirname(path); }
9
- return join(realpathSync(path), ...tail);
18
+ return join(physicalPath(path), ...tail);
19
+ }
20
+ // True when path is root or below it. Windows separates with a backslash and ignores letter case.
21
+ export function pathInside(root, path, { relative, isAbsolute, sep } = nodePath) {
22
+ const part = relative(root, path);
23
+ return part === '' || (part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part));
10
24
  }
11
25
  // Physical paths matter: a symlink spelling must not allow credentials inside a checkout.
12
26
  export function assertStateOutsideWorkspace(home, folder) {
13
27
  const state = canonicalPath(home), workspace = canonicalPath(folder);
14
- const inside = (root, path) => {
15
- const part = relative(root, path);
16
- return part === '' || (part !== '..' && !part.startsWith('../') && !isAbsolute(part));
17
- };
18
- if (inside(workspace, state) || inside(state, workspace)) throw new Error('Private connection state and the project workspace must be separate sibling paths. Choose storage outside the checkout.');
28
+ if (pathInside(workspace, state) || pathInside(state, workspace)) throw new Error('Private connection state and the project workspace must be separate sibling paths. Choose storage outside the checkout.');
19
29
  }
20
30
  export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
21
31
  export function privateDir(path) {
22
32
  try { mkdirSync(path, { mode: 0o700 }); }
23
33
  catch (error) { if (error.code !== 'EEXIST') throw error; }
24
34
  const stat = lstatSync(path);
25
- if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Error('Connection storage must be a private directory, without symbolic links.');
35
+ if (!stat.isDirectory() || stat.isSymbolicLink() || exposed(stat)) throw new Error('Connection storage must be a private directory, without symbolic links.');
26
36
  }
27
37
  export function ensureHome(home) {
28
38
  // Only the caller-selected root is created recursively. Existing root must be private.
29
39
  mkdirSync(home, { recursive: true, mode: 0o700 });
30
40
  privateDir(home);
41
+ if (windows) {
42
+ const profile = canonicalPath(userInfo().homedir), state = canonicalPath(home);
43
+ if (state === profile || !pathInside(profile, state)) throw new Error(`Private connection storage on Windows must be inside your user folder (${profile}), which Windows keeps private to your account.`);
44
+ }
31
45
  }
32
46
  export function readPrivate(path, json = false) {
47
+ // Windows cannot refuse a symbolic link while opening, so it is refused by name first.
48
+ if (windows && lstatSync(path).isSymbolicLink()) throw new Error('Connection files must be private regular files.');
33
49
  for (let attempt = 0; attempt < 3; attempt++) {
34
50
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
35
51
  try {
36
52
  const stat = fstatSync(fd);
37
- if (!stat.isFile() || (stat.mode & 0o077) || stat.nlink > 1) throw new Error('Connection files must be private regular files.');
53
+ if (!stat.isFile() || exposed(stat) || stat.nlink > 1) throw new Error('Connection files must be private regular files.');
38
54
  // Atomic replacement can unlink the inode between open and fstat. Reopen
39
55
  // the named record; never read an unlinked inode or relax private-file checks.
40
56
  if (stat.nlink === 0 && attempt < 2) continue;
@@ -48,13 +64,23 @@ export function readPrivate(path, json = false) {
48
64
  export function writePrivate(path, value) {
49
65
  try {
50
66
  const stat = lstatSync(path);
51
- if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.nlink !== 1) throw new Error('Refusing to replace unsafe connection storage.');
67
+ if (!stat.isFile() || stat.isSymbolicLink() || exposed(stat) || stat.nlink !== 1) throw new Error('Refusing to replace unsafe connection storage.');
52
68
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
53
69
  const temp = `${path}.${randomUUID()}.tmp`;
54
70
  const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
55
71
  try { writeFileSync(fd, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`); }
56
72
  finally { closeSync(fd); }
57
- try { renameSync(temp, path); } finally { try { unlinkSync(temp); } catch {} }
73
+ try { replaceFile(temp, path); } finally { try { unlinkSync(temp); } catch {} }
74
+ }
75
+ // Windows refuses to replace a file while another process, often a virus scanner, holds it open. Wait briefly.
76
+ function replaceFile(temp, path) {
77
+ for (let attempt = 0; ; attempt++) {
78
+ try { return renameSync(temp, path); }
79
+ catch (error) {
80
+ if (!windows || attempt === 9 || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error;
81
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
82
+ }
83
+ }
58
84
  }
59
85
  export function connectionDir(home, id) {
60
86
  if (!/^[a-f0-9]{64}\/[\w-]{1,200}$/.test(id ?? '')) throw new Error('Choose a connection ID from status.');
package/src/version.mjs CHANGED
@@ -1,3 +1,3 @@
1
- export const VERSION = '0.1.0-beta.12';
1
+ export const VERSION = '0.1.0-beta.13';
2
2
  // Raised when the bundled guide gains rules a website prompt depends on; prompts name the minimum they need.
3
3
  export const GUIDE_REVISION = 5;
@@ -1,4 +1,4 @@
1
- import { join } from 'node:path';
1
+ import { join, isAbsolute } from 'node:path';
2
2
  import { lstatExists, readPrivate, writePrivate } from './state.mjs';
3
3
  import { claimWorkerCheckout } from './checkout-ownership.mjs';
4
4
 
@@ -18,7 +18,7 @@ export function readWorkerMission(dir, record) {
18
18
  const value = readPrivate(path, true);
19
19
  if (!value || typeof value !== 'object' || Array.isArray(value) || value.schema !== 1 || !uuid(value.id) || value.connectionId !== record.id || value.service !== record.service
20
20
  || value.repo !== record.repo || !uuid(value.workerDir) || !Number.isSafeInteger(value.ownerPid) || value.ownerPid <= 0
21
- || !['claude', 'codex'].includes(value.client) || !bounded(value.folder, 4000) || !value.folder.startsWith('/')
21
+ || !['claude', 'codex'].includes(value.client) || !bounded(value.folder, 4000) || !isAbsolute(value.folder)
22
22
  || !bounded(value.scope, 4000) || !(value.model === null || (typeof value.model === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,149}$/.test(value.model)))
23
23
  || !Number.isSafeInteger(value.startedAt) || !Number.isSafeInteger(value.deadline) || value.deadline <= value.startedAt
24
24
  || value.deadline - value.startedAt < 15 * 60000 || value.deadline - value.startedAt > 240 * 60000
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import { rmSync } from 'node:fs';
3
3
  import { createWorkController } from '../runtime/work-controller.mjs';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { join } from 'node:path';
5
+ import { join, isAbsolute } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { createProvider, CLAUDE_ALLOWED_TOOLS } from '../runtime/providers.mjs';
8
8
  import { VERSION } from './version.mjs';
@@ -32,7 +32,7 @@ const contactIsTransient = failure => failure.fatal === false || failure.name ==
32
32
  || (failure instanceof TypeError && failure.message === 'fetch failed')
33
33
  || ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(failure.cause?.code);
34
34
  export function validateWorkerWorkspace(folder, record) {
35
- if (typeof folder !== 'string' || !folder.startsWith('/')) throw new Error('Choose a separate absolute Git checkout or worktree for the worker.');
35
+ if (typeof folder !== 'string' || !isAbsolute(folder)) throw new Error('Choose a separate absolute Git checkout or worktree for the worker.');
36
36
  const cwd = canonicalPath(folder);
37
37
  // An agent added through the approved computer has a folder of its own, which is also its checkout.
38
38
  // A connection made from a chat folder still needs a separate one, so the chat and worker never share files.