@vellumai/cli 0.11.7-staging.2 → 0.11.8-staging.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.
Files changed (61) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  4. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  5. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  6. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  7. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  8. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  10. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  11. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  12. package/node_modules/@vellumai/local-mode/src/hatch.ts +17 -2
  13. package/node_modules/@vellumai/local-mode/src/index.ts +5 -0
  14. package/node_modules/@vellumai/local-mode/src/lifecycle-budgets.ts +47 -0
  15. package/node_modules/@vellumai/local-mode/src/retire.ts +9 -3
  16. package/node_modules/@vellumai/local-mode/src/sleep.ts +5 -5
  17. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  18. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  21. package/package.json +4 -3
  22. package/src/__tests__/assistant-client-platform-auth.test.ts +219 -0
  23. package/src/__tests__/clean.test.ts +1 -0
  24. package/src/__tests__/nginx-ingress.test.ts +123 -8
  25. package/src/__tests__/retire-local.test.ts +32 -1
  26. package/src/__tests__/sleep.test.ts +5 -3
  27. package/src/__tests__/teleport.test.ts +1 -0
  28. package/src/__tests__/upgrade-local.test.ts +116 -0
  29. package/src/__tests__/windows-lifecycle.test.ts +77 -5
  30. package/src/adapters/openclaw-http-server.ts +5 -0
  31. package/src/commands/client.ts +35 -7
  32. package/src/commands/exec.ts +8 -2
  33. package/src/commands/logs.ts +11 -2
  34. package/src/commands/sleep.ts +6 -9
  35. package/src/commands/ssh.ts +6 -3
  36. package/src/commands/teleport.ts +6 -1
  37. package/src/commands/upgrade.ts +22 -12
  38. package/src/commands/voice.test.ts +81 -0
  39. package/src/commands/voice.ts +83 -6
  40. package/src/commands/wake.ts +7 -1
  41. package/src/lib/assistant-client.ts +145 -31
  42. package/src/lib/cloudflare-tunnel.ts +2 -1
  43. package/src/lib/detached-process.ts +1 -0
  44. package/src/lib/guardian-token.ts +4 -2
  45. package/src/lib/hatch-local.ts +5 -1
  46. package/src/lib/live-voice/client.ts +23 -2
  47. package/src/lib/live-voice/connection.ts +87 -10
  48. package/src/lib/live-voice/pcm-player.ts +16 -3
  49. package/src/lib/live-voice/velay.test.ts +99 -0
  50. package/src/lib/live-voice/velay.ts +199 -0
  51. package/src/lib/local-hatch-lock.ts +16 -3
  52. package/src/lib/local.ts +48 -13
  53. package/src/lib/nginx-ingress.ts +70 -10
  54. package/src/lib/ngrok.ts +2 -0
  55. package/src/lib/open-browser.ts +5 -1
  56. package/src/lib/process.ts +72 -10
  57. package/src/lib/provider-secrets.ts +5 -0
  58. package/src/lib/retire-local.ts +12 -5
  59. package/src/lib/step-runner.ts +3 -0
  60. package/src/lib/tailscale-tunnel.ts +6 -1
  61. package/src/lib/upgrade-lifecycle.ts +1 -0
