@volter/supercode-teams 0.3.6 → 0.3.8

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/bin/teams.mjs CHANGED
@@ -29,7 +29,7 @@
29
29
  // Every other verb (server, login, context, connect, disconnect, status,
30
30
  // members, groups, sources, sync, machines, sessions) is the Teams workspace
31
31
  // CLI in ./workspace.mjs.
32
- import { createWriteStream, readFileSync, writeFileSync } from 'node:fs';
32
+ import { appendFileSync, createWriteStream, readFileSync, writeFileSync } from 'node:fs';
33
33
  import { spawnSync } from 'node:child_process';
34
34
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
35
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
@@ -172,7 +172,9 @@ async function main() {
172
172
  return;
173
173
  }
174
174
  if (sub === 'status') { out(readJson(p.machine, { running: false })); return; }
175
- die('machine start|status');
175
+ // `machine restart [--machine M] [--reason text]`: the daemon (this one, or M's through the server) restarts under its service.
176
+ if (sub === 'restart') return withNode(async (rpc) => out(await rpc.call('machine.restart', { reason: flags.reason ? String(flags.reason) : null })));
177
+ die('machine start|status|restart');
176
178
  }
177
179
  case 'describe':
178
180
  return withNode(async (rpc) => out(await rpc.call('describe')));
