@vellumai/cli 0.11.8 → 0.11.9-staging.2
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
|
@@ -182,12 +182,48 @@ export function renameLockfileAssistantIfPresent(
|
|
|
182
182
|
assistantId: string,
|
|
183
183
|
name: string,
|
|
184
184
|
): WriteResult {
|
|
185
|
-
if (typeof assistantId !== "string" || assistantId === "") {
|
|
186
|
-
return { ok: false, status: 400, error: "Missing assistantId" };
|
|
187
|
-
}
|
|
188
185
|
if (typeof name !== "string" || name === "") {
|
|
189
186
|
return { ok: false, status: 400, error: "Missing name" };
|
|
190
187
|
}
|
|
188
|
+
return patchLockfileAssistantIfPresent(lockfilePaths, assistantId, { name });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Record when an assistant finished first-run onboarding. Update-only, on the
|
|
193
|
+
* same terms as {@link renameLockfileAssistantIfPresent}. Keeps an existing
|
|
194
|
+
* stamp: the first completion is the real one, and a replayed funnel must not
|
|
195
|
+
* move it.
|
|
196
|
+
*/
|
|
197
|
+
export function stampLockfileAssistantOnboardedIfPresent(
|
|
198
|
+
lockfilePaths: string[],
|
|
199
|
+
assistantId: string,
|
|
200
|
+
onboardedAt: string,
|
|
201
|
+
): WriteResult {
|
|
202
|
+
if (typeof onboardedAt !== "string" || onboardedAt === "") {
|
|
203
|
+
return { ok: false, status: 400, error: "Missing onboardedAt" };
|
|
204
|
+
}
|
|
205
|
+
return patchLockfileAssistantIfPresent(
|
|
206
|
+
lockfilePaths,
|
|
207
|
+
assistantId,
|
|
208
|
+
{ onboardedAt },
|
|
209
|
+
(entry) => typeof entry.onboardedAt === "string" && entry.onboardedAt !== "",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The shared update-only write: read the on-disk registry under the lock,
|
|
215
|
+
* refuse a missing entry, and merge `patch` into it. `skip` short-circuits to
|
|
216
|
+
* a successful no-op for a field whose current value should win.
|
|
217
|
+
*/
|
|
218
|
+
function patchLockfileAssistantIfPresent(
|
|
219
|
+
lockfilePaths: string[],
|
|
220
|
+
assistantId: string,
|
|
221
|
+
patch: Record<string, unknown>,
|
|
222
|
+
skip?: (entry: Record<string, unknown>) => boolean,
|
|
223
|
+
): WriteResult {
|
|
224
|
+
if (typeof assistantId !== "string" || assistantId === "") {
|
|
225
|
+
return { ok: false, status: 400, error: "Missing assistantId" };
|
|
226
|
+
}
|
|
191
227
|
|
|
192
228
|
const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
|
|
193
229
|
const read = readRawLockfileStrict(lockfilePaths);
|
|
@@ -206,10 +242,14 @@ export function renameLockfileAssistantIfPresent(
|
|
|
206
242
|
error: "No lockfile entry for this assistant",
|
|
207
243
|
};
|
|
208
244
|
}
|
|
209
|
-
|
|
245
|
+
const entry = assistants[idx]!;
|
|
246
|
+
const converged =
|
|
247
|
+
skip?.(entry) ??
|
|
248
|
+
Object.entries(patch).every(([key, value]) => entry[key] === value);
|
|
249
|
+
if (converged) {
|
|
210
250
|
return { ok: true, lockfile: toWireLockfile(lockfile) };
|
|
211
251
|
}
|
|
212
|
-
assistants[idx] = { ...
|
|
252
|
+
assistants[idx] = { ...entry, ...patch };
|
|
213
253
|
lockfile.assistants = assistants;
|
|
214
254
|
return writeRawLockfile(lockfilePaths, lockfile);
|
|
215
255
|
});
|
|
@@ -354,7 +394,24 @@ export function replacePlatformAssistants(
|
|
|
354
394
|
if (syncedIds.has(a.assistantId)) return false;
|
|
355
395
|
return organizationId != null && a.organizationId !== organizationId;
|
|
356
396
|
});
|
|
357
|
-
|
|
397
|
+
// `onboardedAt` is recorded by the client and unknown to the platform, so
|
|
398
|
+
// the replacement rows never carry it. Without this it is erased by every
|
|
399
|
+
// routine session sync.
|
|
400
|
+
const priorOnboardedAt = new Map<string, unknown>();
|
|
401
|
+
for (const a of existing as Array<Record<string, unknown>>) {
|
|
402
|
+
if (typeof a?.assistantId === "string" && a.onboardedAt != null) {
|
|
403
|
+
priorOnboardedAt.set(a.assistantId, a.onboardedAt);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
lockfile.assistants = [
|
|
407
|
+
...preserved,
|
|
408
|
+
...platformAssistants.map((a) => {
|
|
409
|
+
const prior = priorOnboardedAt.get(a.assistantId as string);
|
|
410
|
+
return a.onboardedAt == null && prior != null
|
|
411
|
+
? { ...a, onboardedAt: prior }
|
|
412
|
+
: a;
|
|
413
|
+
}),
|
|
414
|
+
];
|
|
358
415
|
|
|
359
416
|
const active = lockfile.activeAssistant as string | null;
|
|
360
417
|
if (active) {
|
|
@@ -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>;
|
|
@@ -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
|
+
>;
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import * as realPlatformClient from "../lib/platform-client.js";
|
|
4
|
+
|
|
5
|
+
const calls: string[] = [];
|
|
6
|
+
let verifyResult: "valid" | "rejected" | "unknown" | null = "valid";
|
|
7
|
+
let readResult: { value: string | null; unreachable: boolean } = {
|
|
8
|
+
value: "stored-key",
|
|
9
|
+
unreachable: false,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
mock.module("../lib/platform-client.js", () => ({
|
|
13
|
+
...realPlatformClient,
|
|
14
|
+
verifyGatewayManagedCredential: mock(async () => {
|
|
15
|
+
calls.push("verify");
|
|
16
|
+
return verifyResult;
|
|
17
|
+
}),
|
|
18
|
+
readGatewayCredential: mock(async () => {
|
|
19
|
+
calls.push("read");
|
|
20
|
+
return readResult;
|
|
21
|
+
}),
|
|
22
|
+
reprovisionAssistantApiKey: mock(async () => {
|
|
23
|
+
calls.push("reprovision");
|
|
24
|
+
return { provisioning: { assistant_api_key: "fresh-key" } };
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { resolveAssistantApiKeyForInjection } =
|
|
29
|
+
await import("../lib/assistant-api-key-resolution.js");
|
|
30
|
+
|
|
31
|
+
const ARGS = {
|
|
32
|
+
registrationApiKey: null,
|
|
33
|
+
runtimeUrl: "http://localhost:20101",
|
|
34
|
+
bearerToken: "actor",
|
|
35
|
+
token: "session",
|
|
36
|
+
organizationId: "org",
|
|
37
|
+
clientInstallationId: "install",
|
|
38
|
+
runtimeAssistantId: "qa-loopback-auth",
|
|
39
|
+
clientPlatform: "cli",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
calls.length = 0;
|
|
44
|
+
verifyResult = "valid";
|
|
45
|
+
readResult = { value: "stored-key", unreachable: false };
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("resolveAssistantApiKeyForInjection", () => {
|
|
49
|
+
test("a key the registration just issued wins without asking the gateway", async () => {
|
|
50
|
+
const resolved = await resolveAssistantApiKeyForInjection({
|
|
51
|
+
...ARGS,
|
|
52
|
+
registrationApiKey: "registration-key",
|
|
53
|
+
});
|
|
54
|
+
expect(resolved).toEqual({
|
|
55
|
+
apiKey: "registration-key",
|
|
56
|
+
source: "registration",
|
|
57
|
+
});
|
|
58
|
+
expect(calls).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// The verdict comes first and the key is read after it, so the value
|
|
62
|
+
// returned is the one the verdict covered, never one a concurrent repair
|
|
63
|
+
// has since replaced.
|
|
64
|
+
test("a stored key the platform accepts is read after the verdict", async () => {
|
|
65
|
+
const resolved = await resolveAssistantApiKeyForInjection(ARGS);
|
|
66
|
+
expect(resolved).toEqual({ apiKey: "stored-key", source: "stored" });
|
|
67
|
+
expect(calls).toEqual(["verify", "read"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a stored key the platform rejects is replaced without being read", async () => {
|
|
71
|
+
verifyResult = "rejected";
|
|
72
|
+
const resolved = await resolveAssistantApiKeyForInjection(ARGS);
|
|
73
|
+
expect(resolved).toEqual({ apiKey: "fresh-key", source: "reprovisioned" });
|
|
74
|
+
expect(calls).toEqual(["verify", "reprovision"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// An unsettled answer is not a rejection: the stored key stays in use.
|
|
78
|
+
test("an unsettled verdict keeps the stored key", async () => {
|
|
79
|
+
verifyResult = "unknown";
|
|
80
|
+
const resolved = await resolveAssistantApiKeyForInjection(ARGS);
|
|
81
|
+
expect(resolved).toEqual({ apiKey: "stored-key", source: "stored" });
|
|
82
|
+
expect(calls).not.toContain("reprovision");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Rotating while the gateway is down would start the grace clock on a key
|
|
86
|
+
// the assistant still needs, with nowhere to store the replacement.
|
|
87
|
+
test("an unreachable gateway withholds rotation", async () => {
|
|
88
|
+
verifyResult = null;
|
|
89
|
+
readResult = { value: null, unreachable: true };
|
|
90
|
+
const resolved = await resolveAssistantApiKeyForInjection(ARGS);
|
|
91
|
+
expect(resolved).toEqual({ apiKey: null, source: "unavailable" });
|
|
92
|
+
expect(calls).not.toContain("reprovision");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a reachable assistant with no key stored is provisioned one", async () => {
|
|
96
|
+
verifyResult = "unknown";
|
|
97
|
+
readResult = { value: null, unreachable: false };
|
|
98
|
+
const resolved = await resolveAssistantApiKeyForInjection(ARGS);
|
|
99
|
+
expect(resolved).toEqual({ apiKey: "fresh-key", source: "reprovisioned" });
|
|
100
|
+
expect(calls).toEqual(["verify", "read", "reprovision"]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -21,10 +21,14 @@ describe("constants", () => {
|
|
|
21
21
|
|
|
22
22
|
test("VALID_REMOTE_HOSTS includes expected hosts", () => {
|
|
23
23
|
expect(VALID_REMOTE_HOSTS).toContain("local");
|
|
24
|
-
expect(VALID_REMOTE_HOSTS).toContain("gcp");
|
|
25
|
-
expect(VALID_REMOTE_HOSTS).toContain("aws");
|
|
26
24
|
expect(VALID_REMOTE_HOSTS).toContain("docker");
|
|
27
25
|
expect(VALID_REMOTE_HOSTS).toContain("custom");
|
|
26
|
+
expect(VALID_REMOTE_HOSTS).toContain("vellum");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("VALID_REMOTE_HOSTS excludes cloud provisioning targets", () => {
|
|
30
|
+
expect(VALID_REMOTE_HOSTS).not.toContain("gcp");
|
|
31
|
+
expect(VALID_REMOTE_HOSTS).not.toContain("aws");
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
test("VALID_SPECIES includes expected species", () => {
|