github-issue-tower-defence-management 1.128.0 → 1.129.0

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 (43) hide show
  1. package/.github/workflows/console-ui.yml +1 -1
  2. package/.github/workflows/empty-format-test-job.yml +1 -1
  3. package/.github/workflows/format.yml +1 -1
  4. package/.github/workflows/publish.yml +1 -1
  5. package/.github/workflows/test.yml +1 -1
  6. package/CHANGELOG.md +14 -0
  7. package/README.md +3 -3
  8. package/bin/adapter/entry-points/console/ui-dist/assets/index-Dr5qin37.js +103 -0
  9. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  10. package/bin/adapter/entry-points/console/webServer.js +56 -3
  11. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  12. package/bin/domain/entities/WorkflowStatus.js +6 -1
  13. package/bin/domain/entities/WorkflowStatus.js.map +1 -1
  14. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -0
  15. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  16. package/package.json +3 -3
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.stories.tsx +1 -2
  18. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.ts +2 -4
  19. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +7 -7
  20. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +15 -18
  21. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +2 -5
  22. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +22 -25
  23. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +12 -23
  24. package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.test.ts +7 -10
  25. package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.ts +2 -9
  26. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +2 -4
  27. package/src/adapter/entry-points/console/ui-dist/assets/index-Dr5qin37.js +103 -0
  28. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  29. package/src/adapter/entry-points/console/webServer.test.ts +283 -6
  30. package/src/adapter/entry-points/console/webServer.ts +64 -0
  31. package/src/domain/entities/WorkflowStatus.ts +5 -0
  32. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +92 -2
  33. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +2 -0
  34. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +1 -0
  35. package/types/adapter/entry-points/console/webServer.d.ts +5 -1
  36. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  37. package/types/domain/entities/WorkflowStatus.d.ts +1 -0
  38. package/types/domain/entities/WorkflowStatus.d.ts.map +1 -1
  39. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  40. package/bin/adapter/entry-points/console/ui-dist/assets/index-8H0HejYa.js +0 -103
  41. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleToken.test.ts +0 -41
  42. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleToken.ts +0 -64
  43. package/src/adapter/entry-points/console/ui-dist/assets/index-8H0HejYa.js +0 -103
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-8H0HejYa.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-Dr5qin37.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-N9M1qhkw.css">
9
9
  </head>
10
10
  <body>
