@mycadet/assistant 0.13.0-dev.202611142107.f5e00a2 → 0.13.0-dev.202611151727.8310b2c
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/Dockerfile +1 -0
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +12 -0
- package/node_modules/@mycadet/ces-client/src/index.ts +0 -1
- package/node_modules/@mycadet/ces-client/src/socket-path.ts +1 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +12 -0
- package/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +12 -0
- package/node_modules/@mycadet/shared/seeds.ts +5 -22
- package/package.json +1 -1
- package/src/__tests__/conversation-slash-commands.test.ts +6 -0
- package/src/__tests__/dockerfile-workspace-packages.test.ts +39 -0
- package/src/credential-execution/client.test.ts +24 -9
- package/src/credential-execution/client.ts +3 -3
- package/src/credential-execution/process-manager.test.ts +7 -5
- package/src/credential-execution/process-manager.ts +1 -1
- package/src/security/ces-rpc-record-backend.ts +22 -64
package/Dockerfile
CHANGED
|
@@ -189,6 +189,7 @@ COPY packages/egress-proxy /app/packages/egress-proxy
|
|
|
189
189
|
COPY packages/environments /app/packages/environments
|
|
190
190
|
COPY packages/gateway-client /app/packages/gateway-client
|
|
191
191
|
COPY packages/ipc-server-utils /app/packages/ipc-server-utils
|
|
192
|
+
COPY packages/shared /app/packages/shared
|
|
192
193
|
COPY packages/slack-text /app/packages/slack-text
|
|
193
194
|
COPY packages/twilio-client /app/packages/twilio-client
|
|
194
195
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Wire contracts for the remote-web pairing flow — the RFC 8628-style
|
|
3
5
|
* device-code exchange that connects a browser (or the iOS app) to a
|
|
@@ -59,6 +61,16 @@ export interface RemoteWebPairingChallengeResponse {
|
|
|
59
61
|
intervalSeconds: number;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
export const RemoteWebPairingChallengeResponseSchema = z.object({
|
|
65
|
+
deviceCode: z.string(),
|
|
66
|
+
userCode: z.string(),
|
|
67
|
+
verificationUri: z.string(),
|
|
68
|
+
publicBaseUrl: z.string(),
|
|
69
|
+
expiresAt: z.string(),
|
|
70
|
+
expiresInSeconds: z.number(),
|
|
71
|
+
intervalSeconds: z.number(),
|
|
72
|
+
}) satisfies z.ZodType<RemoteWebPairingChallengeResponse>;
|
|
73
|
+
|
|
62
74
|
/** `POST /v1/remote-web/pairing-verification` request body. */
|
|
63
75
|
export interface RemoteWebPairingVerificationRequest {
|
|
64
76
|
/** The `userCode` from the challenge, entered/scanned on the host. */
|
|
@@ -18,7 +18,7 @@ export const CES_SOCKET_DIR_DEFAULT = "/run/ces-ipc";
|
|
|
18
18
|
* The directory holding `ces.sock`: `CES_SOCKET_DIR` when set, otherwise
|
|
19
19
|
* `/run/ces-ipc`.
|
|
20
20
|
*/
|
|
21
|
-
|
|
21
|
+
function getCesSocketDir(): string {
|
|
22
22
|
return process.env["CES_SOCKET_DIR"]?.trim() || CES_SOCKET_DIR_DEFAULT;
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Wire contracts for the remote-web pairing flow — the RFC 8628-style
|
|
3
5
|
* device-code exchange that connects a browser (or the iOS app) to a
|
|
@@ -59,6 +61,16 @@ export interface RemoteWebPairingChallengeResponse {
|
|
|
59
61
|
intervalSeconds: number;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
export const RemoteWebPairingChallengeResponseSchema = z.object({
|
|
65
|
+
deviceCode: z.string(),
|
|
66
|
+
userCode: z.string(),
|
|
67
|
+
verificationUri: z.string(),
|
|
68
|
+
publicBaseUrl: z.string(),
|
|
69
|
+
expiresAt: z.string(),
|
|
70
|
+
expiresInSeconds: z.number(),
|
|
71
|
+
intervalSeconds: z.number(),
|
|
72
|
+
}) satisfies z.ZodType<RemoteWebPairingChallengeResponse>;
|
|
73
|
+
|
|
62
74
|
/** `POST /v1/remote-web/pairing-verification` request body. */
|
|
63
75
|
export interface RemoteWebPairingVerificationRequest {
|
|
64
76
|
/** The `userCode` from the challenge, entered/scanned on the host. */
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Wire contracts for the remote-web pairing flow — the RFC 8628-style
|
|
3
5
|
* device-code exchange that connects a browser (or the iOS app) to a
|
|
@@ -59,6 +61,16 @@ export interface RemoteWebPairingChallengeResponse {
|
|
|
59
61
|
intervalSeconds: number;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
export const RemoteWebPairingChallengeResponseSchema = z.object({
|
|
65
|
+
deviceCode: z.string(),
|
|
66
|
+
userCode: z.string(),
|
|
67
|
+
verificationUri: z.string(),
|
|
68
|
+
publicBaseUrl: z.string(),
|
|
69
|
+
expiresAt: z.string(),
|
|
70
|
+
expiresInSeconds: z.number(),
|
|
71
|
+
intervalSeconds: z.number(),
|
|
72
|
+
}) satisfies z.ZodType<RemoteWebPairingChallengeResponse>;
|
|
73
|
+
|
|
62
74
|
/** `POST /v1/remote-web/pairing-verification` request body. */
|
|
63
75
|
export interface RemoteWebPairingVerificationRequest {
|
|
64
76
|
/** The `userCode` from the challenge, entered/scanned on the host. */
|
|
@@ -207,28 +207,11 @@ function readPersistedDefault(): CadetEnvironment | undefined {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
|
-
* The environment name every surface shares
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* `VITE_ENVIRONMENT` (the build-time inlined var bundled surfaces bake)
|
|
216
|
-
* 2. the release channel the running copy's version carries —
|
|
217
|
-
* `__CADET_OWN_VERSION__`, which the entrypoints that know their
|
|
218
|
-
* package root inject (the CLI's bin computes its git/local stamp,
|
|
219
|
-
* the Electron builds define it at build time). A released `-dev.`
|
|
220
|
-
* install resolves `dev` even on a machine that never wrote a
|
|
221
|
-
* persisted default
|
|
222
|
-
* 3. the persisted default written by pre-version-tied CLIs (old files
|
|
223
|
-
* are still honored)
|
|
224
|
-
* 4. `production`
|
|
225
|
-
*
|
|
226
|
-
* The version step is what keeps every released install from silently
|
|
227
|
-
* reading production's state: the channel is decided by the release
|
|
228
|
-
* pipeline, not by whatever default an older CLI happened to leave behind.
|
|
229
|
-
* Names outside `CadetEnvironment` are ignored rather than returned: an
|
|
230
|
-
* unrecognized value falls through to the next arm instead of leaking an
|
|
231
|
-
* unkeyable string into `SEEDS` lookups.
|
|
210
|
+
* The environment name every surface shares: `CADET_ENVIRONMENT`, then
|
|
211
|
+
* `VITE_ENVIRONMENT`, then the release channel the copy's version carries
|
|
212
|
+
* (`__CADET_OWN_VERSION__`, defined at build time by hosts that bundle
|
|
213
|
+
* local-mode), then the persisted default written by pre-version-tied CLIs,
|
|
214
|
+
* then production.
|
|
232
215
|
*/
|
|
233
216
|
export function resolveEnvironmentName(): CadetEnvironment {
|
|
234
217
|
const env = processEnv();
|
package/package.json
CHANGED
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
type SlashContext,
|
|
9
9
|
} from "../daemon/conversation-slash.js";
|
|
10
10
|
import { getWorkspaceConfigPath } from "../util/layout.js";
|
|
11
|
+
import { useMemorySecureKeys } from "./helpers/secure-keys.js";
|
|
12
|
+
|
|
13
|
+
// The /models resolution probes configured providers through secure storage;
|
|
14
|
+
// pin the memory backend so the classification-parity tests never route
|
|
15
|
+
// through CES RPC discovery, which has no daemon to reach in a test process.
|
|
16
|
+
useMemorySecureKeys();
|
|
11
17
|
|
|
12
18
|
function makeSlashContext(overrides: Partial<SlashContext> = {}): SlashContext {
|
|
13
19
|
return {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The assistant image copies workspace package sources after `bun install`,
|
|
7
|
+
* and the container smoke step resolves each declared `workspace:*`
|
|
8
|
+
* dependency. A declared dependency that is not COPYed fails the image
|
|
9
|
+
* build with `Cannot find module '@mycadet/<name>'` — the DR134 red.
|
|
10
|
+
*/
|
|
11
|
+
describe("assistant Dockerfile workspace packages", () => {
|
|
12
|
+
test("copies every workspace dependency into the image", () => {
|
|
13
|
+
const repoRoot = join(import.meta.dir, "../../..");
|
|
14
|
+
const pkg = JSON.parse(
|
|
15
|
+
readFileSync(join(repoRoot, "assistant/package.json"), "utf8"),
|
|
16
|
+
) as { dependencies?: Record<string, string> };
|
|
17
|
+
const dockerfile = readFileSync(
|
|
18
|
+
join(repoRoot, "assistant/Dockerfile"),
|
|
19
|
+
"utf8",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const missing: string[] = [];
|
|
23
|
+
for (const [name, version] of Object.entries(pkg.dependencies ?? {})) {
|
|
24
|
+
if (version !== "workspace:*") {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!name.startsWith("@mycadet/")) {
|
|
28
|
+
missing.push(`${name} (unexpected workspace package)`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const dir = `packages/${name.slice("@mycadet/".length)}`;
|
|
32
|
+
if (!dockerfile.includes(`COPY ${dir} `)) {
|
|
33
|
+
missing.push(dir);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
expect(missing).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -2,21 +2,36 @@
|
|
|
2
2
|
* Tests for the shared CES RPC session factory used by assistant boot and
|
|
3
3
|
* child-process credential resolution.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { mkdtempSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
6
9
|
|
|
7
10
|
import { createCesClient } from "./client.js";
|
|
8
11
|
|
|
12
|
+
// Socket discovery is the process manager's concern, and it reads
|
|
13
|
+
// `CES_SOCKET_DIR` — an empty socket dir makes the real discovery exhaust
|
|
14
|
+
// its poll budget and come back unavailable, no module mocking needed.
|
|
15
|
+
const socketDir = mkdtempSync(join(tmpdir(), "ces-no-socket-"));
|
|
16
|
+
const prevSocketDir = process.env["CES_SOCKET_DIR"];
|
|
17
|
+
process.env["CES_SOCKET_DIR"] = socketDir;
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
if (prevSocketDir === undefined) {
|
|
21
|
+
delete process.env["CES_SOCKET_DIR"];
|
|
22
|
+
} else {
|
|
23
|
+
process.env["CES_SOCKET_DIR"] = prevSocketDir;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
9
27
|
describe("createCesClient", () => {
|
|
10
|
-
test("returns undefined when discovery
|
|
28
|
+
test("returns undefined when discovery finds no socket", async () => {
|
|
11
29
|
const start = Date.now();
|
|
12
|
-
const client = await createCesClient(
|
|
13
|
-
discover: async () => ({
|
|
14
|
-
mode: "unavailable",
|
|
15
|
-
reason: "missing test socket",
|
|
16
|
-
}),
|
|
17
|
-
});
|
|
30
|
+
const client = await createCesClient();
|
|
18
31
|
|
|
19
32
|
expect(client).toBeUndefined();
|
|
20
|
-
|
|
33
|
+
// The discovery poll budget (3s) is the dominant cost — the point is
|
|
34
|
+
// that the factory settles on its own rather than hanging.
|
|
35
|
+
expect(Date.now() - start).toBeLessThan(10_000);
|
|
21
36
|
});
|
|
22
37
|
});
|
|
@@ -59,8 +59,6 @@ export interface CesSessionClient extends CesRpcClient, CesClientLifecycle {}
|
|
|
59
59
|
export interface CesSessionClientOptions {
|
|
60
60
|
/** Handshake material reused verbatim across reconnections. */
|
|
61
61
|
handshake?: CesRpcHandshakeOptions;
|
|
62
|
-
/** Socket discovery override. Tests inject a stub. */
|
|
63
|
-
discover?: Parameters<typeof createCesProcessManager>[0]["discover"];
|
|
64
62
|
/** RPC/handshake timeouts forwarded to the shared client. */
|
|
65
63
|
config?: CesRpcClientConfig;
|
|
66
64
|
}
|
|
@@ -79,7 +77,9 @@ export interface CesSessionClientOptions {
|
|
|
79
77
|
export async function createCesClient(
|
|
80
78
|
options: CesSessionClientOptions = {},
|
|
81
79
|
): Promise<CesSessionClient | undefined> {
|
|
82
|
-
|
|
80
|
+
// Socket discovery is the process manager's own concern — this factory
|
|
81
|
+
// only supplies the session-level handshake and timeouts.
|
|
82
|
+
const pm = createCesProcessManager();
|
|
83
83
|
const handshake = options.handshake;
|
|
84
84
|
|
|
85
85
|
let inner: CesRpcClient | undefined;
|
|
@@ -5,7 +5,10 @@ import { join } from "node:path";
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
6
6
|
|
|
7
7
|
import { waitFor } from "../__tests__/helpers/wait-for.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type CesProcessManagerConfig,
|
|
10
|
+
createCesProcessManager,
|
|
11
|
+
} from "./process-manager.js";
|
|
9
12
|
|
|
10
13
|
const untilClosed = (predicate: () => boolean) =>
|
|
11
14
|
waitFor(predicate, {
|
|
@@ -124,10 +127,11 @@ describe("CesProcessManager.onTransportClose", () => {
|
|
|
124
127
|
// ---------------------------------------------------------------------------
|
|
125
128
|
|
|
126
129
|
type LogCall = { level: string; msg: string };
|
|
130
|
+
type PmLogger = NonNullable<CesProcessManagerConfig["logger"]>;
|
|
127
131
|
|
|
128
132
|
function makeRecordingLogger(): {
|
|
129
133
|
calls: LogCall[];
|
|
130
|
-
logger:
|
|
134
|
+
logger: PmLogger;
|
|
131
135
|
} {
|
|
132
136
|
const calls: LogCall[] = [];
|
|
133
137
|
const rec =
|
|
@@ -141,9 +145,7 @@ function makeRecordingLogger(): {
|
|
|
141
145
|
warn: rec("warn"),
|
|
142
146
|
debug: rec("debug"),
|
|
143
147
|
error: rec("error"),
|
|
144
|
-
} as unknown as
|
|
145
|
-
Parameters<typeof createCesProcessManager>[0]["logger"]
|
|
146
|
-
>;
|
|
148
|
+
} as unknown as PmLogger;
|
|
147
149
|
return { calls, logger };
|
|
148
150
|
}
|
|
149
151
|
|
|
@@ -110,7 +110,7 @@ export interface CesProcessManager {
|
|
|
110
110
|
// ---------------------------------------------------------------------------
|
|
111
111
|
|
|
112
112
|
export function createCesProcessManager(
|
|
113
|
-
config: CesProcessManagerConfig,
|
|
113
|
+
config: CesProcessManagerConfig = {},
|
|
114
114
|
): CesProcessManager {
|
|
115
115
|
const pmLog = config.logger ?? log;
|
|
116
116
|
const discover = config.discover ?? discoverCesWithRetry;
|
|
@@ -21,30 +21,18 @@ import { getLogger } from "../util/logger.js";
|
|
|
21
21
|
|
|
22
22
|
const log = getLogger("ces-rpc-record-backend");
|
|
23
23
|
|
|
24
|
-
/** Minimum interval between CES connection attempts after a failure. */
|
|
25
|
-
const LAZY_SESSION_COOLDOWN_MS = 3_000;
|
|
26
|
-
|
|
27
|
-
/** How long ensureClient waits on a self-healing session before giving up. */
|
|
28
|
-
const RECONNECT_WAIT_MS = 15_000;
|
|
29
|
-
|
|
30
|
-
function sleep(ms: number): Promise<void> {
|
|
31
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
24
|
class CesRpcRecordBackend {
|
|
35
25
|
/**
|
|
36
26
|
* The live IPC session, opened by this backend's first record operation.
|
|
37
27
|
* The session owns its transport and reconnection: after a CES restart the
|
|
38
|
-
* same reference becomes ready again, so this backend never re-opens
|
|
39
|
-
* swaps clients
|
|
28
|
+
* same reference becomes ready again, so this backend never re-opens,
|
|
29
|
+
* swaps, or even waits on clients — it opens once and every operation
|
|
30
|
+
* either talks to that session or throws.
|
|
40
31
|
*/
|
|
41
32
|
private client: CesSessionClient | undefined;
|
|
42
33
|
|
|
43
|
-
/** Memoized
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
/** When the last connection attempt failed; gates re-attempts. */
|
|
47
|
-
private lastFailedAttemptAt = 0;
|
|
34
|
+
/** Memoized open (single-flight across concurrent first operations). */
|
|
35
|
+
private clientPromise: Promise<CesClient> | undefined;
|
|
48
36
|
|
|
49
37
|
/** Sync snapshot of the live client; `undefined` until connected. */
|
|
50
38
|
getClient(): CesClient | undefined {
|
|
@@ -55,62 +43,32 @@ class CesRpcRecordBackend {
|
|
|
55
43
|
* Resolve the client, opening our own CES IPC connection on first use.
|
|
56
44
|
* Throws {@link CesUnavailableError} when CES is unreachable — callers
|
|
57
45
|
* surface that rather than degrading, so a lost write is never mistaken
|
|
58
|
-
* for a successful one.
|
|
59
|
-
* cooldown window: every operation would otherwise re-pay the discovery
|
|
60
|
-
* polling while CES is down. A held session whose transport died is NOT
|
|
61
|
-
* dropped here — the session reconnects itself; we just wait for it.
|
|
46
|
+
* for a successful one.
|
|
62
47
|
*/
|
|
63
48
|
async ensureClient(): Promise<CesClient> {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
for (let waited = 0; waited < RECONNECT_WAIT_MS; waited += 250) {
|
|
71
|
-
await sleep(250);
|
|
72
|
-
if (this.client.isReady()) {
|
|
73
|
-
return this.client;
|
|
49
|
+
this.clientPromise ??= createCesClient()
|
|
50
|
+
.then((client) => {
|
|
51
|
+
if (!client) {
|
|
52
|
+
throw new CesUnavailableError(
|
|
53
|
+
"CES is unreachable (socket discovery failed or handshake rejected)",
|
|
54
|
+
);
|
|
74
55
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
if (!this.sessionAttempt) {
|
|
86
|
-
this.sessionAttempt = createCesClient()
|
|
87
|
-
.then((client) => {
|
|
88
|
-
if (!client) {
|
|
89
|
-
throw new CesUnavailableError(
|
|
90
|
-
"CES is unreachable (socket discovery failed or handshake rejected)",
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
this.client = client;
|
|
94
|
-
return client;
|
|
95
|
-
})
|
|
96
|
-
.catch((err) => {
|
|
97
|
-
this.lastFailedAttemptAt = Date.now();
|
|
98
|
-
throw err;
|
|
99
|
-
})
|
|
100
|
-
.finally(() => {
|
|
101
|
-
this.sessionAttempt = undefined;
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
return this.sessionAttempt;
|
|
56
|
+
this.client = client;
|
|
57
|
+
return client;
|
|
58
|
+
})
|
|
59
|
+
.catch((err) => {
|
|
60
|
+
// A failed open is not sticky: the next operation opens fresh.
|
|
61
|
+
this.clientPromise = undefined;
|
|
62
|
+
throw err;
|
|
63
|
+
});
|
|
64
|
+
return this.clientPromise;
|
|
105
65
|
}
|
|
106
66
|
|
|
107
67
|
/** Close the backend's connection (one-shot CLI processes call this on exit). */
|
|
108
68
|
async close(): Promise<void> {
|
|
109
69
|
const client = this.client;
|
|
110
70
|
this.client = undefined;
|
|
111
|
-
this.
|
|
112
|
-
// A deliberate close is a fresh start: clear any open-failure cooldown.
|
|
113
|
-
this.lastFailedAttemptAt = 0;
|
|
71
|
+
this.clientPromise = undefined;
|
|
114
72
|
await client?.stop().catch(() => {});
|
|
115
73
|
}
|
|
116
74
|
|