@vellumai/cli 0.11.8-staging.2 → 0.11.9-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.
- package/README.md +6 -7
- package/knip.json +0 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token-refresh.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/__tests__/helpers/child-process-mock.ts +55 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/retire.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/__tests__/upgrade.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/devices.test.ts +5 -8
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +6 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +165 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +63 -6
- package/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-api-key-resolution.test.ts +102 -0
- package/src/__tests__/constants.test.ts +6 -2
- package/src/commands/hatch.ts +1 -367
- package/src/commands/login.ts +16 -29
- package/src/commands/teleport.ts +14 -26
- package/src/lib/assistant-api-key-resolution.ts +89 -0
- package/src/lib/aws.ts +0 -593
- package/src/lib/constants.ts +0 -2
- package/src/lib/gcp.ts +3 -671
- package/src/lib/platform-client.ts +58 -5
- package/src/__tests__/ssh-user-guard.test.ts +0 -28
- package/src/adapters/openclaw-http-server.ts +0 -209
- package/src/adapters/openclaw.ts +0 -131
- package/src/lib/openclaw-runtime-server.ts +0 -28
package/README.md
CHANGED
|
@@ -54,18 +54,17 @@ vellum hatch [species] [options]
|
|
|
54
54
|
|
|
55
55
|
#### Options
|
|
56
56
|
|
|
57
|
-
| Option | Description
|
|
58
|
-
| ------------------- |
|
|
59
|
-
| `-d` | Detached mode. Start the instance in the background without watching startup progress.
|
|
60
|
-
| `--name <name>` | Use a specific instance name instead of an auto-generated one.
|
|
61
|
-
| `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `
|
|
57
|
+
| Option | Description |
|
|
58
|
+
| ------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `-d` | Detached mode. Start the instance in the background without watching startup progress. |
|
|
60
|
+
| `--name <name>` | Use a specific instance name instead of an auto-generated one. |
|
|
61
|
+
| `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `custom`. Defaults to `local`. |
|
|
62
62
|
|
|
63
63
|
#### Remote Targets
|
|
64
64
|
|
|
65
65
|
- **`local`** -- Starts the local assistant and local gateway. Gateway source resolution order is: repo source tree, then installed `@vellumai/vellum-gateway` package.
|
|
66
66
|
- **`docker`** -- Starts the assistant, gateway, and credential service in Docker containers.
|
|
67
67
|
- **`vellum`** -- Hatches an assistant on the Vellum platform.
|
|
68
|
-
- **`gcp`** and **`aws`** -- Recognized but not supported as provisioning targets yet. The CLI exits before creating cloud resources. To self-host on AWS/GCP, SSH into the VM and run `vellum hatch` or `vellum hatch --remote docker` there.
|
|
69
68
|
- **`custom`** -- Recognized but not yet implemented.
|
|
70
69
|
|
|
71
70
|
#### Environment Variables
|
|
@@ -87,7 +86,7 @@ vellum hatch --remote docker
|
|
|
87
86
|
vellum hatch --name my-assistant --remote docker
|
|
88
87
|
```
|
|
89
88
|
|
|
90
|
-
|
|
89
|
+
To self-host on a cloud VM (AWS, GCP, or any other provider), SSH into the machine and run `vellum hatch` or `vellum hatch --remote docker` from inside it. The CLI does not provision cloud instances for you.
|
|
91
90
|
|
|
92
91
|
### `terminal`
|
|
93
92
|
|
package/knip.json
CHANGED
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
"./redacted-credential": "./src/redacted-credential.ts",
|
|
23
23
|
"./secret-detection": "./src/secret-detection.ts",
|
|
24
24
|
"./stripe-currency": "./src/stripe-currency.ts",
|
|
25
|
-
"./error": "./src/error.ts"
|
|
25
|
+
"./error": "./src/error.ts",
|
|
26
|
+
"./reactions": "./src/reactions.ts",
|
|
27
|
+
"./guardian-requests": "./src/guardian-requests.ts",
|
|
28
|
+
"./platform-credential": "./src/platform-credential.ts"
|
|
26
29
|
},
|
|
27
30
|
"scripts": {
|
|
28
31
|
"typecheck": "bunx tsc --noEmit",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical vocabulary for a guardian request's lifecycle status, shared by
|
|
3
|
+
* the gateway that owns the request rows, the daemon that decides and
|
|
4
|
+
* projects them, and the web that renders the projection.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
export const GUARDIAN_REQUEST_STATUS_VALUES = [
|
|
9
|
+
"pending",
|
|
10
|
+
"approved",
|
|
11
|
+
"denied",
|
|
12
|
+
"expired",
|
|
13
|
+
"cancelled",
|
|
14
|
+
] as const;
|
|
15
|
+
export const GuardianRequestStatusSchema = z.enum(
|
|
16
|
+
GUARDIAN_REQUEST_STATUS_VALUES,
|
|
17
|
+
);
|
|
18
|
+
export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export * from "./rpc.js";
|
|
|
28
28
|
export * from "./trust-rules.js";
|
|
29
29
|
export * from "./ingress.js";
|
|
30
30
|
export * from "./no-response.js";
|
|
31
|
+
export * from "./platform-credential.js";
|
|
31
32
|
export * from "./remote-web-pairing.js";
|
|
32
33
|
export * from "./twilio-ingress.js";
|
|
33
34
|
export * from "./url-normalization.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire contract for asking an assistant whether its stored platform-managed
|
|
3
|
+
* credential still authenticates.
|
|
4
|
+
*
|
|
5
|
+
* The daemon route is the authoritative serving side:
|
|
6
|
+
* - `POST /v1/platform/verify-credential`
|
|
7
|
+
* (`assistant/src/runtime/routes/platform-routes.ts`)
|
|
8
|
+
*
|
|
9
|
+
* The daemon's platform client produces the verdict, the route reports it,
|
|
10
|
+
* and the `vellum` CLI reads it before deciding whether a stored key can be
|
|
11
|
+
* re-injected (`cli/src/lib/assistant-api-key-resolution.ts`), so all three
|
|
12
|
+
* share one definition and cannot silently drift. The web app reads the same
|
|
13
|
+
* shape through its generated daemon client.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the platform said about the stored credential, right now.
|
|
19
|
+
*
|
|
20
|
+
* - `valid`: the platform accepted it.
|
|
21
|
+
* - `rejected`: the platform refused it (unauthorized or forbidden); the
|
|
22
|
+
* credential needs replacing.
|
|
23
|
+
* - `unknown`: the check itself could not run (no credential stored, the
|
|
24
|
+
* platform unreachable, a server error). Not evidence either way.
|
|
25
|
+
*/
|
|
26
|
+
export const PlatformCredentialVerificationStatusSchema = z.enum([
|
|
27
|
+
"valid",
|
|
28
|
+
"rejected",
|
|
29
|
+
"unknown",
|
|
30
|
+
]);
|
|
31
|
+
export type PlatformCredentialVerificationStatus = z.infer<
|
|
32
|
+
typeof PlatformCredentialVerificationStatusSchema
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
/** `POST /v1/platform/verify-credential` response body. */
|
|
36
|
+
export const PlatformVerifyCredentialResponseSchema = z.object({
|
|
37
|
+
status: PlatformCredentialVerificationStatusSchema,
|
|
38
|
+
});
|
|
39
|
+
export type PlatformVerifyCredentialResponse = z.infer<
|
|
40
|
+
typeof PlatformVerifyCredentialResponseSchema
|
|
41
|
+
>;
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical vocabulary for a reaction's emoji, shared by every service that
|
|
3
|
+
* handles one: the gateway that normalizes it, the daemon that stores and
|
|
4
|
+
* projects it, and the web that renders it.
|
|
5
|
+
*
|
|
6
|
+
* The kind is said by the channel rather than inferred from how the emoji
|
|
7
|
+
* is spelled. Modelled on Zulip's `reaction_type`, the one surveyed system
|
|
8
|
+
* that separates the namespace from the name.
|
|
9
|
+
*
|
|
10
|
+
* `shortcode` is a name in a channel's own namespace whose kind the channel
|
|
11
|
+
* does not disclose: Slack sends `+1` for the standard emoji and `blob_wave`
|
|
12
|
+
* for a workspace upload with nothing to tell them apart, and only the
|
|
13
|
+
* workspace token can resolve the second. It is a distinct kind from
|
|
14
|
+
* `unicode`, not a stand-in for an unknown one.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
export const REACTION_EMOJI_KINDS = ["unicode", "shortcode", "custom"] as const;
|
|
19
|
+
export type ReactionEmojiKind = (typeof REACTION_EMOJI_KINDS)[number];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The typed emoji fields every schema that carries a reaction spreads in,
|
|
23
|
+
* so the wire contract, the stored envelopes, and the response projection
|
|
24
|
+
* describe one shape. Optional throughout: a persisted row or a replayed
|
|
25
|
+
* payload may carry only the spelling.
|
|
26
|
+
*/
|
|
27
|
+
export const ReactionEmojiFieldsSchema = z.object({
|
|
28
|
+
/** Which namespace the emoji was drawn from. */
|
|
29
|
+
emojiKind: z.enum(REACTION_EMOJI_KINDS).optional(),
|
|
30
|
+
/**
|
|
31
|
+
* The emoji's name in that namespace: the character itself for `unicode`,
|
|
32
|
+
* the bare name for `shortcode` and `custom`. Never a mention form.
|
|
33
|
+
*/
|
|
34
|
+
emojiName: z.string().optional(),
|
|
35
|
+
/** The channel's id for a `custom` emoji, absent for every other kind. */
|
|
36
|
+
emojiId: z.string().optional(),
|
|
37
|
+
/** Whether a `custom` emoji animates. Absent for every other kind. */
|
|
38
|
+
emojiAnimated: z.boolean().optional(),
|
|
39
|
+
});
|
|
40
|
+
export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The typed emoji fields a source actually carries, with undefined ones
|
|
44
|
+
* omitted: an absent key and a present-but-undefined one serialize alike, but the
|
|
45
|
+
* stored envelope and the response should carry only what was declared. Every writer of a reaction shape (the wire
|
|
46
|
+
* payload, both stored envelopes, the response projection) copies the
|
|
47
|
+
* fields through this rather than restating the four-way pick.
|
|
48
|
+
*/
|
|
49
|
+
export function pickReactionEmojiFields(
|
|
50
|
+
source: ReactionEmojiFields,
|
|
51
|
+
): ReactionEmojiFields {
|
|
52
|
+
return {
|
|
53
|
+
...(source.emojiKind !== undefined ? { emojiKind: source.emojiKind } : {}),
|
|
54
|
+
...(source.emojiName !== undefined ? { emojiName: source.emojiName } : {}),
|
|
55
|
+
...(source.emojiId !== undefined ? { emojiId: source.emojiId } : {}),
|
|
56
|
+
...(source.emojiAnimated !== undefined
|
|
57
|
+
? { emojiAnimated: source.emojiAnimated }
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
2
|
import fs from "node:fs";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
|
|
7
6
|
import type { CliInvocation } from "../util";
|
|
8
7
|
import type { GuardianTokenData } from "../guardian-token";
|
|
9
|
-
|
|
10
|
-
class FakeChild extends EventEmitter {
|
|
11
|
-
stdout = new EventEmitter();
|
|
12
|
-
stderr = new EventEmitter();
|
|
13
|
-
kill = mock(() => true);
|
|
14
|
-
}
|
|
8
|
+
import { FakeChild, mockChildProcessSpawn } from "./helpers/child-process-mock";
|
|
15
9
|
|
|
16
10
|
let lastChild: FakeChild;
|
|
17
11
|
const spawnMock = mock((_command: string, _args: string[]) => {
|
|
@@ -19,7 +13,7 @@ const spawnMock = mock((_command: string, _args: string[]) => {
|
|
|
19
13
|
return lastChild;
|
|
20
14
|
});
|
|
21
15
|
|
|
22
|
-
|
|
16
|
+
await mockChildProcessSpawn(spawnMock);
|
|
23
17
|
|
|
24
18
|
let getGuardianAccessToken: typeof import("../guardian-token").getGuardianAccessToken;
|
|
25
19
|
let saveGuardianToken: typeof import("../guardian-token").saveGuardianToken;
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
2
|
|
|
4
3
|
import type { CliInvocation } from "../util";
|
|
5
|
-
|
|
6
|
-
class FakeChild extends EventEmitter {
|
|
7
|
-
stdout = new EventEmitter();
|
|
8
|
-
stderr = new EventEmitter();
|
|
9
|
-
kill = mock(() => true);
|
|
10
|
-
}
|
|
4
|
+
import { FakeChild, mockChildProcessSpawn } from "./helpers/child-process-mock";
|
|
11
5
|
|
|
12
6
|
let lastChild: FakeChild;
|
|
13
7
|
const spawnArgs: Array<[string, string[]]> = [];
|
|
@@ -17,7 +11,7 @@ const spawnMock = mock((command: string, args: string[]) => {
|
|
|
17
11
|
return lastChild;
|
|
18
12
|
});
|
|
19
13
|
|
|
20
|
-
|
|
14
|
+
await mockChildProcessSpawn(spawnMock);
|
|
21
15
|
|
|
22
16
|
let runHatch: typeof import("../hatch").runHatch;
|
|
23
17
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { mock } from "bun:test";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stand-in for the `ChildProcess` a mocked `spawn` returns. Tests drive it by
|
|
6
|
+
* emitting `"close"` / `"error"` on the instance and `"data"` on its streams.
|
|
7
|
+
*/
|
|
8
|
+
export class FakeChild extends EventEmitter {
|
|
9
|
+
stdout = new EventEmitter();
|
|
10
|
+
stderr = new EventEmitter();
|
|
11
|
+
kill = mock(() => true);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Any stand-in for `spawn`; each suite records the arguments it asserts on. */
|
|
15
|
+
type SpawnReplacement = (...args: never[]) => unknown;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Replace `spawn` in `node:child_process` while leaving every other export of
|
|
19
|
+
* the module intact.
|
|
20
|
+
*
|
|
21
|
+
* `mock.module()` is process-global, and a registration that lands before
|
|
22
|
+
* anything in the process has loaded `node:child_process` installs the
|
|
23
|
+
* factory's object as the entire module. A factory returning only `{ spawn }`
|
|
24
|
+
* therefore strips `spawnSync` and the rest for every module linked
|
|
25
|
+
* afterwards. `src/lockfile-lock.test.ts` imports `spawnSync` at module scope
|
|
26
|
+
* and fails to link with "Export named 'spawnSync' not found in module
|
|
27
|
+
* 'node:child_process'" once that happens, and since bun runs a package's test
|
|
28
|
+
* files in one process, whether it happens depends on file ordering.
|
|
29
|
+
*
|
|
30
|
+
* Two things keep the namespace whole:
|
|
31
|
+
*
|
|
32
|
+
* 1. Awaiting the real module materializes it before `mock.module()`
|
|
33
|
+
* registers. Bun patches that live namespace in place, so the exports the
|
|
34
|
+
* factory does not name keep their real values. This is the property that
|
|
35
|
+
* prevents the link error.
|
|
36
|
+
* 2. The factory returns a plain copy of the namespace captured before
|
|
37
|
+
* registration, so the replacement is complete on its own terms and never
|
|
38
|
+
* reads back through the namespace being patched. Spreading a live,
|
|
39
|
+
* already-mocked namespace inside a factory is the shape that
|
|
40
|
+
* `cli/src/__tests__/helpers/os-mock.ts` documents as recursing forever.
|
|
41
|
+
*
|
|
42
|
+
* Every `node:child_process` spawn mock in this package routes through here so
|
|
43
|
+
* both hold for all of them at once. Call it once at module scope, with
|
|
44
|
+
* `await`, before the module under test is imported.
|
|
45
|
+
*/
|
|
46
|
+
export async function mockChildProcessSpawn(
|
|
47
|
+
spawnMock: SpawnReplacement,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const realChildProcess = await import("node:child_process");
|
|
50
|
+
const realChildProcessSnapshot = { ...realChildProcess };
|
|
51
|
+
mock.module("node:child_process", () => ({
|
|
52
|
+
...realChildProcessSnapshot,
|
|
53
|
+
spawn: spawnMock,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
2
|
|
|
4
3
|
import type { CliInvocation } from "../util";
|
|
5
|
-
|
|
6
|
-
class FakeChild extends EventEmitter {
|
|
7
|
-
stdout = new EventEmitter();
|
|
8
|
-
stderr = new EventEmitter();
|
|
9
|
-
kill = mock(() => true);
|
|
10
|
-
}
|
|
4
|
+
import { FakeChild, mockChildProcessSpawn } from "./helpers/child-process-mock";
|
|
11
5
|
|
|
12
6
|
let lastChild: FakeChild;
|
|
13
7
|
const spawnArgs: Array<
|
|
@@ -25,7 +19,7 @@ const spawnMock = mock(
|
|
|
25
19
|
},
|
|
26
20
|
);
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
await mockChildProcessSpawn(spawnMock);
|
|
29
23
|
|
|
30
24
|
let runRetire: typeof import("../retire").runRetire;
|
|
31
25
|
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
2
|
|
|
4
3
|
import type { CliInvocation } from "../util";
|
|
5
|
-
|
|
6
|
-
class FakeChild extends EventEmitter {
|
|
7
|
-
stdout = new EventEmitter();
|
|
8
|
-
stderr = new EventEmitter();
|
|
9
|
-
kill = mock(() => true);
|
|
10
|
-
}
|
|
4
|
+
import { FakeChild, mockChildProcessSpawn } from "./helpers/child-process-mock";
|
|
11
5
|
|
|
12
6
|
let lastChild: FakeChild;
|
|
13
7
|
const spawnArgs: Array<
|
|
@@ -25,7 +19,7 @@ const spawnMock = mock(
|
|
|
25
19
|
},
|
|
26
20
|
);
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
await mockChildProcessSpawn(spawnMock);
|
|
29
23
|
|
|
30
24
|
let runUpgrade: typeof import("../upgrade").runUpgrade;
|
|
31
25
|
let isValidReleaseVersion: typeof import("../upgrade").isValidReleaseVersion;
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
2
|
|
|
4
3
|
import type { CliInvocation } from "../util";
|
|
5
|
-
|
|
6
|
-
class FakeChild extends EventEmitter {
|
|
7
|
-
stdout = new EventEmitter();
|
|
8
|
-
stderr = new EventEmitter();
|
|
9
|
-
kill = mock(() => true);
|
|
10
|
-
}
|
|
4
|
+
import { FakeChild, mockChildProcessSpawn } from "./helpers/child-process-mock";
|
|
11
5
|
|
|
12
6
|
let lastChild: FakeChild;
|
|
13
7
|
const spawnArgs: Array<[string, string[]]> = [];
|
|
@@ -17,7 +11,7 @@ const spawnMock = mock((command: string, args: string[]) => {
|
|
|
17
11
|
return lastChild;
|
|
18
12
|
});
|
|
19
13
|
|
|
20
|
-
|
|
14
|
+
await mockChildProcessSpawn(spawnMock);
|
|
21
15
|
|
|
22
16
|
let runWake: typeof import("../wake").runWake;
|
|
23
17
|
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
2
|
|
|
4
3
|
import type { CliInvocation } from "./util";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
kill = mock(() => true);
|
|
10
|
-
}
|
|
4
|
+
import {
|
|
5
|
+
FakeChild,
|
|
6
|
+
mockChildProcessSpawn,
|
|
7
|
+
} from "./__tests__/helpers/child-process-mock";
|
|
11
8
|
|
|
12
9
|
let lastChild: FakeChild;
|
|
13
10
|
const spawnArgs: Array<[string, string[], { stdio?: unknown; windowsHide?: boolean }]> = [];
|
|
@@ -19,7 +16,7 @@ const spawnMock = mock(
|
|
|
19
16
|
},
|
|
20
17
|
);
|
|
21
18
|
|
|
22
|
-
|
|
19
|
+
await mockChildProcessSpawn(spawnMock);
|
|
23
20
|
|
|
24
21
|
let runDevicesList: typeof import("./devices").runDevicesList;
|
|
25
22
|
let runDevicesRevoke: typeof import("./devices").runDevicesRevoke;
|
|
@@ -179,6 +179,12 @@ export const LockfileAssistantSchema = z.object({
|
|
|
179
179
|
runtimeUrl: z.string().optional(),
|
|
180
180
|
species: z.string().optional(),
|
|
181
181
|
hatchedAt: z.string().optional(),
|
|
182
|
+
/**
|
|
183
|
+
* When this assistant finished first-run onboarding. Absent for entries that
|
|
184
|
+
* predate the record, which fall back to hatch age (see the web client's
|
|
185
|
+
* `onboarded-assistant.ts`).
|
|
186
|
+
*/
|
|
187
|
+
onboardedAt: z.string().optional(),
|
|
182
188
|
/** Owning org for platform assistants; absent for local ones. */
|
|
183
189
|
organizationId: z.string().optional(),
|
|
184
190
|
/** Platform assistant UUID for a self-hosted local assistant registration. */
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getLockfileData,
|
|
8
8
|
isPairedLockfileEntry,
|
|
9
9
|
renameLockfileAssistantIfPresent,
|
|
10
|
+
stampLockfileAssistantOnboardedIfPresent,
|
|
10
11
|
replacePlatformAssistants,
|
|
11
12
|
upsertLockfileAssistant,
|
|
12
13
|
upsertRendererLockfileAssistant,
|
|
@@ -280,6 +281,87 @@ describe("upsertLockfileAssistant", () => {
|
|
|
280
281
|
});
|
|
281
282
|
});
|
|
282
283
|
|
|
284
|
+
describe("stampLockfileAssistantOnboardedIfPresent", () => {
|
|
285
|
+
const entry = {
|
|
286
|
+
assistantId: "asst_1",
|
|
287
|
+
cloud: "local",
|
|
288
|
+
name: "Credence",
|
|
289
|
+
signingKey: "sk-on-disk-secret",
|
|
290
|
+
};
|
|
291
|
+
const AT = "2026-08-31T00:00:00.000Z";
|
|
292
|
+
|
|
293
|
+
test("stamps the entry preserving its other fields and activeAssistant", () => {
|
|
294
|
+
writeOnDisk({ activeAssistant: "asst_other", assistants: [entry] });
|
|
295
|
+
|
|
296
|
+
const result = stampLockfileAssistantOnboardedIfPresent(
|
|
297
|
+
[lockfilePath],
|
|
298
|
+
"asst_1",
|
|
299
|
+
AT,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
expect(result.ok).toBe(true);
|
|
303
|
+
const onDisk = readOnDisk();
|
|
304
|
+
expect(onDisk.activeAssistant).toBe("asst_other");
|
|
305
|
+
expect(onDisk.assistants).toEqual([{ ...entry, onboardedAt: AT }]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Update-only: a completion racing a CLI retire must not resurrect the row.
|
|
309
|
+
test("refuses a missing entry without writing the file", () => {
|
|
310
|
+
const result = stampLockfileAssistantOnboardedIfPresent(
|
|
311
|
+
[lockfilePath],
|
|
312
|
+
"asst_gone",
|
|
313
|
+
AT,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(result).toEqual({
|
|
317
|
+
ok: false,
|
|
318
|
+
status: 404,
|
|
319
|
+
error: "No lockfile entry for this assistant",
|
|
320
|
+
});
|
|
321
|
+
expect(fs.existsSync(lockfilePath)).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("keeps an existing stamp: the first completion is the real one", () => {
|
|
325
|
+
const stamped = { ...entry, onboardedAt: "2026-08-01T00:00:00.000Z" };
|
|
326
|
+
writeOnDisk({ activeAssistant: null, assistants: [stamped] });
|
|
327
|
+
|
|
328
|
+
const result = stampLockfileAssistantOnboardedIfPresent(
|
|
329
|
+
[lockfilePath],
|
|
330
|
+
"asst_1",
|
|
331
|
+
AT,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(result.ok).toBe(true);
|
|
335
|
+
expect(readOnDisk().assistants).toEqual([stamped]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("refuses a corrupt on-disk file without clobbering it", () => {
|
|
339
|
+
fs.writeFileSync(lockfilePath, "{ not json");
|
|
340
|
+
|
|
341
|
+
const result = stampLockfileAssistantOnboardedIfPresent(
|
|
342
|
+
[lockfilePath],
|
|
343
|
+
"asst_1",
|
|
344
|
+
AT,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
expect(result.ok).toBe(false);
|
|
348
|
+
if (!result.ok) {
|
|
349
|
+
expect(result.status).toBe(409);
|
|
350
|
+
}
|
|
351
|
+
expect(fs.readFileSync(lockfilePath, "utf-8")).toBe("{ not json");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("rejects a missing id or timestamp", () => {
|
|
355
|
+
expect(
|
|
356
|
+
stampLockfileAssistantOnboardedIfPresent([lockfilePath], "", AT).ok,
|
|
357
|
+
).toBe(false);
|
|
358
|
+
expect(
|
|
359
|
+
stampLockfileAssistantOnboardedIfPresent([lockfilePath], "asst_1", "").ok,
|
|
360
|
+
).toBe(false);
|
|
361
|
+
expect(fs.existsSync(lockfilePath)).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
283
365
|
describe("renameLockfileAssistantIfPresent", () => {
|
|
284
366
|
const entry = {
|
|
285
367
|
assistantId: "asst_1",
|
|
@@ -596,6 +678,89 @@ describe("replacePlatformAssistants", () => {
|
|
|
596
678
|
});
|
|
597
679
|
});
|
|
598
680
|
|
|
681
|
+
// `onboardedAt` is recorded by the client and unknown to the platform, so the
|
|
682
|
+
// rows a sync builds from the API never carry it. Dropping it here would let
|
|
683
|
+
// a routine session refresh erase the completion record.
|
|
684
|
+
test("carries an existing onboardedAt onto the replacement row", () => {
|
|
685
|
+
writeOnDisk({
|
|
686
|
+
activeAssistant: "asst_p",
|
|
687
|
+
assistants: [
|
|
688
|
+
{
|
|
689
|
+
assistantId: "asst_p",
|
|
690
|
+
cloud: "vellum",
|
|
691
|
+
runtimeUrl: "http://old",
|
|
692
|
+
onboardedAt: "2026-08-01T00:00:00.000Z",
|
|
693
|
+
},
|
|
694
|
+
],
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const result = replacePlatformAssistants(
|
|
698
|
+
[lockfilePath],
|
|
699
|
+
[{ assistantId: "asst_p", cloud: "vellum", runtimeUrl: "http://new" }],
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
expect(result.ok).toBe(true);
|
|
703
|
+
expect(readOnDisk().assistants).toEqual([
|
|
704
|
+
{
|
|
705
|
+
assistantId: "asst_p",
|
|
706
|
+
cloud: "vellum",
|
|
707
|
+
runtimeUrl: "http://new",
|
|
708
|
+
onboardedAt: "2026-08-01T00:00:00.000Z",
|
|
709
|
+
},
|
|
710
|
+
]);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("an incoming onboardedAt wins over the stored one", () => {
|
|
714
|
+
writeOnDisk({
|
|
715
|
+
activeAssistant: null,
|
|
716
|
+
assistants: [
|
|
717
|
+
{
|
|
718
|
+
assistantId: "asst_p",
|
|
719
|
+
cloud: "vellum",
|
|
720
|
+
onboardedAt: "2026-08-01T00:00:00.000Z",
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
replacePlatformAssistants(
|
|
726
|
+
[lockfilePath],
|
|
727
|
+
[
|
|
728
|
+
{
|
|
729
|
+
assistantId: "asst_p",
|
|
730
|
+
cloud: "vellum",
|
|
731
|
+
onboardedAt: "2026-08-31T00:00:00.000Z",
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
expect(
|
|
737
|
+
(readOnDisk().assistants as Array<Record<string, unknown>>)[0]
|
|
738
|
+
?.onboardedAt,
|
|
739
|
+
).toBe("2026-08-31T00:00:00.000Z");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("a retired-then-recreated id does not inherit a stale stamp from another entry", () => {
|
|
743
|
+
writeOnDisk({
|
|
744
|
+
activeAssistant: null,
|
|
745
|
+
assistants: [
|
|
746
|
+
{
|
|
747
|
+
assistantId: "asst_a",
|
|
748
|
+
cloud: "vellum",
|
|
749
|
+
onboardedAt: "2026-08-01T00:00:00.000Z",
|
|
750
|
+
},
|
|
751
|
+
],
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
replacePlatformAssistants(
|
|
755
|
+
[lockfilePath],
|
|
756
|
+
[{ assistantId: "asst_b", cloud: "vellum" }],
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
expect(readOnDisk().assistants).toEqual([
|
|
760
|
+
{ assistantId: "asst_b", cloud: "vellum" },
|
|
761
|
+
]);
|
|
762
|
+
});
|
|
763
|
+
|
|
599
764
|
test("replaces platform assistants while keeping local ones and unknown fields", () => {
|
|
600
765
|
writeOnDisk({
|
|
601
766
|
schemaVersion: 99,
|