@@ -212,9 +214,17 @@ async function main() {
212
214
  if (sub === 'stat') return withNode(async (rpc) => out(await rpc.call('files.stat', { path })));
213
215
  if (sub === 'search') return withNode(async (rpc) => out(await rpc.call('files.search', { path, query: flags.query ?? '' })));
214
216
  if (sub === 'get') return withNode(async (rpc) => {
215
- const result = await rpc.call('files.read', { path });
216
- const bytes = Buffer.from(result.bytes, 'base64');
217
- if (flags.out) writeFileSync(flags.out, bytes); else process.stdout.write(bytes);
217
+ // In ranges: one answer is one frame, so a file of any size is read 4 MiB at a time until it ends.
218
+ const { size } = await rpc.call('files.stat', { path });
219
+ const CHUNK = 4 * 1024 * 1024;
220
+ if (flags.out) writeFileSync(flags.out, Buffer.alloc(0));
221
+ for (let offset = 0; offset < size;) {
222
+ const result = await rpc.call('files.read', { path, offset, length: Math.min(CHUNK, size - offset) });
223
+ const bytes = Buffer.from(result.bytes, 'base64');
224
+ if (!bytes.length) break;
225
+ if (flags.out) appendFileSync(flags.out, bytes); else await new Promise((done) => process.stdout.write(bytes, done));
226
+ offset += bytes.length;
227
+ }
218
228
  });
219
229
  if (sub === 'put') return withNode(async (rpc) => {
220
230
  const bytes = flags.in ? readFileSync(flags.in) : readFileSync(0);
package/bin/workspace.mjs CHANGED
@@ -1096,6 +1096,12 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
1096
1096
  // unreachable with no remote way back (the Mac mini sat unlinked for hours while its connector lived on): a
1097
1097
  // daemon that died, a start that half failed or a link its backoff never recovered all end in a fresh start.
1098
1098
  // It exits non-zero, which every service manager here restarts.
1099
+ // A service-run connector restarts on request through the machine's door: it exits non-zero, which every service
1100
+ // manager here restarts (the Windows task's minute trigger starts it whatever the exit).
1101
+ if (saved.service) machine.restartHandler = (reason) => {
1102
+ process.stderr.write(`supercode teams: restart requested through the machine door${reason ? ` (${reason})` : ''}\n`);
1103
+ void (async () => { await stopConnector?.(); process.exit(75); })();
1104
+ };
1099
1105
  if (saved.service) {
1100
1106
  const linkWatch = setInterval(() => {
1101
1107
  if (linkReplaced || linkDownSince === null || Date.now() - linkDownSince < LINK_LOST_MS) return;
package/execution.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // so. A job ends when its stream closes, when the daemon stops, and, if the daemon died, when
9
9
  // the next daemon reaps the record it left.
10
10
  import { spawn } from 'node:child_process';
11
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
11
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
12
12
  import { delimiter, join } from 'node:path';
13
13
  import { EXECUTION_DOORS, executionDoorFor } from '@volter/supercode-harness-sdk/execution-doors';
14
14
  import { fileURLToPath } from 'node:url';
@@ -36,9 +36,21 @@ export function sftpServer() {
36
36
  return SFTP_SERVERS.find((path) => existsSync(path)) ?? null;
37
37
  }
38
38
 
39
- /** `program` as this daemon's PATH finds it, or null. */
39
+ /** `program` as this daemon's own spawn (no shell) can run it from PATH, or null: an executable file, which on
40
+ * Windows is a `.exe` or `.com` (an npm `.cmd` shim or an extensionless sh script there does not start). */
40
41
  function onPath(program, env) {
41
- for (const dir of (env.PATH ?? '').split(delimiter)) if (dir && existsSync(join(dir, program))) return join(dir, program);
42
+ const names = process.platform === 'win32' ? [`${program}.exe`, `${program}.com`] : [program];
43
+ for (const dir of (env.PATH ?? env.Path ?? '').split(delimiter)) {
44
+ if (!dir) continue;
45
+ for (const name of names) {
46
+ const candidate = join(dir, name);
47
+ try {
48
+ if (!statSync(candidate).isFile()) continue;
49
+ if (process.platform !== 'win32') accessSync(candidate, constants.X_OK);
50
+ return candidate;
51
+ } catch { /* not here, or not runnable */ }
52
+ }
53
+ }
42
54
  return null;
43
55
  }
44
56
 
package/machine.mjs CHANGED
@@ -30,7 +30,7 @@ import { EventEmitter } from 'node:events';
30
30
  import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync, accessSync, constants } from 'node:fs';
31
31
  import { join, resolve, dirname } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
- import { boardGuardArguments } from './board-process-guard.mjs';
33
+ import { boardGuardArguments } from '@volter/teams/board-process-guard';
34
34
  import { homedir, hostname } from 'node:os';
35
35
  import { Workspaces, inspectCheckout } from './workspaces.mjs';
36
36
  import { WorkspaceContexts, fromContext } from '@volter/teams/context';
@@ -197,6 +197,8 @@ export class TeamsMachine extends MachineCore {
197
197
  this.onTurn = onTurn;
198
198
  this.onTurnWithdrawn = onTurnWithdrawn;
199
199
  this.tmuxSocket = tmuxSocket ?? null;
200
+ // Set by a service-run connector: how `machine.restart` ends this daemon so its service manager starts it again.
201
+ this.restartHandler = null;
200
202
  this.launches = new Launches(this.p.launches);
201
203
  // Windows has no tmux: its panes live in the machine's pane server, a process of their own that outlives this
202
204
  // daemon (a restart or an upgrade never ends a pane), as tmux panes live in tmux's server.
@@ -1215,6 +1217,7 @@ export class TeamsMachine extends MachineCore {
1215
1217
  execution: executionCapabilities(this.env),
1216
1218
  boardProcessPolicy: process.platform === 'win32' ? null : { version: 1, harnesses: ['claude-code','codex'], boundary: 'native-PreToolUse' },
1217
1219
  runSpec: this.env.SUPERCODE_TEAMS_RUN_SPEC_SHA256 ?? null,
1220
+ restart: Boolean(this.restartHandler),
1218
1221
  at: Date.now(),
1219
1222
  };
1220
1223
  return { ...body, signature: signBytes(this.identity.privateKeyPem, Buffer.from(canonical(body))) };
@@ -1695,6 +1698,17 @@ export class TeamsMachine extends MachineCore {
1695
1698
  case 'harness.v1.machine.describe':
1696
1699
  case 'describe':
1697
1700
  return this.describe();
1701
+ case 'harness.v1.machine.restart':
1702
+ case 'machine.restart': {
1703
+ // A stale daemon (old code, a module it failed to load) is restarted from any machine its operator reaches,
1704
+ // never needing a person at it. Only a daemon its service manager brings back may go: one run by hand would not return.
1705
+ if (!operator) { this.log.append({ principal, cap: method, target: '*', outcome: 'refused' }); throw Object.assign(new Error('only an operator restarts the machine daemon'), { code: 403 }); }
1706
+ if (!this.restartHandler) throw Object.assign(new Error('this daemon runs without a service manager, so nothing would start it again; restart it where it runs'), { code: 'unsupported' });
1707
+ act(method, '*', 'ok', params.reason ? String(params.reason).slice(0, 200) : null);
1708
+ // The answer goes out first; the service manager starts the next daemon.
1709
+ setTimeout(() => this.restartHandler(params.reason ? String(params.reason) : null), 500).unref?.();
1710
+ return { restarting: true, pid: process.pid };
1711
+ }
1698
1712
  case 'panes.present': {
1699
1713
  // No harness callback: discovery may be the harness's own caller.
1700
1714
  const whole = operator || holds(admitted, 'view', '*');
@@ -2292,8 +2306,8 @@ async function filesOp(method, params, to) {
2292
2306
  case 'files.read': {
2293
2307
  const fd = openSync(path, 'r');
2294
2308
  try {
2295
- // One read is at most 8 MiB (RH2's transfer chunks at 8 MiB); a caller asks again for more.
2296
- const length = Math.min(Number(params.length) || statSync(path).size, 8 * 1024 * 1024);
2309
+ // One read is at most 4 MiB, so its base64 answer fits the channel's 8 MiB frame; a caller asks again for more.
2310
+ const length = Math.min(Number(params.length) || statSync(path).size, 4 * 1024 * 1024);
2297
2311
  const buffer = Buffer.alloc(length);
2298
2312
  const read = readSync(fd, buffer, 0, length, params.offset ?? 0);
2299
2313
  return { bytes: buffer.subarray(0, read).toString('base64'), read };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",
@@ -13,7 +13,6 @@
13
13
  "./event-client": "./event-client.mjs",
14
14
  "./bin": "./bin/teams.mjs",
15
15
  "./fleet": "./fleet.mjs",
16
- "./board-process-guard": "./board-process-guard.mjs",
17
16
  "./bin/fleet": "./bin/fleet.mjs",
18
17
  "./server": {
19
18
  "types": "./server/index.d.ts",
@@ -67,7 +66,7 @@
67
66
  "@volter/supercode-health": "^0.1.0",
68
67
  "@volter/supercode-terminal": "^0.2.54",
69
68
  "@volter/supercode-trace": "^0.1.0",
70
- "@volter/teams": "0.2.3",
69
+ "@volter/teams": "0.2.5",
71
70
  "@xterm/addon-serialize": "^0.14.0",
72
71
  "@xterm/headless": "^6.0.0",
73
72
  "protobufjs": "8.8.0",
@@ -1,52 +0,0 @@
1
- #!/usr/bin/env node
2
- // The board's native PreToolUse command policy. No process lookup or signal occurs here.
3
- import { readFileSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
5
- import { resolve } from 'node:path';
6
- export const processGuardReason = 'Pattern kills are refused for board sessions. Kill only the PID this session recorded when it started that process; use the owning service door for a service.';
7
- export function patternKill(command) {
8
- // Inspect shell bodies as well as nested `sh -c` / substitutions. Removing shell
9
- // escapes and quotes catches split words, paths and quoted embedded commands.
10
- const text = String(command).replace(/\\\r?\n/g, '').replace(/\\([a-zA-Z-])/g, '$1').replace(/["']/g, '');
11
- const head = String.raw`(?:^|[;|&(\n{}]|\s-[a-zA-Z]*c\s+)\s*(?:(?:env|sudo|exec|command|xargs|nohup|do|then|else|!)\s+(?:--?[^\s;|&()]+\s+)*|[A-Za-z_][A-Za-z0-9_]*=[^\s;|&()]+\s+)*(?:[\w./-]+/)?`;
12
- return new RegExp(head + String.raw`(?:pkill|killall)(?=$|[\s;|&()])`).test(text) ||
13
- new RegExp(head + String.raw`pgrep\s+[^;|&\n)]*(?:--full\b|-[a-zA-Z]*f[a-zA-Z]*(?=$|\s))`).test(text);
14
- }
15
- const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
16
- export function boardGuardArguments(kind, args, { cwd, enabled, node = process.execPath } = {}) {
17
- if (!enabled) return args;
18
- const command = `${quote(node)} ${quote(fileURLToPath(import.meta.url))}`;
19
- const hooks = [{ matcher: '^Bash$', hooks: [{ type: 'command', command, timeout: 10 }] }];
20
- if (kind === 'claude-code') {
21
- const next = [...args], at = next.findIndex(arg => arg === '--settings' || arg.startsWith('--settings='));
22
- if (at < 0) return [...next, '--settings', JSON.stringify({ hooks: { PreToolUse: hooks } })];
23
- const inline = next[at].startsWith('--settings='), value = inline ? next[at].slice(11) : next[at + 1];
24
- const settings = JSON.parse(value?.trimStart().startsWith('{') ? value : readFileSync(resolve(cwd || process.cwd(), value), 'utf8'));
25
- settings.hooks ??= {}; settings.hooks.PreToolUse = [...(settings.hooks.PreToolUse ?? []), ...hooks];
26
- next.splice(at, inline ? 1 : 2, '--settings', JSON.stringify(settings));
27
- return next;
28
- }
29
- if (kind === 'codex') {
30
- // Native Codex rules cannot split substitutions such as kill $(pgrep -f …).
31
- // Its synchronous Bash hook can deny the entire command before execution.
32
- // This invocation enables and trusts the board-vetted command policy.
33
- const handler = `{ matcher = "^Bash$", hooks = [{ type = "command", command = ${JSON.stringify(command)}, timeout = 10 }] }`;
34
- // Codex's own flags go before any subcommand (`app-server`, `resume`) or prompt: after `app-server`, Codex
35
- // 0.159 refuses --dangerously-bypass-hook-trust and the worker never starts.
36
- return [...(args.includes('--dangerously-bypass-hook-trust') ? [] : ['--dangerously-bypass-hook-trust']), '-c', 'features.hooks=true', '-c', `hooks.PreToolUse=[${handler}]`, ...args];
37
- }
38
- throw new Error(`board process guard is unavailable for ${kind}; no unguarded session was launched`);
39
- }
40
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
41
- try {
42
- const input = JSON.parse(readFileSync(0, 'utf8'));
43
- const command = input.tool_input?.command ?? input.tool_input?.cmd;
44
- if (typeof command !== 'string') throw new Error('Bash hook input has no command');
45
- if (patternKill(command)) process.stdout.write(JSON.stringify({ hookSpecificOutput: {
46
- hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: processGuardReason,
47
- } }) + '\n');
48
- } catch (error) {
49
- process.stderr.write(`Board process policy could not read the command: ${error.message}. ${processGuardReason}\n`);
50
- process.exitCode = 2;
51
- }
52
- }