package/AGENTS.md CHANGED
@@ -65,7 +65,7 @@ The CLI must **never** read from or write to the `.vellum/` directory (e.g. `~/.
65
65
 
66
66
  For example, the signing key used for JWT auth between the daemon and gateway is persisted in the lockfile (`resources.signingKey`) so that client actor tokens survive daemon/gateway restarts. On first start (or when the key is missing), the CLI generates a new key via `generateLocalSigningKey()` in `lib/local.ts`, saves it to the lockfile entry, and passes it to both `startLocalDaemon` and `startGateway` as the `ACTOR_TOKEN_SIGNING_KEY` env var. The CLI does **not** read or write to the `.vellum/` directory for signing keys — it uses the lockfile instead.
67
67
 
68
- **Exception: `~/.vellum/device.json`.** That file is the machine-wide shared device-identity file, co-owned by the Electron main process, the host-mode assistant, and the CLI (see `clients/macos/src/main/device-id.ts`). The boundary rule covers daemon/gateway-internal state (e.g. `~/.vellum/protected/`, instance dirs), not this file.
68
+ **Exception: `~/.vellum/device.json`.** That file is the machine-wide shared device-identity file, co-owned by the Electron main process, the host-mode assistant, and the CLI (see `packages/electron-desktop/src/device-id.ts`). The boundary rule covers daemon/gateway-internal state (e.g. `~/.vellum/protected/`, instance dirs), not this file.
69
69
 
70
70
  ## Process liveness
71
71
 
@@ -7,7 +7,8 @@
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./seeds": "./src/seeds.ts",
10
- "./shell": "./src/shell.ts"
10
+ "./shell": "./src/shell.ts",
11
+ "./test-path-guard": "./src/test-path-guard.ts"
11
12
  },
12
13
  "scripts": {
13
14
  "typecheck": "bunx tsc --noEmit",
@@ -0,0 +1,89 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, test } from "bun:test";
5
+
6
+ import {
7
+ assertTestPathIsEphemeral,
8
+ canonicalizePathThroughExistingParent,
9
+ isBunTestProcess,
10
+ } from "../test-path-guard.js";
11
+
12
+ const OPTIONS = {
13
+ allowEnvVar: "TEST_PATH_GUARD_ALLOW",
14
+ runHint: "Run tests from the package root.",
15
+ };
16
+
17
+ describe("isBunTestProcess", () => {
18
+ test("detects this bun test run", () => {
19
+ expect(isBunTestProcess()).toBe(true);
20
+ });
21
+ });
22
+
23
+ describe("canonicalizePathThroughExistingParent", () => {
24
+ test("resolves symlinks on the deepest existing ancestor", () => {
25
+ const root = mkdtempSync(join(tmpdir(), "test-path-guard-"));
26
+ try {
27
+ const target = join(root, "target");
28
+ mkdirSync(target);
29
+ const link = join(root, "link");
30
+ symlinkSync(target, link, "dir");
31
+ expect(
32
+ canonicalizePathThroughExistingParent(join(link, "missing", "leaf")),
33
+ ).toBe(
34
+ join(canonicalizePathThroughExistingParent(target), "missing", "leaf"),
35
+ );
36
+ } finally {
37
+ rmSync(root, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test("returns the input path when no ancestor exists", () => {
42
+ expect(canonicalizePathThroughExistingParent("/nonexistent-root-x/y")).toBe(
43
+ "/nonexistent-root-x/y",
44
+ );
45
+ });
46
+ });
47
+
48
+ describe("assertTestPathIsEphemeral", () => {
49
+ test("allows paths under tmpdir, existing or not", () => {
50
+ expect(() =>
51
+ assertTestPathIsEphemeral(join(tmpdir(), "guard-missing", "x"), OPTIONS),
52
+ ).not.toThrow();
53
+ });
54
+
55
+ test("refuses paths outside tmpdir, naming the escape hatch and hint", () => {
56
+ delete process.env[OPTIONS.allowEnvVar];
57
+ const dir = join(homedir(), "test-path-guard-live");
58
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
59
+ /Refusing to use[\s\S]*package root[\s\S]*TEST_PATH_GUARD_ALLOW/,
60
+ );
61
+ });
62
+
63
+ test("refuses a tmpdir symlink that resolves outside tmpdir", () => {
64
+ delete process.env[OPTIONS.allowEnvVar];
65
+ const link = join(tmpdir(), `test-path-guard-escape-${process.pid}`);
66
+ rmSync(link, { force: true });
67
+ symlinkSync(homedir(), link);
68
+ try {
69
+ expect(() => assertTestPathIsEphemeral(link, OPTIONS)).toThrow(
70
+ /Refusing to use/,
71
+ );
72
+ } finally {
73
+ rmSync(link, { force: true });
74
+ }
75
+ });
76
+
77
+ test("the allow env var bypasses the guard, per call", () => {
78
+ const dir = join(homedir(), "test-path-guard-optout");
79
+ process.env[OPTIONS.allowEnvVar] = "1";
80
+ try {
81
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).not.toThrow();
82
+ } finally {
83
+ delete process.env[OPTIONS.allowEnvVar];
84
+ }
85
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
86
+ /Refusing to use/,
87
+ );
88
+ });
89
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Live-path guard for test processes, shared by every service that resolves
3
+ * install-layout paths (the assistant daemon's workspace/root, the gateway's
4
+ * workspace and security dirs).
5
+ *
6
+ * A test process must never resolve one of those paths to a real, non-temp
7
+ * directory: production code exercised by a test would then read and
8
+ * destructively write live state. The tmpdir redirection normally comes from
9
+ * each package's bunfig.toml test preload, but bun only loads bunfig from
10
+ * the cwd, so `bun test` run from any other directory skips the preload and
11
+ * inherits the ambient env (or a ~/.vellum fallback). The containment
12
+ * assertion therefore lives in production code, where it fires no matter how
13
+ * the test process was launched.
14
+ *
15
+ * assistant/src/__tests__/assert-not-live-db.ts keeps its own containment
16
+ * check: test machinery must not import production dependencies.
17
+ */
18
+
19
+ import { realpathSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { basename, dirname, resolve, sep } from "node:path";
22
+
23
+ /**
24
+ * Resolve symlinks in the deepest existing ancestor of `path`, then
25
+ * re-append the not-yet-created tail. This keeps containment checks honest
26
+ * both for paths under a symlinked temp root (macOS /var/folders) and for
27
+ * symlinks that point outside it, whether or not the leaf exists yet.
28
+ */
29
+ export function canonicalizePathThroughExistingParent(path: string): string {
30
+ const resolvedPath = resolve(path);
31
+ const pendingSegments: string[] = [];
32
+ let currentPath = resolvedPath;
33
+
34
+ while (true) {
35
+ try {
36
+ return resolve(realpathSync(currentPath), ...pendingSegments.reverse());
37
+ } catch {
38
+ const parentPath = dirname(currentPath);
39
+ if (parentPath === currentPath) {
40
+ return resolvedPath;
41
+ }
42
+ pendingSegments.push(basename(currentPath));
43
+ currentPath = parentPath;
44
+ }
45
+ }
46
+ }
47
+
48
+ /** Lazily computed: is this process a `bun test` run? */
49
+ let isTestProcess: boolean | undefined;
50
+
51
+ /**
52
+ * True when this process is a `bun test` run. Computed once and cached for
53
+ * the process lifetime, so a runtime flip of NODE_ENV or BUN_TEST does not
54
+ * change the answer. Test-mode behavior toggles that must observe such
55
+ * flips read the env directly instead of calling this.
56
+ */
57
+ export function isBunTestProcess(): boolean {
58
+ isTestProcess ??=
59
+ process.env.NODE_ENV === "test" ||
60
+ process.env.BUN_TEST === "1" ||
61
+ // `bun test` sets NODE_ENV=test only when unset; Bun.main being the test
62
+ // file itself is the backstop signal that survives a preset NODE_ENV.
63
+ (typeof Bun !== "undefined" &&
64
+ /\.(test|spec)\.[cm]?[jt]sx?$/.test(Bun.main));
65
+ return isTestProcess;
66
+ }
67
+
68
+ export interface EphemeralTestPathOptions {
69
+ /**
70
+ * Escape hatch for the rare intentional run against real state: setting
71
+ * this env var to "1" bypasses the guard. Callers reuse the variable their
72
+ * DB-open guard already honors. Deliberately not forwarded into
73
+ * agent-spawned shells (assistant/src/tools/terminal/safe-env.ts), so a
74
+ * daemon-level opt-out cannot disarm the guard for tests run from there.
75
+ */
76
+ allowEnvVar: string;
77
+ /** Package-specific line telling the reader where to run tests from. */
78
+ runHint: string;
79
+ }
80
+
81
+ /**
82
+ * Throws when called in a bun-test process and `dir` does not resolve under
83
+ * `os.tmpdir()`. Inert outside test processes. Canonicalizes on every call
84
+ * (no memo): a previously validated path could later be replaced by a
85
+ * symlink, and the guard only runs in test processes anyway.
86
+ */
87
+ export function assertTestPathIsEphemeral(
88
+ dir: string,
89
+ options: EphemeralTestPathOptions,
90
+ ): void {
91
+ if (!isBunTestProcess()) {
92
+ return;
93
+ }
94
+ if (process.env[options.allowEnvVar] === "1") {
95
+ return;
96
+ }
97
+ const tmpRoot = canonicalizePathThroughExistingParent(tmpdir());
98
+ const resolved = canonicalizePathThroughExistingParent(dir);
99
+ if (resolved !== tmpRoot && !resolved.startsWith(tmpRoot + sep)) {
100
+ throw new Error(
101
+ [
102
+ `Refusing to use ${dir} (resolves to ${resolved}) in a test process: it is not under the temp directory (${tmpRoot}).`,
103
+ "",
104
+ "Tests must only touch ephemeral state; a real directory would expose",
105
+ "live install state to destructive test fixtures. This usually means",
106
+ "`bun test` ran from a cwd without the package's bunfig.toml, so the",
107
+ "test preload that redirects paths to a tmpdir never loaded.",
108
+ options.runHint,
109
+ `Set ${options.allowEnvVar}=1 to bypass deliberately.`,
110
+ ].join("\n"),
111
+ );
112
+ }
113
+ }
@@ -7,7 +7,8 @@
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./seeds": "./src/seeds.ts",
10
- "./shell": "./src/shell.ts"
10
+ "./shell": "./src/shell.ts",
11
+ "./test-path-guard": "./src/test-path-guard.ts"
11
12
  },
12
13
  "scripts": {
13
14
  "typecheck": "bunx tsc --noEmit",
@@ -0,0 +1,89 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, test } from "bun:test";
5
+
6
+ import {
7
+ assertTestPathIsEphemeral,
8
+ canonicalizePathThroughExistingParent,
9
+ isBunTestProcess,
10
+ } from "../test-path-guard.js";
11
+
12
+ const OPTIONS = {
13
+ allowEnvVar: "TEST_PATH_GUARD_ALLOW",
14
+ runHint: "Run tests from the package root.",
15
+ };
16
+
17
+ describe("isBunTestProcess", () => {
18
+ test("detects this bun test run", () => {
19
+ expect(isBunTestProcess()).toBe(true);
20
+ });
21
+ });
22
+
23
+ describe("canonicalizePathThroughExistingParent", () => {
24
+ test("resolves symlinks on the deepest existing ancestor", () => {
25
+ const root = mkdtempSync(join(tmpdir(), "test-path-guard-"));
26
+ try {
27
+ const target = join(root, "target");
28
+ mkdirSync(target);
29
+ const link = join(root, "link");
30
+ symlinkSync(target, link, "dir");
31
+ expect(
32
+ canonicalizePathThroughExistingParent(join(link, "missing", "leaf")),
33
+ ).toBe(
34
+ join(canonicalizePathThroughExistingParent(target), "missing", "leaf"),
35
+ );
36
+ } finally {
37
+ rmSync(root, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test("returns the input path when no ancestor exists", () => {
42
+ expect(canonicalizePathThroughExistingParent("/nonexistent-root-x/y")).toBe(
43
+ "/nonexistent-root-x/y",
44
+ );
45
+ });
46
+ });
47
+
48
+ describe("assertTestPathIsEphemeral", () => {
49
+ test("allows paths under tmpdir, existing or not", () => {
50
+ expect(() =>
51
+ assertTestPathIsEphemeral(join(tmpdir(), "guard-missing", "x"), OPTIONS),
52
+ ).not.toThrow();
53
+ });
54
+
55
+ test("refuses paths outside tmpdir, naming the escape hatch and hint", () => {
56
+ delete process.env[OPTIONS.allowEnvVar];
57
+ const dir = join(homedir(), "test-path-guard-live");
58
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
59
+ /Refusing to use[\s\S]*package root[\s\S]*TEST_PATH_GUARD_ALLOW/,
60
+ );
61
+ });
62
+
63
+ test("refuses a tmpdir symlink that resolves outside tmpdir", () => {
64
+ delete process.env[OPTIONS.allowEnvVar];
65
+ const link = join(tmpdir(), `test-path-guard-escape-${process.pid}`);
66
+ rmSync(link, { force: true });
67
+ symlinkSync(homedir(), link);
68
+ try {
69
+ expect(() => assertTestPathIsEphemeral(link, OPTIONS)).toThrow(
70
+ /Refusing to use/,
71
+ );
72
+ } finally {
73
+ rmSync(link, { force: true });
74
+ }
75
+ });
76
+
77
+ test("the allow env var bypasses the guard, per call", () => {
78
+ const dir = join(homedir(), "test-path-guard-optout");
79
+ process.env[OPTIONS.allowEnvVar] = "1";
80
+ try {
81
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).not.toThrow();
82
+ } finally {
83
+ delete process.env[OPTIONS.allowEnvVar];
84
+ }
85
+ expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
86
+ /Refusing to use/,
87
+ );
88
+ });
89
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Live-path guard for test processes, shared by every service that resolves
3
+ * install-layout paths (the assistant daemon's workspace/root, the gateway's
4
+ * workspace and security dirs).
5
+ *
6
+ * A test process must never resolve one of those paths to a real, non-temp
7
+ * directory: production code exercised by a test would then read and
8
+ * destructively write live state. The tmpdir redirection normally comes from
9
+ * each package's bunfig.toml test preload, but bun only loads bunfig from
10
+ * the cwd, so `bun test` run from any other directory skips the preload and
11
+ * inherits the ambient env (or a ~/.vellum fallback). The containment
12
+ * assertion therefore lives in production code, where it fires no matter how
13
+ * the test process was launched.
14
+ *
15
+ * assistant/src/__tests__/assert-not-live-db.ts keeps its own containment
16
+ * check: test machinery must not import production dependencies.
17
+ */
18
+
19
+ import { realpathSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { basename, dirname, resolve, sep } from "node:path";
22
+
23
+ /**
24
+ * Resolve symlinks in the deepest existing ancestor of `path`, then
25
+ * re-append the not-yet-created tail. This keeps containment checks honest
26
+ * both for paths under a symlinked temp root (macOS /var/folders) and for
27
+ * symlinks that point outside it, whether or not the leaf exists yet.
28
+ */
29
+ export function canonicalizePathThroughExistingParent(path: string): string {
30
+ const resolvedPath = resolve(path);
31
+ const pendingSegments: string[] = [];
32
+ let currentPath = resolvedPath;
33
+
34
+ while (true) {
35
+ try {
36
+ return resolve(realpathSync(currentPath), ...pendingSegments.reverse());
37
+ } catch {
38
+ const parentPath = dirname(currentPath);
39
+ if (parentPath === currentPath) {
40
+ return resolvedPath;
41
+ }
42
+ pendingSegments.push(basename(currentPath));
43
+ currentPath = parentPath;
44
+ }
45
+ }
46
+ }
47
+
48
+ /** Lazily computed: is this process a `bun test` run? */
49
+ let isTestProcess: boolean | undefined;
50
+
51
+ /**
52
+ * True when this process is a `bun test` run. Computed once and cached for
53
+ * the process lifetime, so a runtime flip of NODE_ENV or BUN_TEST does not
54
+ * change the answer. Test-mode behavior toggles that must observe such
55
+ * flips read the env directly instead of calling this.
56
+ */
57
+ export function isBunTestProcess(): boolean {
58
+ isTestProcess ??=
59
+ process.env.NODE_ENV === "test" ||
60
+ process.env.BUN_TEST === "1" ||
61
+ // `bun test` sets NODE_ENV=test only when unset; Bun.main being the test
62
+ // file itself is the backstop signal that survives a preset NODE_ENV.
63
+ (typeof Bun !== "undefined" &&
64
+ /\.(test|spec)\.[cm]?[jt]sx?$/.test(Bun.main));
65
+ return isTestProcess;
66
+ }
67
+
68
+ export interface EphemeralTestPathOptions {
69
+ /**
70
+ * Escape hatch for the rare intentional run against real state: setting
71
+ * this env var to "1" bypasses the guard. Callers reuse the variable their
72
+ * DB-open guard already honors. Deliberately not forwarded into
73
+ * agent-spawned shells (assistant/src/tools/terminal/safe-env.ts), so a
74
+ * daemon-level opt-out cannot disarm the guard for tests run from there.
75
+ */
76
+ allowEnvVar: string;
77
+ /** Package-specific line telling the reader where to run tests from. */
78
+ runHint: string;
79
+ }
80
+
81
+ /**
82
+ * Throws when called in a bun-test process and `dir` does not resolve under
83
+ * `os.tmpdir()`. Inert outside test processes. Canonicalizes on every call
84
+ * (no memo): a previously validated path could later be replaced by a
85
+ * symlink, and the guard only runs in test processes anyway.
86
+ */
87
+ export function assertTestPathIsEphemeral(
88
+ dir: string,
89
+ options: EphemeralTestPathOptions,
90
+ ): void {
91
+ if (!isBunTestProcess()) {
92
+ return;
93
+ }
94
+ if (process.env[options.allowEnvVar] === "1") {
95
+ return;
96
+ }
97
+ const tmpRoot = canonicalizePathThroughExistingParent(tmpdir());
98
+ const resolved = canonicalizePathThroughExistingParent(dir);
99
+ if (resolved !== tmpRoot && !resolved.startsWith(tmpRoot + sep)) {
100
+ throw new Error(
101
+ [
102
+ `Refusing to use ${dir} (resolves to ${resolved}) in a test process: it is not under the temp directory (${tmpRoot}).`,
103
+ "",
104
+ "Tests must only touch ephemeral state; a real directory would expose",
105
+ "live install state to destructive test fixtures. This usually means",
106
+ "`bun test` ran from a cwd without the package's bunfig.toml, so the",
107
+ "test preload that redirects paths to a tmpdir never loaded.",
108
+ options.runHint,
109
+ `Set ${options.allowEnvVar}=1 to bypass deliberately.`,
110
+ ].join("\n"),
111
+ );
112
+ }
113
+ }
@@ -11,6 +11,7 @@
11
11
  "./client-metadata": "./src/client-metadata.ts",
12
12
  "./credential-rpc": "./src/credential-rpc.ts",
13
13
  "./ingress": "./src/ingress.ts",
14
+ "./no-response": "./src/no-response.ts",
14
15
  "./remote-web-pairing": "./src/remote-web-pairing.ts",
15
16
  "./twilio-ingress": "./src/twilio-ingress.ts",
16
17
  "./trust-rules": "./src/trust-rules.ts",
@@ -66,6 +66,13 @@ export function isChannelId(value: unknown): value is ChannelId {
66
66
  * That irregularity is why this is stated rather than derived from the key,
67
67
  * and it is stated here because this file already owns what a channel is.
68
68
  *
69
+ * Both senses can hold a user token, which is the sharpest edge. The `slack`
70
+ * integration's persisted token *is* the installer's user token, held on its
71
+ * OAuth connection. `slack_channel` holds an optional `user_token` in the
72
+ * credential store, beside its bot and app tokens. Same words, different
73
+ * homes, and only the second is a credential-store key: a pasted token is
74
+ * always the channel's, because the integration's never leaves the exchange.
75
+ *
69
76
  * Deliberately only the key. What fields each credential requires is declared
70
77
  * once already, per service, in the gateway's credential specs; restating it
71
78
  * here would be a second copy of a different fact.
@@ -27,6 +27,7 @@ export * from "./handles.js";
27
27
  export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
+ export * from "./no-response.js";
30
31
  export * from "./remote-web-pairing.js";
31
32
  export * from "./twilio-ingress.js";
32
33
  export * from "./url-normalization.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The `<no_response/>` sentinel: the cross-service convention for a turn
3
+ * that deliberately produces no user-visible reply.
4
+ *
5
+ * The model emits it, the daemon stamps and strips it, channel delivery
6
+ * suppresses it, and clients hold live-streamed prefixes of it back from
7
+ * display. One definition here keeps every consumer's parsing identical;
8
+ * a hand-rolled copy in any one of them is how the case-sensitivity of a
9
+ * regex drifts.
10
+ */
11
+
12
+ /** Matches a message whose entire content is the sentinel. */
13
+ const NO_RESPONSE_ONLY_RE = /^\s*<no_response\s*\/?>\s*$/i;
14
+
15
+ /**
16
+ * Whether `text` is nothing but the sentinel: the whole reply is a
17
+ * deliberate non-response, as opposed to real content with an inline
18
+ * sentinel mixed in.
19
+ */
20
+ export function isNoResponseOnlyText(text: string): boolean {
21
+ return NO_RESPONSE_ONLY_RE.test(text);
22
+ }
23
+
24
+ /** Matches every sentinel occurrence for stripping it out of mixed content. */
25
+ export const NO_RESPONSE_INLINE_RE = /<no_response\s*\/?>/gi;
26
+
27
+ /**
28
+ * Detection variant without the `g` flag: a `g`-flagged regex is stateful
29
+ * under `.test()` (it resumes from `lastIndex`), so reusing
30
+ * {@link NO_RESPONSE_INLINE_RE} for detection would alternate between
31
+ * matches and misses across calls.
32
+ */
33
+ const NO_RESPONSE_MARKER_RE = new RegExp(NO_RESPONSE_INLINE_RE.source, "i");
34
+
35
+ /** Whether `text` contains the sentinel anywhere. */
36
+ export function containsNoResponseMarker(text: string): boolean {
37
+ return NO_RESPONSE_MARKER_RE.test(text);
38
+ }
39
+
40
+ /** Removes every sentinel occurrence, trimming the leftover whitespace. */
41
+ export function stripNoResponseMarkers(text: string): string {
42
+ return text.replace(NO_RESPONSE_INLINE_RE, "").trim();
43
+ }
44
+
45
+ const NO_RESPONSE_SENTINEL_FORMS = [
46
+ "<no_response/>",
47
+ "<no_response />",
48
+ "<no_response>",
49
+ ] as const;
50
+
51
+ /**
52
+ * Whether `text` could still grow into the sentinel, i.e. it is a leading
53
+ * substring of one of its forms. Holding on these keeps a slowly-streamed
54
+ * `<no_response/>` from surfacing as visible partial content.
55
+ */
56
+ export function isPotentialNoResponsePrefix(text: string): boolean {
57
+ const lower = text.trim().toLowerCase();
58
+ if (lower.length === 0) {
59
+ return false;
60
+ }
61
+ return NO_RESPONSE_SENTINEL_FORMS.some((sentinel) =>
62
+ sentinel.startsWith(lower),
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Whether `text` carries user-visible content worth acting on now. Returns
68
+ * `false` for empty text, the standalone sentinel, and any prefix that could
69
+ * still complete into one; returns `true` once real content remains after
70
+ * stripping inline sentinels.
71
+ */
72
+ export function hasDeliverableAssistantText(text: string): boolean {
73
+ const trimmed = text.trim();
74
+ if (trimmed.length === 0) {
75
+ return false;
76
+ }
77
+ if (NO_RESPONSE_ONLY_RE.test(trimmed)) {
78
+ return false;
79
+ }
80
+ if (isPotentialNoResponsePrefix(trimmed)) {
81
+ return false;
82
+ }
83
+ return stripNoResponseMarkers(trimmed).length > 0;
84
+ }
@@ -1,8 +1,19 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
+ import {
4
+ DAEMON_READINESS_WINDOW_MS,
5
+ DAEMON_STOP_TIMEOUT_MS,
6
+ HOST_WRAPPER_LONG_HEADROOM_MS,
7
+ } from "./lifecycle-budgets";
3
8
  import type { CliInvocation } from "./util";
4
9
 
5
- const HATCH_TIMEOUT_MS = 120_000;
10
+ // A hatch whose daemon comes up but never becomes ready spends the readiness
11
+ // window first, then rolls back through the full daemon stop, so the wrapper
12
+ // has to cover both plus the gateway and CES cleanup that follows.
13
+ const HATCH_TIMEOUT_MS =
14
+ DAEMON_READINESS_WINDOW_MS +
15
+ DAEMON_STOP_TIMEOUT_MS +
16
+ HOST_WRAPPER_LONG_HEADROOM_MS;
6
17
  // Docker hatches pull images and wait up to 5 min for container readiness.
7
18
  const DOCKER_HATCH_TIMEOUT_MS = 10 * 60 * 1000;
8
19
 
@@ -85,7 +96,11 @@ export function runHatch(
85
96
  });
86
97
 
87
98
  child.on("error", (err) => {
88
- finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
99
+ finish({
100
+ ok: false,
101
+ status: 500,
102
+ error: `Failed to spawn CLI: ${err.message}`,
103
+ });
89
104
  });
90
105
  });
91
106
  }
@@ -17,6 +17,11 @@ export {
17
17
  resolveDevCliInvocation,
18
18
  } from "./util";
19
19
  export type { CliInvocation } from "./util";
20
+ export {
21
+ DAEMON_STOP_TIMEOUT_MS,
22
+ HOST_WRAPPER_HEADROOM_MS,
23
+ HOST_WRAPPER_LONG_HEADROOM_MS,
24
+ } from "./lifecycle-budgets";
20
25
  export {
21
26
  resolveLocalConfigFromEnv,
22
27
  resolveLockfilePaths,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Timeouts shared between the CLI's lifecycle commands and the host wrappers
3
+ * that spawn them.
4
+ *
5
+ * A host wrapper kills the CLI subprocess when its own timeout expires, so
6
+ * every wrapper timeout must sit above the budget of the command it runs.
7
+ * Otherwise a slow-but-succeeding command is killed partway and misreported as
8
+ * a failure, and the SIGKILL ceiling it was waiting on is never reached.
9
+ * Deriving the wrapper timeouts from the same constant keeps the two from
10
+ * drifting apart.
11
+ */
12
+
13
+ /**
14
+ * SIGKILL ceiling for stopping the assistant daemon.
15
+ *
16
+ * Derived from the daemon's own budget rather than guessed. Its shutdown arms
17
+ * a 30s force-exit timer; when that fires it hands the SQLite WAL fold to a
18
+ * detached subprocess that outlives `process.exit` and then exits. So a daemon
19
+ * whose event loop still runs is gone within 30s with its WAL folded, and a
20
+ * daemon whose event loop is blocked never fires that timer at all, which no
21
+ * amount of extra waiting changes. Waiting past the daemon's own ceiling plus
22
+ * a margin therefore buys nothing and only delays the SIGKILL.
23
+ *
24
+ * A ceiling, not a delay. The CLI returns as soon as the process exits, so
25
+ * this only applies to a daemon that is genuinely wedged.
26
+ */
27
+ export const DAEMON_STOP_TIMEOUT_MS = 45_000;
28
+
29
+ /**
30
+ * Headroom a host wrapper allows on top of {@link DAEMON_STOP_TIMEOUT_MS} for
31
+ * the rest of a command: the gateway's drain window, the CES and Qdrant stops,
32
+ * and any archiving or bring-up the command does around them.
33
+ */
34
+ export const HOST_WRAPPER_HEADROOM_MS = 30_000;
35
+
36
+ /** Headroom for commands that also archive or provision after stopping. */
37
+ export const HOST_WRAPPER_LONG_HEADROOM_MS = 60_000;
38
+
39
+ /**
40
+ * How long the CLI polls a freshly started daemon for readiness before giving
41
+ * up on it.
42
+ *
43
+ * A command that starts a daemon spends this before it can even discover it
44
+ * needs to stop one, so its wrapper budget has to cover the wait and the stop
45
+ * that may follow it, not just the stop.
46
+ */
47
+ export const DAEMON_READINESS_WINDOW_MS = 60_000;