@cluesmith/codev 3.3.6 → 3.3.7

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 (30) hide show
  1. package/dist/agent-farm/lib/ide-prefix.d.ts +10 -0
  2. package/dist/agent-farm/lib/ide-prefix.d.ts.map +1 -0
  3. package/dist/agent-farm/lib/ide-prefix.js +13 -0
  4. package/dist/agent-farm/lib/ide-prefix.js.map +1 -0
  5. package/dist/agent-farm/lib/ide-record.d.ts +14 -2
  6. package/dist/agent-farm/lib/ide-record.d.ts.map +1 -1
  7. package/dist/agent-farm/lib/ide-record.js +26 -2
  8. package/dist/agent-farm/lib/ide-record.js.map +1 -1
  9. package/dist/agent-farm/servers/ide-forward.d.ts +57 -5
  10. package/dist/agent-farm/servers/ide-forward.d.ts.map +1 -1
  11. package/dist/agent-farm/servers/ide-forward.js +263 -31
  12. package/dist/agent-farm/servers/ide-forward.js.map +1 -1
  13. package/dist/agent-farm/servers/ide-server.d.ts.map +1 -1
  14. package/dist/agent-farm/servers/ide-server.js +4 -2
  15. package/dist/agent-farm/servers/ide-server.js.map +1 -1
  16. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  17. package/dist/agent-farm/servers/tower-routes.js +99 -6
  18. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  19. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  20. package/dist/agent-farm/servers/tower-websocket.js +11 -7
  21. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  22. package/dist/agent-farm/utils/server-utils.d.ts +54 -19
  23. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  24. package/dist/agent-farm/utils/server-utils.js +144 -27
  25. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  26. package/ide-extension/codev-vscode.vsix +0 -0
  27. package/ide-extension/manifest.json +1 -1
  28. package/package.json +4 -4
  29. package/skeleton/resources/commands/agent-farm.md +31 -4
  30. package/templates/tower.html +78 -3
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The IDE forward prefix (Issue #1668 §D1) and its matcher. Dependency-free on
3
+ * purpose: the auth seam (`utils/server-utils.ts`) imports it, and it must stay
4
+ * a light leaf (Issue #1869). `ide-record.ts` re-exports both.
5
+ */
6
+ /** The fixed Tower prefix that routes to the IDE server (Issue #1668 §D1). */
7
+ export declare const IDE_DEFAULT_PREFIX = "/ide/";
8
+ /** True if `pathname` targets the IDE forward prefix (`/ide` or `/ide/…`, never `/ideas`). */
9
+ export declare function matchIdePrefix(pathname: string): boolean;
10
+ //# sourceMappingURL=ide-prefix.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ide-prefix.d.ts","sourceRoot":"","sources":["../../../src/agent-farm/lib/ide-prefix.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,UAAU,CAAC;AAE1C,8FAA8F;AAC9F,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGxD"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The IDE forward prefix (Issue #1668 §D1) and its matcher. Dependency-free on
3
+ * purpose: the auth seam (`utils/server-utils.ts`) imports it, and it must stay
4
+ * a light leaf (Issue #1869). `ide-record.ts` re-exports both.
5
+ */
6
+ /** The fixed Tower prefix that routes to the IDE server (Issue #1668 §D1). */
7
+ export const IDE_DEFAULT_PREFIX = '/ide/';
8
+ /** True if `pathname` targets the IDE forward prefix (`/ide` or `/ide/…`, never `/ideas`). */
9
+ export function matchIdePrefix(pathname) {
10
+ const bare = IDE_DEFAULT_PREFIX.replace(/\/$/, ''); // "/ide"
11
+ return pathname === bare || pathname.startsWith(IDE_DEFAULT_PREFIX);
12
+ }
13
+ //# sourceMappingURL=ide-prefix.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ide-prefix.js","sourceRoot":"","sources":["../../../src/agent-farm/lib/ide-prefix.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,8EAA8E;AAC9E,MAAM,CAAC,MAAM,kBAAkB,GAAG,OAAO,CAAC;AAE1C,8FAA8F;AAC9F,MAAM,UAAU,cAAc,CAAC,QAAgB;IAC7C,MAAM,IAAI,GAAG,kBAAkB,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS;IAC7D,OAAO,QAAQ,KAAK,IAAI,IAAI,QAAQ,CAAC,UAAU,CAAC,kBAAkB,CAAC,CAAC;AACtE,CAAC"}
@@ -12,8 +12,7 @@
12
12
  * never hand-edited. Tower is the sole writer; `afx ide` drives it through the
13
13
  * local `/api/ide` endpoint.
14
14
  */
15
- /** The fixed Tower prefix that routes to the IDE server (Issue #1668 §D1). */
16
- export declare const IDE_DEFAULT_PREFIX = "/ide/";
15
+ export { IDE_DEFAULT_PREFIX, matchIdePrefix } from './ide-prefix.js';
17
16
  /** Default port the local IDE server listens on when `--port` is not given. */
18
17
  export declare const IDE_DEFAULT_PORT = 8200;
19
18
  /**
@@ -60,11 +59,24 @@ export declare function deleteIdeRecord(): void;
60
59
  * Tower injects it at the forward instead.
61
60
  */
62
61
  export declare function readIdeConnectionToken(): string | null;
62
+ /**
63
+ * Write a fresh connection token (randomBytes(32) hex, 0600), replacing any
64
+ * existing one, and return it (Issue #1869). Called before every IDE server
65
+ * SPAWN (never on adopt: a live server keeps the token it was started with), so
66
+ * a token that ever leaked into a browser jar is valid only for that server's
67
+ * uptime. Tower reads the token from this file on every forwarded request and
68
+ * overwrites any browser-held copy, so rotation breaks no client.
69
+ */
70
+ export declare function rotateIdeConnectionToken(): string;
63
71
  /**
64
72
  * Return the Tower-owned connection token, generating and persisting one (0600)
65
73
  * on first use. This token is the IDE server's own inner auth layer (passed via
66
74
  * `--connection-token-file`); it is NOT a substitute for Tower's key check on
67
75
  * the forward (Issue #1668 §D2).
76
+ *
77
+ * TEST SEAM: production code no longer calls this. Every spawn uses
78
+ * `rotateIdeConnectionToken` (Issue #1869), and the forward only reads the
79
+ * file. Tests use it to provision a token without forcing a new one.
68
80
  */
69
81
  export declare function ensureIdeConnectionToken(): string;
70
82
  //# sourceMappingURL=ide-record.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ide-record.d.ts","sourceRoot":"","sources":["../../../src/agent-farm/lib/ide-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAcH,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,UAAU,CAAC;AAE1C,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAKrC;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,yGAAyG;IACzG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oGAAoG;IACpG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oGAAoG;IACpG,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,+CAA+C;AAC/C,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,yDAAyD;AACzD,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,IAAI,eAAe,GAAG,IAAI,CA2BtD;AAOD,wEAAwE;AACxE,wBAAgB,cAAc,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAQ5D;AAED,gEAAgE;AAChE,wBAAgB,eAAe,IAAI,IAAI,CAGtC;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,GAAG,IAAI,CAKtD;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,CAajD"}
1
+ {"version":3,"file":"ide-record.d.ts","sourceRoot":"","sources":["../../../src/agent-farm/lib/ide-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAiBH,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAErE,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAKrC;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,yGAAyG;IACzG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oGAAoG;IACpG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oGAAoG;IACpG,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,+CAA+C;AAC/C,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,yDAAyD;AACzD,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,IAAI,eAAe,GAAG,IAAI,CA2BtD;AAOD,wEAAwE;AACxE,wBAAgB,cAAc,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAQ5D;AAED,gEAAgE;AAChE,wBAAgB,eAAe,IAAI,IAAI,CAGtC;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,GAAG,IAAI,CAKtD;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,CASjD;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,CAajD"}
@@ -16,8 +16,10 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSy
16
16
  import { randomBytes } from 'node:crypto';
17
17
  import { resolve } from 'node:path';
18
18
  import { AGENT_FARM_DIR } from './tower-client.js';
19
- /** The fixed Tower prefix that routes to the IDE server (Issue #1668 §D1). */
20
- export const IDE_DEFAULT_PREFIX = '/ide/';
19
+ // The prefix and its matcher live in a dependency-free module so the auth seam
20
+ // (`utils/server-utils.ts`) can use them without importing this file's
21
+ // filesystem and tower-client dependencies (Issue #1869).
22
+ export { IDE_DEFAULT_PREFIX, matchIdePrefix } from './ide-prefix.js';
21
23
  /** Default port the local IDE server listens on when `--port` is not given. */
22
24
  export const IDE_DEFAULT_PORT = 8200;
23
25
  const IDE_RECORD_FILENAME = 'ide-server.json';
@@ -101,11 +103,33 @@ export function readIdeConnectionToken() {
101
103
  const token = readFileSync(path, 'utf-8').trim();
102
104
  return token.length > 0 ? token : null;
103
105
  }
106
+ /**
107
+ * Write a fresh connection token (randomBytes(32) hex, 0600), replacing any
108
+ * existing one, and return it (Issue #1869). Called before every IDE server
109
+ * SPAWN (never on adopt: a live server keeps the token it was started with), so
110
+ * a token that ever leaked into a browser jar is valid only for that server's
111
+ * uptime. Tower reads the token from this file on every forwarded request and
112
+ * overwrites any browser-held copy, so rotation breaks no client.
113
+ */
114
+ export function rotateIdeConnectionToken() {
115
+ const path = getIdeConnectionTokenPath();
116
+ if (!existsSync(AGENT_FARM_DIR)) {
117
+ mkdirSync(AGENT_FARM_DIR, { recursive: true, mode: 0o700 });
118
+ }
119
+ const token = randomBytes(32).toString('hex');
120
+ writeFileSync(path, token + '\n', { mode: 0o600 });
121
+ chmodSync(path, 0o600);
122
+ return token;
123
+ }
104
124
  /**
105
125
  * Return the Tower-owned connection token, generating and persisting one (0600)
106
126
  * on first use. This token is the IDE server's own inner auth layer (passed via
107
127
  * `--connection-token-file`); it is NOT a substitute for Tower's key check on
108
128
  * the forward (Issue #1668 §D2).
129
+ *
130
+ * TEST SEAM: production code no longer calls this. Every spawn uses
131
+ * `rotateIdeConnectionToken` (Issue #1869), and the forward only reads the
132
+ * file. Tests use it to provision a token without forcing a new one.
109
133
  */
110
134
  export function ensureIdeConnectionToken() {
111
135
  const path = getIdeConnectionTokenPath();
@@ -1 +1 @@
1
- {"version":3,"file":"ide-record.js","sourceRoot":"","sources":["../../../src/agent-farm/lib/ide-record.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ide-record.js","sourceRoot":"","sources":["../../../src/agent-farm/lib/ide-record.ts"],"names":[],"mappings":"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"}
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import http from 'node:http';
16
16
  import type net from 'node:net';
17
+ import { matchIdePrefix } from '../lib/ide-record.js';
17
18
  /** The tunnel's public authority, for stamping `x-forwarded-*` on tunnel-borne requests. */
18
19
  export interface PublicAuthority {
19
20
  host: string;
@@ -21,11 +22,20 @@ export interface PublicAuthority {
21
22
  prefix: string;
22
23
  }
23
24
  /**
24
- * Merge the IDE connection token into a Cookie header value as `vscode-tkn=…`,
25
- * preserving every existing cookie and never clobbering an already-present
26
- * `vscode-tkn` (Issue #1668). Exported for testing.
25
+ * Set the IDE connection token in a Cookie header value as `vscode-tkn=…`,
26
+ * preserving every other cookie. Tower's token REPLACES any incoming
27
+ * `vscode-tkn` (Issue #1869): Tower is the sole legitimate source, so a
28
+ * browser-held copy (a hand-built `?tkn=` URL, a pre-fix session, a token
29
+ * stale after rotation) is never authoritative. Exported for testing.
27
30
  */
28
31
  export declare function mergeConnectionTokenCookie(existing: string | string[] | undefined, token: string): string;
32
+ /**
33
+ * Remove one cookie (exact name) from a Cookie header value, preserving every
34
+ * other pair in order. Accepts the string | string[] shape. Returns '' when no
35
+ * pair remains. Used to keep Tower's forward cookie (Issue #1869) away from the
36
+ * IDE server, the way the key header is stripped. Exported for testing.
37
+ */
38
+ export declare function stripCookie(existing: string | string[] | undefined, name: string): string;
29
39
  /**
30
40
  * Derive the tunnel's public authority from the cloud config, matching the
31
41
  * `accessUrl` Tower advertises (`tower-tunnel.ts`: `${server_url}/t/${tower_name}/`).
@@ -43,8 +53,44 @@ export declare function getPublicAuthority(): PublicAuthority | null;
43
53
  * Pure and exported for unit testing.
44
54
  */
45
55
  export declare function buildForwardHeaders(incoming: http.IncomingHttpHeaders, port: number, publicAuthority: PublicAuthority | null, connectionToken: string | null): Record<string, string | string[]>;
46
- /** True if `pathname` targets the IDE forward prefix (`/ide` or `/ide/…`). */
47
- export declare function matchIdePrefix(pathname: string): boolean;
56
+ /** True if `pathname` targets the IDE forward prefix; defined beside the prefix constant. */
57
+ export { matchIdePrefix };
58
+ /**
59
+ * Drop every `vscode-tkn` cookie the IDE server tries to set in the browser
60
+ * (Issue #1869). The server answers an authenticated request with
61
+ * `Set-Cookie: vscode-tkn=<connection token>` (7 days, SameSite=Lax); cookies
62
+ * are not port-scoped, so a browser holding it could open the remote protocol
63
+ * on the IDE server's own port from any localhost page, bypassing Tower. Tower
64
+ * injects the token on every forwarded request, so the browser never needs it.
65
+ * Returns undefined when nothing remains. Exported for testing.
66
+ */
67
+ export declare function stripConnectionTokenSetCookie(value: string | string[] | undefined): string[] | undefined;
68
+ /**
69
+ * Copy response headers, dropping hop-by-hop; preserves `Cache-Control` /
70
+ * `ETag`. With `stripTokenCookie` (a LOCAL request, Issue #1869) it also drops
71
+ * the IDE server's `vscode-tkn` Set-Cookie; the tunnel path relays it unchanged
72
+ * (the cloud variant is a separate follow-up). Exported for testing.
73
+ */
74
+ export declare function copyResponseHeaders(headers: http.IncomingHttpHeaders, stripTokenCookie: boolean): Record<string, string | string[] | number>;
75
+ /** Largest workbench page Tower will buffer to rewrite; anything bigger streams unchanged. */
76
+ export declare const MAX_WORKBENCH_HTML_BYTES: number;
77
+ /**
78
+ * Hand the workbench its connection token through its own configuration
79
+ * instead of the browser cookie jar (Issue #1869). The workbench takes
80
+ * `connectionToken` from its configuration first and falls back to the
81
+ * `vscode-tkn` cookie, which Tower strips locally because a cookie is readable
82
+ * by every localhost port. Same shape as Tower's same-origin key injection into
83
+ * the dashboard shell: post-auth only, never cross-origin readable.
84
+ *
85
+ * Inserts `"connectionToken":"<token>"` as the first key of the HTML-escaped
86
+ * JSON in `data-settings` (no trailing comma for an empty `{}`). Returns null,
87
+ * so the caller relays the page unchanged, when the token is not
88
+ * `^[A-Za-z0-9-]+$`, the page carries no workbench configuration, the
89
+ * configuration does not parse, it already names a `connectionToken`, or the
90
+ * rewritten configuration would not decode to valid JSON carrying the token.
91
+ * Exported for testing.
92
+ */
93
+ export declare function injectWorkbenchConnectionToken(html: string, token: string): string | null;
48
94
  /**
49
95
  * Forward a plain HTTP request to the local IDE server. Assumes authorization
50
96
  * has already passed. Path and query are forwarded unchanged (the workbench
@@ -59,4 +105,10 @@ export declare function forwardIdeHttp(req: http.IncomingMessage, res: http.Serv
59
105
  * parse. Mirrors `tunnel-client.ts handleWebSocketConnect`'s pipe.
60
106
  */
61
107
  export declare function forwardIdeWebSocket(req: http.IncomingMessage, socket: net.Socket, head: Buffer, log: (level: 'INFO' | 'ERROR' | 'WARN', message: string) => void): Promise<void>;
108
+ /**
109
+ * Reconstruct the HTTP/1.1 101 switching-protocols response line + headers.
110
+ * With `stripTokenCookie` (a LOCAL request) it drops the IDE server's
111
+ * `vscode-tkn` Set-Cookie like the HTTP path does. Exported for testing.
112
+ */
113
+ export declare function rebuildUpgradeResponse(res: Pick<http.IncomingMessage, 'statusCode' | 'statusMessage' | 'headers'>, stripTokenCookie: boolean): string;
62
114
  //# sourceMappingURL=ide-forward.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ide-forward.d.ts","sourceRoot":"","sources":["../../../src/agent-farm/servers/ide-forward.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAuChC,4FAA4F;AAC5F,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMzG;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,IAAI,CAa3D;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,IAAI,CAAC,mBAAmB,EAClC,IAAI,EAAE,MAAM,EACZ,eAAe,EAAE,eAAe,GAAG,IAAI,EACvC,eAAe,EAAE,MAAM,GAAG,IAAI,GAC7B,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CA0BnC;AAED,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGxD;AAaD;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,IAAI,CAAC,eAAe,EACzB,GAAG,EAAE,IAAI,CAAC,cAAc,EACxB,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAC/D,OAAO,CAAC,IAAI,CAAC,CAmCf;AAaD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,IAAI,CAAC,eAAe,EACzB,MAAM,EAAE,GAAG,CAAC,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAC/D,OAAO,CAAC,IAAI,CAAC,CA2Df"}
1
+ {"version":3,"file":"ide-forward.d.ts","sourceRoot":"","sources":["../../../src/agent-farm/servers/ide-forward.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAKhC,OAAO,EAAsB,cAAc,EAA0B,MAAM,sBAAsB,CAAC;AAmDlG,4FAA4F;AAC5F,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIzG;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAazF;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,IAAI,CAa3D;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,IAAI,CAAC,mBAAmB,EAClC,IAAI,EAAE,MAAM,EACZ,eAAe,EAAE,eAAe,GAAG,IAAI,EACvC,eAAe,EAAE,MAAM,GAAG,IAAI,GAC7B,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CA4CnC;AAED,6FAA6F;AAC7F,OAAO,EAAE,cAAc,EAAE,CAAC;AAE1B;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,GAAG,SAAS,CAKxG;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,IAAI,CAAC,mBAAmB,EACjC,gBAAgB,EAAE,OAAO,GACxB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,MAAM,CAAC,CAe5C;AAKD,8FAA8F;AAC9F,eAAO,MAAM,wBAAwB,QAAkB,CAAC;AA4BxD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,8BAA8B,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAoBzF;AA0DD;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,IAAI,CAAC,eAAe,EACzB,GAAG,EAAE,IAAI,CAAC,cAAc,EACxB,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAC/D,OAAO,CAAC,IAAI,CAAC,CA2Cf;AAaD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,IAAI,CAAC,eAAe,EACzB,MAAM,EAAE,GAAG,CAAC,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAC/D,OAAO,CAAC,IAAI,CAAC,CA2Df;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,eAAe,EAAE,YAAY,GAAG,eAAe,GAAG,SAAS,CAAC,EAC3E,gBAAgB,EAAE,OAAO,GACxB,MAAM,CAeR"}
@@ -17,8 +17,9 @@ import { URL } from 'node:url';
17
17
  import { readCloudConfig } from '../lib/cloud-config.js';
18
18
  import { TUNNEL_PROXY_HEADER } from '../lib/tunnel-client.js';
19
19
  import { TOWER_KEY_HEADER, LEGACY_WEB_KEY_HEADER } from '@cluesmith/codev-types';
20
- import { IDE_DEFAULT_PREFIX, readIdeConnectionToken } from '../lib/ide-record.js';
20
+ import { IDE_DEFAULT_PREFIX, matchIdePrefix, readIdeConnectionToken } from '../lib/ide-record.js';
21
21
  import { getRecordedIdePort, ensureIdeServerLive } from './ide-server.js';
22
+ import { IDE_FORWARD_COOKIE } from '../utils/server-utils.js';
22
23
  /** Hop-by-hop headers not forwarded between connections (RFC 7230 §6.1). */
23
24
  const HOP_BY_HOP = new Set([
24
25
  'connection',
@@ -38,16 +39,31 @@ const HOP_BY_HOP = new Set([
38
39
  * server must never see it.
39
40
  * - `x-codev-tunnel-proxy`: Tower's tunnel marker — never forwarded, so it
40
41
  * cannot leak or be trusted downstream (marker integrity, #1674).
42
+ * - `x-forwarded-host` / `-proto` / `-prefix`: only Tower's own stamps reach the
43
+ * IDE server (Issue #1869); a client copy could steer the workbench's
44
+ * `remoteAuthority` or base path.
41
45
  * - `host`: replaced with the loopback target authority below.
42
46
  */
43
47
  const STRIP_BEFORE_FORWARD = new Set([
44
48
  'x-forwarded-port',
45
49
  'x-original-host',
50
+ // Tower's own x-forwarded-* stamps are the only ones the IDE server may see
51
+ // (Issue #1869): client-supplied copies are dropped on every path.
52
+ 'x-forwarded-host',
53
+ 'x-forwarded-proto',
54
+ 'x-forwarded-prefix',
46
55
  TOWER_KEY_HEADER,
47
56
  LEGACY_WEB_KEY_HEADER,
48
57
  TUNNEL_PROXY_HEADER,
49
58
  'host',
50
59
  ]);
60
+ /**
61
+ * Response headers never relayed from the IDE server, on any path (Issue #1869):
62
+ * Tower must NEVER send `Access-Control-Allow-Credentials` (Tower reflects any
63
+ * loopback `Origin`, so with it another localhost page could read
64
+ * cookie-authorized `/ide` responses).
65
+ */
66
+ const STRIP_FROM_RESPONSE = new Set(['access-control-allow-credentials']);
51
67
  /** True if this request arrived over the tunnel (stamped by `TunnelClient`, #1588). */
52
68
  function isTunnelBorne(headers) {
53
69
  return headers[TUNNEL_PROXY_HEADER] === '1';
@@ -55,17 +71,38 @@ function isTunnelBorne(headers) {
55
71
  /** The connection-token cookie name the VS Code server reads. */
56
72
  const IDE_TOKEN_COOKIE = 'vscode-tkn';
57
73
  /**
58
- * Merge the IDE connection token into a Cookie header value as `vscode-tkn=…`,
59
- * preserving every existing cookie and never clobbering an already-present
60
- * `vscode-tkn` (Issue #1668). Exported for testing.
74
+ * Set the IDE connection token in a Cookie header value as `vscode-tkn=…`,
75
+ * preserving every other cookie. Tower's token REPLACES any incoming
76
+ * `vscode-tkn` (Issue #1869): Tower is the sole legitimate source, so a
77
+ * browser-held copy (a hand-built `?tkn=` URL, a pre-fix session, a token
78
+ * stale after rotation) is never authoritative. Exported for testing.
61
79
  */
62
80
  export function mergeConnectionTokenCookie(existing, token) {
63
- const current = Array.isArray(existing) ? existing.join('; ') : existing ?? '';
64
- // Never clobber: if a vscode-tkn is already present, leave the header as-is.
65
- if (new RegExp(`(^|;\\s*)${IDE_TOKEN_COOKIE}=`).test(current))
66
- return current;
81
+ const others = stripCookie(existing, IDE_TOKEN_COOKIE);
67
82
  const pair = `${IDE_TOKEN_COOKIE}=${token}`;
68
- return current ? `${current}; ${pair}` : pair;
83
+ return others ? `${others}; ${pair}` : pair;
84
+ }
85
+ /**
86
+ * Remove one cookie (exact name) from a Cookie header value, preserving every
87
+ * other pair in order. Accepts the string | string[] shape. Returns '' when no
88
+ * pair remains. Used to keep Tower's forward cookie (Issue #1869) away from the
89
+ * IDE server, the way the key header is stripped. Exported for testing.
90
+ */
91
+ export function stripCookie(existing, name) {
92
+ if (existing === undefined)
93
+ return '';
94
+ const joined = Array.isArray(existing) ? existing.join('; ') : existing;
95
+ return joined
96
+ .split(';')
97
+ .map((pair) => pair.trim())
98
+ .filter((pair) => {
99
+ if (pair.length === 0)
100
+ return false;
101
+ const eq = pair.indexOf('=');
102
+ const pairName = eq < 0 ? pair : pair.slice(0, eq).trim();
103
+ return pairName !== name;
104
+ })
105
+ .join('; ');
69
106
  }
70
107
  /**
71
108
  * Derive the tunnel's public authority from the cloud config, matching the
@@ -111,38 +148,216 @@ export function buildForwardHeaders(incoming, port, publicAuthority, connectionT
111
148
  out[key] = value;
112
149
  }
113
150
  out['host'] = `127.0.0.1:${port}`;
114
- if (isTunnelBorne(incoming) && publicAuthority) {
115
- out['x-forwarded-host'] = publicAuthority.host;
116
- out['x-forwarded-proto'] = publicAuthority.proto;
117
- out['x-forwarded-prefix'] = publicAuthority.prefix;
151
+ if (isTunnelBorne(incoming)) {
152
+ if (publicAuthority) {
153
+ out['x-forwarded-host'] = publicAuthority.host;
154
+ out['x-forwarded-proto'] = publicAuthority.proto;
155
+ out['x-forwarded-prefix'] = publicAuthority.prefix;
156
+ }
157
+ }
158
+ else if (typeof incoming.host === 'string' && incoming.host.length > 0) {
159
+ // Local browser (Issue #1869): the IDE server builds the workbench's
160
+ // `remoteAuthority` from the Host it sees, and the rewrite above makes that
161
+ // its own port, so the workbench would dial the IDE server directly and
162
+ // bypass Tower's forward seam. Stamp Tower's own authority (the incoming
163
+ // Host, already Host-guarded) so the workbench's sockets and remote
164
+ // resources come back through Tower. No prefix: locally `/ide` IS the
165
+ // server's base path.
166
+ out['x-forwarded-host'] = incoming.host;
167
+ out['x-forwarded-proto'] = 'http';
118
168
  }
119
169
  // Present the IDE server's connection token as a cookie. The server is
120
- // spawned with `--connection-token-file` and 403s without it, and it cannot
121
- // ride the browser's cookie jar through the relay (Set-Cookie Path=/ide never
122
- // matches /t/<tower>/ide/), so Tower injects it here — merging with, never
123
- // clobbering, any existing Cookie header (Issue #1668).
170
+ // spawned with `--connection-token-file` and 403s without it, and the
171
+ // browser never holds the token (Tower strips the server's Set-Cookie,
172
+ // Issue #1869), so Tower injects it here on every request (Issue #1668),
173
+ // REPLACING any browser-held `vscode-tkn` and keeping every other cookie.
174
+ // Tower's own forward cookie is stripped first: the IDE server must never
175
+ // see it (Issue #1869).
176
+ const cookie = stripCookie(incoming.cookie, IDE_FORWARD_COOKIE);
177
+ delete out['cookie'];
124
178
  if (connectionToken) {
125
- out['cookie'] = mergeConnectionTokenCookie(incoming.cookie, connectionToken);
179
+ out['cookie'] = mergeConnectionTokenCookie(cookie, connectionToken);
180
+ }
181
+ else if (cookie) {
182
+ out['cookie'] = cookie;
126
183
  }
127
184
  return out;
128
185
  }
129
- /** True if `pathname` targets the IDE forward prefix (`/ide` or `/ide/…`). */
130
- export function matchIdePrefix(pathname) {
131
- const bare = IDE_DEFAULT_PREFIX.replace(/\/$/, ''); // "/ide"
132
- return pathname === bare || pathname.startsWith(IDE_DEFAULT_PREFIX);
186
+ /** True if `pathname` targets the IDE forward prefix; defined beside the prefix constant. */
187
+ export { matchIdePrefix };
188
+ /**
189
+ * Drop every `vscode-tkn` cookie the IDE server tries to set in the browser
190
+ * (Issue #1869). The server answers an authenticated request with
191
+ * `Set-Cookie: vscode-tkn=<connection token>` (7 days, SameSite=Lax); cookies
192
+ * are not port-scoped, so a browser holding it could open the remote protocol
193
+ * on the IDE server's own port from any localhost page, bypassing Tower. Tower
194
+ * injects the token on every forwarded request, so the browser never needs it.
195
+ * Returns undefined when nothing remains. Exported for testing.
196
+ */
197
+ export function stripConnectionTokenSetCookie(value) {
198
+ if (value === undefined)
199
+ return undefined;
200
+ const entries = Array.isArray(value) ? value : [value];
201
+ const kept = entries.filter((entry) => !new RegExp(`^\\s*${IDE_TOKEN_COOKIE}=`).test(entry));
202
+ return kept.length > 0 ? kept : undefined;
133
203
  }
134
- /** Copy response headers, dropping hop-by-hop; preserves `Cache-Control` / `ETag`. */
135
- function copyResponseHeaders(headers) {
204
+ /**
205
+ * Copy response headers, dropping hop-by-hop; preserves `Cache-Control` /
206
+ * `ETag`. With `stripTokenCookie` (a LOCAL request, Issue #1869) it also drops
207
+ * the IDE server's `vscode-tkn` Set-Cookie; the tunnel path relays it unchanged
208
+ * (the cloud variant is a separate follow-up). Exported for testing.
209
+ */
210
+ export function copyResponseHeaders(headers, stripTokenCookie) {
136
211
  const out = {};
137
212
  for (const [key, value] of Object.entries(headers)) {
138
213
  if (value === undefined)
139
214
  continue;
140
- if (HOP_BY_HOP.has(key.toLowerCase()))
215
+ const lower = key.toLowerCase();
216
+ if (HOP_BY_HOP.has(lower))
217
+ continue;
218
+ if (STRIP_FROM_RESPONSE.has(lower))
219
+ continue;
220
+ if (stripTokenCookie && lower === 'set-cookie') {
221
+ const kept = stripConnectionTokenSetCookie(value);
222
+ if (kept)
223
+ out[key] = kept;
141
224
  continue;
225
+ }
142
226
  out[key] = value;
143
227
  }
144
228
  return out;
145
229
  }
230
+ /** Opening of the workbench configuration the IDE server embeds in its page. */
231
+ const WORKBENCH_CONFIG_OPEN = '<meta id="vscode-workbench-web-configuration" data-settings="{';
232
+ /** Largest workbench page Tower will buffer to rewrite; anything bigger streams unchanged. */
233
+ export const MAX_WORKBENCH_HTML_BYTES = 2 * 1024 * 1024;
234
+ /** Only a plain alphanumeric token is ever written into the page. */
235
+ const SAFE_TOKEN = /^[A-Za-z0-9-]+$/;
236
+ /** Decode the HTML character references the server uses in `data-settings`. */
237
+ function decodeAttribute(value) {
238
+ return value
239
+ .replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16)))
240
+ .replace(/&#(\d+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10)))
241
+ .replace(/&quot;/g, '"')
242
+ .replace(/&#39;|&apos;/g, "'")
243
+ .replace(/&lt;/g, '<')
244
+ .replace(/&gt;/g, '>')
245
+ .replace(/&amp;/g, '&');
246
+ }
247
+ /** The decoded `data-settings` JSON object, or null when it does not parse as one. */
248
+ function parseSettings(attribute) {
249
+ try {
250
+ const parsed = JSON.parse(decodeAttribute(attribute));
251
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
252
+ return null;
253
+ return parsed;
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ /**
260
+ * Hand the workbench its connection token through its own configuration
261
+ * instead of the browser cookie jar (Issue #1869). The workbench takes
262
+ * `connectionToken` from its configuration first and falls back to the
263
+ * `vscode-tkn` cookie, which Tower strips locally because a cookie is readable
264
+ * by every localhost port. Same shape as Tower's same-origin key injection into
265
+ * the dashboard shell: post-auth only, never cross-origin readable.
266
+ *
267
+ * Inserts `"connectionToken":"<token>"` as the first key of the HTML-escaped
268
+ * JSON in `data-settings` (no trailing comma for an empty `{}`). Returns null,
269
+ * so the caller relays the page unchanged, when the token is not
270
+ * `^[A-Za-z0-9-]+$`, the page carries no workbench configuration, the
271
+ * configuration does not parse, it already names a `connectionToken`, or the
272
+ * rewritten configuration would not decode to valid JSON carrying the token.
273
+ * Exported for testing.
274
+ */
275
+ export function injectWorkbenchConnectionToken(html, token) {
276
+ if (!SAFE_TOKEN.test(token))
277
+ return null;
278
+ const start = html.indexOf(WORKBENCH_CONFIG_OPEN);
279
+ if (start < 0)
280
+ return null;
281
+ const valueStart = start + WORKBENCH_CONFIG_OPEN.length - 1; // at the "{"
282
+ const valueEnd = html.indexOf('"', valueStart);
283
+ if (valueEnd < 0)
284
+ return null;
285
+ const original = html.slice(valueStart, valueEnd);
286
+ const settings = parseSettings(original);
287
+ if (!settings || 'connectionToken' in settings)
288
+ return null;
289
+ const isEmpty = Object.keys(settings).length === 0;
290
+ const entry = `&quot;connectionToken&quot;:&quot;${token}&quot;`;
291
+ let rewritten = `{${entry},${original.slice(1)}`;
292
+ if (isEmpty)
293
+ rewritten = `{${entry}}`;
294
+ const check = parseSettings(rewritten);
295
+ if (!check || check.connectionToken !== token)
296
+ return null;
297
+ return html.slice(0, valueStart) + rewritten + html.slice(valueEnd);
298
+ }
299
+ /**
300
+ * True for a response Tower may rewrite: 200, uncompressed `text/html`, and not
301
+ * declared larger than the bound. Everything else streams unchanged.
302
+ */
303
+ function isRewritableWorkbenchPage(res) {
304
+ if (res.statusCode !== 200)
305
+ return false;
306
+ const type = res.headers['content-type'] ?? '';
307
+ if (!type.startsWith('text/html'))
308
+ return false;
309
+ if (res.headers['content-encoding'] !== undefined)
310
+ return false;
311
+ const declared = Number(res.headers['content-length']);
312
+ if (Number.isFinite(declared) && declared > MAX_WORKBENCH_HTML_BYTES)
313
+ return false;
314
+ return true;
315
+ }
316
+ /**
317
+ * Buffer a workbench page (up to the bound), inject the connection token, and
318
+ * relay it with a corrected Content-Length. A body that outgrows the bound
319
+ * mid-stream is relayed unchanged: the buffered bytes, then a pipe of the rest.
320
+ */
321
+ function relayWorkbenchHtml(proxyRes, res, token) {
322
+ const chunks = [];
323
+ let size = 0;
324
+ let streaming = false;
325
+ const onData = (chunk) => {
326
+ chunks.push(chunk);
327
+ size += chunk.length;
328
+ if (size <= MAX_WORKBENCH_HTML_BYTES)
329
+ return;
330
+ streaming = true;
331
+ proxyRes.off('data', onData);
332
+ res.writeHead(proxyRes.statusCode ?? 502, copyResponseHeaders(proxyRes.headers, true));
333
+ for (const buffered of chunks)
334
+ res.write(buffered);
335
+ proxyRes.pipe(res);
336
+ };
337
+ proxyRes.on('data', onData);
338
+ proxyRes.on('end', () => {
339
+ if (streaming)
340
+ return;
341
+ const original = Buffer.concat(chunks).toString('utf8');
342
+ const rewritten = injectWorkbenchConnectionToken(original, token);
343
+ const body = Buffer.from(rewritten ?? original, 'utf8');
344
+ const out = copyResponseHeaders(proxyRes.headers, true);
345
+ for (const key of Object.keys(out)) {
346
+ const lower = key.toLowerCase();
347
+ if (lower === 'content-length' || lower === 'transfer-encoding')
348
+ delete out[key];
349
+ }
350
+ out['content-length'] = body.length;
351
+ res.writeHead(proxyRes.statusCode ?? 502, out);
352
+ res.end(body);
353
+ });
354
+ proxyRes.on('error', () => {
355
+ if (!res.headersSent)
356
+ res.writeHead(502, { 'Content-Type': 'application/json' });
357
+ if (!res.writableEnded)
358
+ res.end();
359
+ });
360
+ }
146
361
  /**
147
362
  * Forward a plain HTTP request to the local IDE server. Assumes authorization
148
363
  * has already passed. Path and query are forwarded unchanged (the workbench
@@ -158,9 +373,17 @@ export async function forwardIdeHttp(req, res, log) {
158
373
  res.end(JSON.stringify({ error: 'IDE server unavailable' }));
159
374
  return;
160
375
  }
161
- const headers = buildForwardHeaders(req.headers, port, getPublicAuthority(), readIdeConnectionToken());
376
+ const connectionToken = readIdeConnectionToken();
377
+ const local = !isTunnelBorne(req.headers);
378
+ const headers = buildForwardHeaders(req.headers, port, getPublicAuthority(), connectionToken);
162
379
  const proxyReq = http.request({ hostname: '127.0.0.1', port, path: req.url || '/', method: req.method, headers }, (proxyRes) => {
163
- res.writeHead(proxyRes.statusCode ?? 502, copyResponseHeaders(proxyRes.headers));
380
+ // Local only (Issue #1869): the browser gets the token through the
381
+ // workbench configuration, never as a cookie. The tunnel path is unchanged.
382
+ if (local && connectionToken && isRewritableWorkbenchPage(proxyRes)) {
383
+ relayWorkbenchHtml(proxyRes, res, connectionToken);
384
+ return;
385
+ }
386
+ res.writeHead(proxyRes.statusCode ?? 502, copyResponseHeaders(proxyRes.headers, local));
164
387
  proxyRes.pipe(res);
165
388
  proxyRes.on('error', () => {
166
389
  if (!res.writableEnded)
@@ -221,7 +444,7 @@ export async function forwardIdeWebSocket(req, socket, head, log) {
221
444
  });
222
445
  upstream.on('upgrade', (_upstreamRes, upstreamSocket, upstreamHead) => {
223
446
  // Replay the handshake response and any buffered bytes, then pipe both ways.
224
- socket.write(rebuildUpgradeResponse(_upstreamRes));
447
+ socket.write(rebuildUpgradeResponse(_upstreamRes, !isTunnelBorne(req.headers)));
225
448
  if (upstreamHead.length > 0)
226
449
  socket.write(upstreamHead);
227
450
  if (head.length > 0)
@@ -254,11 +477,20 @@ export async function forwardIdeWebSocket(req, socket, head, log) {
254
477
  });
255
478
  upstream.end();
256
479
  }
257
- /** Reconstruct the HTTP/1.1 101 switching-protocols response line + headers. */
258
- function rebuildUpgradeResponse(res) {
480
+ /**
481
+ * Reconstruct the HTTP/1.1 101 switching-protocols response line + headers.
482
+ * With `stripTokenCookie` (a LOCAL request) it drops the IDE server's
483
+ * `vscode-tkn` Set-Cookie like the HTTP path does. Exported for testing.
484
+ */
485
+ export function rebuildUpgradeResponse(res, stripTokenCookie) {
259
486
  const statusLine = `HTTP/1.1 ${res.statusCode ?? 101} ${res.statusMessage ?? 'Switching Protocols'}`;
260
487
  const lines = [statusLine];
261
- for (const [key, value] of Object.entries(res.headers)) {
488
+ for (const [key, raw] of Object.entries(res.headers)) {
489
+ if (STRIP_FROM_RESPONSE.has(key.toLowerCase()))
490
+ continue;
491
+ let value = raw;
492
+ if (stripTokenCookie && key.toLowerCase() === 'set-cookie')
493
+ value = stripConnectionTokenSetCookie(raw);
262
494
  if (value === undefined)
263
495
  continue;
264
496
  if (Array.isArray(value)) {