@@ -6,11 +6,13 @@ import { mock } from 'jest-mock-extended';
6
6
  import {
7
7
  DEFAULT_WEB_PORT,
8
8
  CONSOLE_TOKEN_HEADER,
9
+ CONSOLE_TOKEN_COOKIE,
9
10
  hasDotSegment,
10
11
  requiresToken,
11
12
  isTokenValid,
12
13
  isConsoleAppRoute,
13
14
  extractProvidedToken,
15
+ extractCookieToken,
14
16
  resolveFlatInTmuxFilePath,
15
17
  resolveDashboardFilePath,
16
18
  startWebServer,
@@ -109,16 +111,53 @@ describe('webServer pure helpers', () => {
109
111
 
110
112
  describe('extractProvidedToken', () => {
111
113
  it('prefers the query token', () => {
112
- expect(extractProvidedToken('fromQuery', 'fromHeader')).toBe('fromQuery');
114
+ expect(
115
+ extractProvidedToken('fromQuery', 'fromHeader', 'fromCookie'),
116
+ ).toBe('fromQuery');
113
117
  });
114
118
 
115
- it('falls back to the header token', () => {
116
- expect(extractProvidedToken(null, 'fromHeader')).toBe('fromHeader');
119
+ it('falls back to the header token before the cookie token', () => {
120
+ expect(extractProvidedToken(null, 'fromHeader', 'fromCookie')).toBe(
121
+ 'fromHeader',
122
+ );
123
+ });
124
+
125
+ it('falls back to the cookie token when query and header are absent', () => {
126
+ expect(extractProvidedToken(null, undefined, 'fromCookie')).toBe(
127
+ 'fromCookie',
128
+ );
129
+ });
130
+
131
+ it('returns null when none is present', () => {
132
+ expect(extractProvidedToken(null, undefined, null)).toBeNull();
133
+ expect(extractProvidedToken('', undefined, '')).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe('extractCookieToken', () => {
138
+ it('reads the token cookie value from a cookie header', () => {
139
+ expect(extractCookieToken(`${CONSOLE_TOKEN_COOKIE}=cookie-value`)).toBe(
140
+ 'cookie-value',
141
+ );
142
+ });
143
+
144
+ it('reads the token cookie when other cookies are present', () => {
145
+ expect(
146
+ extractCookieToken(
147
+ `other=1; ${CONSOLE_TOKEN_COOKIE}=cookie-value; a=b`,
148
+ ),
149
+ ).toBe('cookie-value');
117
150
  });
118
151
 
119
- it('returns null when neither is present', () => {
120
- expect(extractProvidedToken(null, undefined)).toBeNull();
121
- expect(extractProvidedToken('', undefined)).toBeNull();
152
+ it('decodes a percent-encoded cookie value', () => {
153
+ expect(extractCookieToken(`${CONSOLE_TOKEN_COOKIE}=a%20b`)).toBe('a b');
154
+ });
155
+
156
+ it('returns null when the token cookie is absent, empty, or undefined', () => {
157
+ expect(extractCookieToken('other=1')).toBeNull();
158
+ expect(extractCookieToken(`${CONSOLE_TOKEN_COOKIE}=`)).toBeNull();
159
+ expect(extractCookieToken(undefined)).toBeNull();
160
+ expect(extractCookieToken('')).toBeNull();
122
161
  });
123
162
  });
124
163
  });
@@ -1618,3 +1657,241 @@ describe('webServer image proxy', () => {
1618
1657
  }
1619
1658
  });
1620
1659
  });
1660
+
1661
+ describe('webServer token cookie redirect', () => {
1662
+ const testToken = 'integration-test-token-value';
1663
+
1664
+ const closeServer = (server: http.Server): Promise<void> =>
1665
+ new Promise((resolve, reject) => {
1666
+ server.close((error) => {
1667
+ if (error) {
1668
+ reject(error);
1669
+ return;
1670
+ }
1671
+ resolve();
1672
+ });
1673
+ });
1674
+
1675
+ const requestServer = (
1676
+ server: http.Server,
1677
+ requestPath: string,
1678
+ headers: http.OutgoingHttpHeaders = {},
1679
+ ): Promise<{
1680
+ statusCode: number;
1681
+ body: string;
1682
+ location: string | undefined;
1683
+ setCookie: string[] | undefined;
1684
+ referrerPolicy: string | string[] | undefined;
1685
+ cacheControl: string | undefined;
1686
+ }> => {
1687
+ const address = server.address();
1688
+ if (address === null || typeof address === 'string') {
1689
+ throw new Error('server is not listening on a TCP port');
1690
+ }
1691
+ const port = address.port;
1692
+ return new Promise((resolve, reject) => {
1693
+ const httpRequest = http.request(
1694
+ { host: '127.0.0.1', port, path: requestPath, headers },
1695
+ (response) => {
1696
+ const chunks: Uint8Array[] = [];
1697
+ response.on('data', (chunk: Uint8Array) => chunks.push(chunk));
1698
+ response.on('end', () => {
1699
+ resolve({
1700
+ statusCode: response.statusCode ?? 0,
1701
+ body: Buffer.concat(chunks).toString('utf-8'),
1702
+ location: response.headers['location'],
1703
+ setCookie: response.headers['set-cookie'],
1704
+ referrerPolicy: response.headers['referrer-policy'],
1705
+ cacheControl: response.headers['cache-control'],
1706
+ });
1707
+ });
1708
+ },
1709
+ );
1710
+ httpRequest.on('error', reject);
1711
+ httpRequest.end();
1712
+ });
1713
+ };
1714
+
1715
+ const startWithUiDist = async (): Promise<{
1716
+ server: http.Server;
1717
+ tmpDir: string;
1718
+ }> => {
1719
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
1720
+ const uiDistDir = path.join(tmpDir, 'ui-dist');
1721
+ fs.mkdirSync(uiDistDir, { recursive: true });
1722
+ fs.writeFileSync(
1723
+ path.join(uiDistDir, 'index.html'),
1724
+ '<!DOCTYPE html><title>spa</title><div id="root"></div>',
1725
+ );
1726
+ const server = await startWebServer({
1727
+ accessToken: testToken,
1728
+ uiDistDir,
1729
+ consoleDataOutputDir: null,
1730
+ inTmuxDataDir: null,
1731
+ dashboardDir: null,
1732
+ dashboardDataDir: null,
1733
+ dashboardProjectNames: [],
1734
+ port: 0,
1735
+ });
1736
+ return { server, tmpDir };
1737
+ };
1738
+
1739
+ const firstSetCookie = (setCookie: string[] | undefined): string => {
1740
+ expect(setCookie).toBeDefined();
1741
+ expect(setCookie).toHaveLength(1);
1742
+ return (setCookie ?? [''])[0];
1743
+ };
1744
+
1745
+ it('redirects a per-project app route carrying ?k= to the keyless path and sets an HttpOnly SameSite=Strict cookie', async () => {
1746
+ const { server, tmpDir } = await startWithUiDist();
1747
+ try {
1748
+ const response = await requestServer(
1749
+ server,
1750
+ `/projects/umino?k=${testToken}`,
1751
+ );
1752
+ expect(response.statusCode).toBe(302);
1753
+ expect(response.location).toBe('/projects/umino');
1754
+ expect(response.body).not.toContain(testToken);
1755
+ const cookie = firstSetCookie(response.setCookie);
1756
+ expect(cookie).toContain(`${CONSOLE_TOKEN_COOKIE}=${testToken}`);
1757
+ expect(cookie).toContain('HttpOnly');
1758
+ expect(cookie).toContain('SameSite=Strict');
1759
+ expect(cookie).toContain('Path=/');
1760
+ expect(response.referrerPolicy).toBe('no-referrer');
1761
+ } finally {
1762
+ await closeServer(server);
1763
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1764
+ }
1765
+ });
1766
+
1767
+ it('redirects a per-project tab route carrying ?k= and preserves other query parameters', async () => {
1768
+ const { server, tmpDir } = await startWithUiDist();
1769
+ try {
1770
+ const response = await requestServer(
1771
+ server,
1772
+ `/projects/xmile/prs?k=${testToken}&foo=bar`,
1773
+ );
1774
+ expect(response.statusCode).toBe(302);
1775
+ expect(response.location).toBe('/projects/xmile/prs?foo=bar');
1776
+ const cookie = firstSetCookie(response.setCookie);
1777
+ expect(cookie).toContain(`${CONSOLE_TOKEN_COOKIE}=${testToken}`);
1778
+ } finally {
1779
+ await closeServer(server);
1780
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1781
+ }
1782
+ });
1783
+
1784
+ it('redirects the root and index.html routes carrying ?k= to a keyless path', async () => {
1785
+ const { server, tmpDir } = await startWithUiDist();
1786
+ try {
1787
+ const root = await requestServer(server, `/?k=${testToken}`);
1788
+ expect(root.statusCode).toBe(302);
1789
+ expect(root.location).toBe('/');
1790
+ expect(firstSetCookie(root.setCookie)).toContain(
1791
+ `${CONSOLE_TOKEN_COOKIE}=${testToken}`,
1792
+ );
1793
+
1794
+ const indexHtml = await requestServer(
1795
+ server,
1796
+ `/index.html?k=${testToken}`,
1797
+ );
1798
+ expect(indexHtml.statusCode).toBe(302);
1799
+ expect(indexHtml.location).toBe('/index.html');
1800
+ } finally {
1801
+ await closeServer(server);
1802
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1803
+ }
1804
+ });
1805
+
1806
+ it('serves the app route without a token and never leaks a token in the URL', async () => {
1807
+ const { server, tmpDir } = await startWithUiDist();
1808
+ try {
1809
+ const response = await requestServer(server, '/projects/umino');
1810
+ expect(response.statusCode).toBe(200);
1811
+ expect(response.body).toContain('spa');
1812
+ expect(response.setCookie).toBeUndefined();
1813
+ expect(response.referrerPolicy).toBe('no-referrer');
1814
+ } finally {
1815
+ await closeServer(server);
1816
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1817
+ }
1818
+ });
1819
+
1820
+ it('sets Referrer-Policy: no-referrer on the console HTML responses', async () => {
1821
+ const { server, tmpDir } = await startWithUiDist();
1822
+ try {
1823
+ const root = await requestServer(server, '/');
1824
+ expect(root.statusCode).toBe(200);
1825
+ expect(root.referrerPolicy).toBe('no-referrer');
1826
+
1827
+ const projectTab = await requestServer(server, '/projects/xmile/prs');
1828
+ expect(projectTab.statusCode).toBe(200);
1829
+ expect(projectTab.referrerPolicy).toBe('no-referrer');
1830
+ } finally {
1831
+ await closeServer(server);
1832
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1833
+ }
1834
+ });
1835
+
1836
+ it('authenticates a data list request through the cookie without a token in the URL', async () => {
1837
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
1838
+ const dataDir = path.join(tmpDir, 'data');
1839
+ const listDir = path.join(dataDir, 'umino', 'prs');
1840
+ fs.mkdirSync(listDir, { recursive: true });
1841
+ fs.writeFileSync(
1842
+ path.join(listDir, 'list.json'),
1843
+ JSON.stringify({ pjcode: 'umino', items: [] }),
1844
+ );
1845
+ const server = await startWebServer({
1846
+ accessToken: testToken,
1847
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
1848
+ consoleDataOutputDir: dataDir,
1849
+ inTmuxDataDir: null,
1850
+ dashboardDir: null,
1851
+ dashboardDataDir: null,
1852
+ dashboardProjectNames: [],
1853
+ port: 0,
1854
+ });
1855
+ try {
1856
+ const withCookie = await requestServer(
1857
+ server,
1858
+ '/projects/umino/prs/list.json',
1859
+ { Cookie: `${CONSOLE_TOKEN_COOKIE}=${testToken}` },
1860
+ );
1861
+ expect(withCookie.statusCode).toBe(200);
1862
+ expect(JSON.parse(withCookie.body)).toEqual({
1863
+ pjcode: 'umino',
1864
+ items: [],
1865
+ });
1866
+ } finally {
1867
+ await closeServer(server);
1868
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1869
+ }
1870
+ });
1871
+
1872
+ it('rejects a data or api request when no token and no cookie are present', async () => {
1873
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
1874
+ const server = await startWebServer({
1875
+ accessToken: testToken,
1876
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
1877
+ consoleDataOutputDir: null,
1878
+ inTmuxDataDir: null,
1879
+ dashboardDir: null,
1880
+ dashboardDataDir: null,
1881
+ dashboardProjectNames: [],
1882
+ port: 0,
1883
+ });
1884
+ try {
1885
+ const noToken = await requestServer(server, '/data/situation.json');
1886
+ expect(noToken.statusCode).toBe(401);
1887
+
1888
+ const wrongCookie = await requestServer(server, '/api/review', {
1889
+ Cookie: `${CONSOLE_TOKEN_COOKIE}=wrong-token`,
1890
+ });
1891
+ expect(wrongCookie.statusCode).toBe(401);
1892
+ } finally {
1893
+ await closeServer(server);
1894
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1895
+ }
1896
+ });
1897
+ });
@@ -41,6 +41,8 @@ export const DEFAULT_DASHBOARD_PROJECT_NAMES = DASHBOARD_PROJECT_NAMES;
41
41
 
42
42
  export const CONSOLE_TOKEN_HEADER = 'x-pv-token';
43
43
 
44
+ export const CONSOLE_TOKEN_COOKIE = 'pv_token';
45
+
44
46
  const PLACEHOLDER_INDEX_HTML = `<!DOCTYPE html>
45
47
  <html lang="en">
46
48
  <head>
@@ -112,9 +114,34 @@ export const isTokenValid = (
112
114
  providedToken: string | null,
113
115
  ): boolean => providedToken !== null && providedToken === expectedToken;
114
116
 
117
+ export const extractCookieToken = (
118
+ cookieHeader: string | undefined,
119
+ ): string | null => {
120
+ if (typeof cookieHeader !== 'string' || cookieHeader.length === 0) {
121
+ return null;
122
+ }
123
+ for (const segment of cookieHeader.split(';')) {
124
+ const separatorIndex = segment.indexOf('=');
125
+ if (separatorIndex === -1) {
126
+ continue;
127
+ }
128
+ const name = segment.slice(0, separatorIndex).trim();
129
+ if (name !== CONSOLE_TOKEN_COOKIE) {
130
+ continue;
131
+ }
132
+ const value = segment.slice(separatorIndex + 1).trim();
133
+ if (value.length === 0) {
134
+ return null;
135
+ }
136
+ return decodeURIComponent(value);
137
+ }
138
+ return null;
139
+ };
140
+
115
141
  export const extractProvidedToken = (
116
142
  queryToken: string | string[] | null,
117
143
  headerToken: string | string[] | undefined,
144
+ cookieToken: string | null,
118
145
  ): string | null => {
119
146
  if (typeof queryToken === 'string' && queryToken.length > 0) {
120
147
  return queryToken;
@@ -122,9 +149,24 @@ export const extractProvidedToken = (
122
149
  if (typeof headerToken === 'string' && headerToken.length > 0) {
123
150
  return headerToken;
124
151
  }
152
+ if (cookieToken !== null && cookieToken.length > 0) {
153
+ return cookieToken;
154
+ }
125
155
  return null;
126
156
  };
127
157
 
158
+ export const buildTokenCookie = (token: string): string =>
159
+ `${CONSOLE_TOKEN_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict`;
160
+
161
+ export const buildKeylessLocation = (requestUrl: URL): string => {
162
+ const params = new URLSearchParams(requestUrl.searchParams);
163
+ params.delete('k');
164
+ const query = params.toString();
165
+ return query.length > 0
166
+ ? `${requestUrl.pathname}?${query}`
167
+ : requestUrl.pathname;
168
+ };
169
+
128
170
  const contentTypeForPath = (filePath: string): string => {
129
171
  const extension = path.extname(filePath).toLowerCase();
130
172
  return MIME_TYPES[extension] ?? 'application/octet-stream';
@@ -245,6 +287,7 @@ const serveBootstrapIndex = (response: http.ServerResponse): void => {
245
287
  response.writeHead(200, {
246
288
  'Content-Type': 'text/html; charset=utf-8',
247
289
  'Cache-Control': 'no-store',
290
+ 'Referrer-Policy': 'no-referrer',
248
291
  });
249
292
  response.end(PLACEHOLDER_INDEX_HTML);
250
293
  };
@@ -263,10 +306,25 @@ const serveIndexHtml = (
263
306
  response.writeHead(200, {
264
307
  'Content-Type': 'text/html; charset=utf-8',
265
308
  'Cache-Control': 'no-store',
309
+ 'Referrer-Policy': 'no-referrer',
266
310
  });
267
311
  response.end(indexContent);
268
312
  };
269
313
 
314
+ const redirectStrippingToken = (
315
+ response: http.ServerResponse,
316
+ requestUrl: URL,
317
+ token: string,
318
+ ): void => {
319
+ response.writeHead(302, {
320
+ Location: buildKeylessLocation(requestUrl),
321
+ 'Set-Cookie': buildTokenCookie(token),
322
+ 'Referrer-Policy': 'no-referrer',
323
+ 'Cache-Control': 'no-store',
324
+ });
325
+ response.end();
326
+ };
327
+
270
328
  const sendJson = (
271
329
  response: http.ServerResponse,
272
330
  statusCode: number,
@@ -626,6 +684,7 @@ export const handleWebRequest = async (
626
684
  const providedToken = extractProvidedToken(
627
685
  requestUrl.searchParams.get('k'),
628
686
  request.headers[CONSOLE_TOKEN_HEADER],
687
+ extractCookieToken(request.headers.cookie),
629
688
  );
630
689
  if (!isTokenValid(options.accessToken, providedToken)) {
631
690
  sendUnauthorized(response);
@@ -646,6 +705,11 @@ export const handleWebRequest = async (
646
705
  requestPath === '/index.html' ||
647
706
  isConsoleAppRoute(requestPath)
648
707
  ) {
708
+ const queryToken = requestUrl.searchParams.get('k');
709
+ if (queryToken !== null && queryToken.length > 0) {
710
+ redirectStrippingToken(response, requestUrl, queryToken);
711
+ return;
712
+ }
649
713
  serveIndexHtml(options, response);
650
714
  return;
651
715
  }
@@ -6,6 +6,7 @@ export const PREPARATION_STATUS_NAME = 'Preparation';
6
6
  export const FAILED_PREPARATION_STATUS_NAME = 'Failed Preparation';
7
7
  export const AWAITING_QUALITY_CHECK_STATUS_NAME = 'Awaiting Quality Check';
8
8
  export const TODO_STATUS_NAME = 'Todo by human';
9
+ export const TODO_BY_AGENT_STATUS_NAME = 'Todo by agent';
9
10
  export const PC_TODO_STATUS_NAME = 'PC Todo';
10
11
  export const IN_TMUX_STATUS_NAME = 'In Tmux by human';
11
12
  export const IN_TMUX_BY_AGENT_STATUS_NAME = 'In Tmux by agent';
@@ -47,6 +48,10 @@ export const REQUIRED_WORKFLOW_STATUSES: WorkflowStatusDefinition[] = [
47
48
  name: TODO_STATUS_NAME,
48
49
  color: 'PINK',
49
50
  },
51
+ {
52
+ name: TODO_BY_AGENT_STATUS_NAME,
53
+ color: 'BLUE',
54
+ },
50
55
  {
51
56
  name: IN_TMUX_STATUS_NAME,
52
57
  color: 'RED',
@@ -19,6 +19,7 @@ import {
19
19
  PC_TODO_STATUS_NAME,
20
20
  PREPARATION_STATUS_NAME,
21
21
  REQUIRED_WORKFLOW_STATUSES,
22
+ TODO_BY_AGENT_STATUS_NAME,
22
23
  TODO_STATUS_NAME,
23
24
  } from '../entities/WorkflowStatus';
24
25
 
@@ -77,7 +78,7 @@ const buildIssue = (overrides: Partial<Issue>): Issue => ({
77
78
  });
78
79
 
79
80
  describe('SetupTowerDefenceProjectUseCase', () => {
80
- it('should define exactly the 10 required statuses in the documented order with the documented colors and no descriptions', () => {
81
+ it('should define exactly the 11 required statuses in the documented order with the documented colors and no descriptions', () => {
81
82
  expect(REQUIRED_WORKFLOW_STATUSES).toEqual([
82
83
  { name: DEFAULT_STATUS_NAME, color: 'ORANGE' },
83
84
  { name: AWAITING_WORKSPACE_STATUS_NAME, color: 'BLUE' },
@@ -85,6 +86,7 @@ describe('SetupTowerDefenceProjectUseCase', () => {
85
86
  { name: FAILED_PREPARATION_STATUS_NAME, color: 'RED' },
86
87
  { name: AWAITING_QUALITY_CHECK_STATUS_NAME, color: 'GREEN' },
87
88
  { name: TODO_STATUS_NAME, color: 'PINK' },
89
+ { name: TODO_BY_AGENT_STATUS_NAME, color: 'BLUE' },
88
90
  { name: IN_TMUX_STATUS_NAME, color: 'RED' },
89
91
  { name: IN_TMUX_BY_AGENT_STATUS_NAME, color: 'YELLOW' },
90
92
  { name: DONE_STATUS_NAME, color: 'PURPLE' },
@@ -253,6 +255,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
253
255
  color: 'PINK',
254
256
  description: '',
255
257
  },
258
+ {
259
+ id: null,
260
+ name: TODO_BY_AGENT_STATUS_NAME,
261
+ color: 'BLUE',
262
+ description: '',
263
+ },
256
264
  {
257
265
  id: null,
258
266
  name: IN_TMUX_STATUS_NAME,
@@ -324,6 +332,7 @@ describe('SetupTowerDefenceProjectUseCase', () => {
324
332
  FAILED_PREPARATION_STATUS_NAME,
325
333
  AWAITING_QUALITY_CHECK_STATUS_NAME,
326
334
  TODO_STATUS_NAME,
335
+ TODO_BY_AGENT_STATUS_NAME,
327
336
  IN_TMUX_STATUS_NAME,
328
337
  IN_TMUX_BY_AGENT_STATUS_NAME,
329
338
  DONE_STATUS_NAME,
@@ -432,12 +441,90 @@ describe('SetupTowerDefenceProjectUseCase', () => {
432
441
  const [, payload] = mockProjectRepository.updateStatusList.mock.calls[0];
433
442
  const inTmuxEntry = payload.find((s) => s.name === IN_TMUX_STATUS_NAME);
434
443
  expect(inTmuxEntry).toBeDefined();
435
- expect(inTmuxEntry?.id).toBe('id-6');
444
+ expect(inTmuxEntry?.id).toBe('id-7');
436
445
  expect(payload.some((s) => s.name === LEGACY_IN_TMUX_STATUS_NAME)).toBe(
437
446
  false,
438
447
  );
439
448
  });
440
449
 
450
+ it('should create the "Todo by agent" status with blue color when it is missing', async () => {
451
+ const mockProjectRepository =
452
+ mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
453
+ const mockIssueRepository =
454
+ mock<Pick<IssueRepository, 'getAllIssues' | 'updateStatus'>>();
455
+ const statuses = buildCanonicalStatuses().filter(
456
+ (s) => s.name !== TODO_BY_AGENT_STATUS_NAME,
457
+ );
458
+ const project = buildProject(statuses);
459
+ mockProjectRepository.getByUrl.mockResolvedValue(project);
460
+ mockProjectRepository.updateStatusList.mockResolvedValue([]);
461
+ mockIssueRepository.getAllIssues.mockResolvedValue({
462
+ project: mock<Project>(),
463
+ issues: [],
464
+ cacheUsed: false,
465
+ });
466
+
467
+ const useCase = new SetupTowerDefenceProjectUseCase(
468
+ mockProjectRepository,
469
+ mockIssueRepository,
470
+ );
471
+ await useCase.run({ projectUrl: project.url });
472
+
473
+ expect(mockProjectRepository.updateStatusList).toHaveBeenCalledTimes(1);
474
+ const [, payload] = mockProjectRepository.updateStatusList.mock.calls[0];
475
+ const todoByAgentEntry = payload.find(
476
+ (s) => s.name === TODO_BY_AGENT_STATUS_NAME,
477
+ );
478
+ expect(todoByAgentEntry).toBeDefined();
479
+ expect(todoByAgentEntry?.id).toBeNull();
480
+ expect(todoByAgentEntry?.color).toBe('BLUE');
481
+ const names = payload.map((s) => s.name);
482
+ expect(names.indexOf(TODO_BY_AGENT_STATUS_NAME)).toBe(
483
+ names.indexOf(TODO_STATUS_NAME) + 1,
484
+ );
485
+ });
486
+
487
+ it('should leave an already-present "Todo by agent" option unchanged by reusing its existing option ID', async () => {
488
+ const mockProjectRepository =
489
+ mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
490
+ const mockIssueRepository =
491
+ mock<Pick<IssueRepository, 'getAllIssues' | 'updateStatus'>>();
492
+ const statuses = buildCanonicalStatuses().map((s) => {
493
+ if (s.name === TODO_BY_AGENT_STATUS_NAME) {
494
+ return { ...s, id: 'preexisting-todo-agent-id' };
495
+ }
496
+ if (s.name === DEFAULT_STATUS_NAME) {
497
+ return { ...s, description: 'stale description' };
498
+ }
499
+ return s;
500
+ });
501
+ const project = buildProject(statuses);
502
+ mockProjectRepository.getByUrl.mockResolvedValue(project);
503
+ mockProjectRepository.updateStatusList.mockResolvedValue([]);
504
+ mockIssueRepository.getAllIssues.mockResolvedValue({
505
+ project: mock<Project>(),
506
+ issues: [],
507
+ cacheUsed: false,
508
+ });
509
+
510
+ const useCase = new SetupTowerDefenceProjectUseCase(
511
+ mockProjectRepository,
512
+ mockIssueRepository,
513
+ );
514
+ await useCase.run({ projectUrl: project.url });
515
+
516
+ expect(mockProjectRepository.updateStatusList).toHaveBeenCalledTimes(1);
517
+ const [, payload] = mockProjectRepository.updateStatusList.mock.calls[0];
518
+ const todoByAgentEntry = payload.find(
519
+ (s) => s.name === TODO_BY_AGENT_STATUS_NAME,
520
+ );
521
+ expect(todoByAgentEntry?.id).toBe('preexisting-todo-agent-id');
522
+ expect(todoByAgentEntry?.color).toBe('BLUE');
523
+ expect(
524
+ payload.filter((s) => s.name === TODO_BY_AGENT_STATUS_NAME),
525
+ ).toHaveLength(1);
526
+ });
527
+
441
528
  it('should create the "In Tmux by agent" status with yellow color when it is missing', async () => {
442
529
  const mockProjectRepository =
443
530
  mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
@@ -627,6 +714,7 @@ describe('SetupTowerDefenceProjectUseCase', () => {
627
714
  FAILED_PREPARATION_STATUS_NAME,
628
715
  AWAITING_QUALITY_CHECK_STATUS_NAME,
629
716
  TODO_STATUS_NAME,
717
+ TODO_BY_AGENT_STATUS_NAME,
630
718
  IN_TMUX_STATUS_NAME,
631
719
  IN_TMUX_BY_AGENT_STATUS_NAME,
632
720
  DONE_STATUS_NAME,
@@ -764,6 +852,7 @@ describe('SetupTowerDefenceProjectUseCase', () => {
764
852
  FAILED_PREPARATION_STATUS_NAME,
765
853
  AWAITING_QUALITY_CHECK_STATUS_NAME,
766
854
  TODO_STATUS_NAME,
855
+ TODO_BY_AGENT_STATUS_NAME,
767
856
  IN_TMUX_STATUS_NAME,
768
857
  IN_TMUX_BY_AGENT_STATUS_NAME,
769
858
  DONE_STATUS_NAME,
@@ -917,6 +1006,7 @@ describe('SetupTowerDefenceProjectUseCase', () => {
917
1006
  FAILED_PREPARATION_STATUS_NAME,
918
1007
  AWAITING_QUALITY_CHECK_STATUS_NAME,
919
1008
  TODO_STATUS_NAME,
1009
+ TODO_BY_AGENT_STATUS_NAME,
920
1010
  IN_TMUX_STATUS_NAME,
921
1011
  IN_TMUX_BY_AGENT_STATUS_NAME,
922
1012
  DONE_STATUS_NAME,
@@ -23,6 +23,7 @@ const STATUS_OPTIONS: FieldOption[] = [
23
23
  storyOption('st-failed', 'Failed Preparation', 'RED'),
24
24
  storyOption('st-aqc', 'Awaiting Quality Check', 'GREEN'),
25
25
  storyOption('st-todo', 'Todo by human', 'PINK'),
26
+ storyOption('st-todo-agent', 'Todo by agent', 'BLUE'),
26
27
  storyOption('st-tmux', 'In Tmux by human', 'RED'),
27
28
  storyOption('st-tmux-agent', 'In Tmux by agent', 'YELLOW'),
28
29
  storyOption('st-done', 'Done', 'PURPLE'),
@@ -430,6 +431,7 @@ describe('GenerateConsoleListsUseCase', () => {
430
431
  'Unread',
431
432
  'In Tmux by human',
432
433
  'In Tmux by agent',
434
+ 'Todo by agent',
433
435
  ]) {
434
436
  expect(names).not.toContain(excluded);
435
437
  }
@@ -146,6 +146,7 @@ export class GenerateConsoleListsUseCase {
146
146
  'unread',
147
147
  'in tmux by human',
148
148
  'in tmux by agent',
149
+ 'todo by agent',
149
150
  ],
150
151
  ),
151
152
  'todo-by-human': buildStatusTab(
@@ -6,11 +6,15 @@ import { ImageFetcher } from './consoleImageProxy';
6
6
  export declare const DEFAULT_WEB_PORT = 9981;
7
7
  export declare const DEFAULT_DASHBOARD_PROJECT_NAMES: string[];
8
8
  export declare const CONSOLE_TOKEN_HEADER = "x-pv-token";
9
+ export declare const CONSOLE_TOKEN_COOKIE = "pv_token";
9
10
  export declare const hasDotSegment: (requestPath: string) => boolean;
10
11
  export declare const requiresToken: (requestPath: string) => boolean;
11
12
  export declare const isConsoleAppRoute: (requestPath: string) => boolean;
12
13
  export declare const isTokenValid: (expectedToken: string, providedToken: string | null) => boolean;
13
- export declare const extractProvidedToken: (queryToken: string | string[] | null, headerToken: string | string[] | undefined) => string | null;
14
+ export declare const extractCookieToken: (cookieHeader: string | undefined) => string | null;
15
+ export declare const extractProvidedToken: (queryToken: string | string[] | null, headerToken: string | string[] | undefined, cookieToken: string | null) => string | null;
16
+ export declare const buildTokenCookie: (token: string) => string;
17
+ export declare const buildKeylessLocation: (requestUrl: URL) => string;
14
18
  export type WebServerOptions = {
15
19
  accessToken: string;
16
20
  uiDistDir: string;
@@ -1 +1 @@
1
- {"version":3,"file":"webServer.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/console/webServer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAG7B,OAAO,EAAE,eAAe,EAAE,MAAM,6DAA6D,CAAC;AAM9F,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EAQvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAEL,sBAAsB,EACtB,sBAAsB,EAMvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,YAAY,EAAqB,MAAM,qBAAqB,CAAC;AAOtE,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC,eAAO,MAAM,+BAA+B,UAA0B,CAAC;AAEvE,eAAO,MAAM,oBAAoB,eAAe,CAAC;AAmCjD,eAAO,MAAM,aAAa,GAAI,aAAa,MAAM,KAAG,OAGiB,CAAC;AAEtE,eAAO,MAAM,aAAa,GAAI,aAAa,MAAM,KAAG,OAGrB,CAAC;AAIhC,eAAO,MAAM,iBAAiB,GAAI,aAAa,MAAM,KAAG,OAmBvD,CAAC;AAEF,eAAO,MAAM,YAAY,GACvB,eAAe,MAAM,EACrB,eAAe,MAAM,GAAG,IAAI,KAC3B,OAAoE,CAAC;AAExE,eAAO,MAAM,oBAAoB,GAC/B,YAAY,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,EACpC,aAAa,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,KACzC,MAAM,GAAG,IAQX,CAAC;AAuCF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IACnC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IACzC,cAAc,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAC/C,kBAAkB,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAC;IACnD,oBAAoB,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACnD,sBAAsB,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAC;CACxD,CAAC;AAMF,eAAO,MAAM,sBAAsB,cAAc,CAAC;AAElD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAInD,eAAO,MAAM,wBAAwB,GACnC,cAAc,MAAM,EACpB,aAAa,MAAM,KAClB,MAAM,GAAG,IAWX,CAAC;AAEF,eAAO,MAAM,yBAAyB,GACpC,eAAe,MAAM,EACrB,aAAa,MAAM,KAClB,MAAM,GAAG,IAeX,CAAC;AA0VF,eAAO,MAAM,uBAAuB,GAClC,SAAS,gBAAgB,EACzB,aAAa,MAAM,KAClB,MAAM,GAAG,IAeX,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAC3B,SAAS,gBAAgB,EACzB,SAAS,IAAI,CAAC,eAAe,EAC7B,UAAU,IAAI,CAAC,cAAc,KAC5B,OAAO,CAAC,IAAI,CAwEd,CAAC;AAcF,eAAO,MAAM,eAAe,GAAI,SAAS,gBAAgB,KAAG,IAAI,CAAC,MAM7D,CAAC;AAEL,MAAM,MAAM,qBAAqB,GAAG,gBAAgB,GAAG;IACrD,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,eAAO,MAAM,cAAc,GACzB,SAAS,qBAAqB,KAC7B,OAAO,CAAC,IAAI,CAAC,MAAM,CAQlB,CAAC"}
1
+ {"version":3,"file":"webServer.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/console/webServer.ts"],"names":[],"mappings":"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"}