specrails-desktop 2.53.0 → 2.55.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.
- package/client/dist/assets/{ActivityFeedPage-CEWktcV8.js → ActivityFeedPage-ILXMZXcb.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-Bnu8aoF2.js → AgentBrowserCapture-UcsupCy7.js} +1 -1
- package/client/dist/assets/{AgentModeAnalyticsPane-N0k6nlj0.js → AgentModeAnalyticsPane-CQuxYh8B.js} +2 -2
- package/client/dist/assets/{AgentModeCodePane-7s6TnZGe.js → AgentModeCodePane-zPWbW7ei.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-Btsb5Sq4.js → AgentModeJobsPane-CInByIdl.js} +1 -1
- package/client/dist/assets/{AgentsPage-BXgiO5qZ.js → AgentsPage-BWvgxaBv.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-CO8-ON4i.js → AnalyticsPage-DP3jakv-.js} +1 -1
- package/client/dist/assets/{CodePage-CMSInNsE.js → CodePage-BKXiwE5a.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-1ekD8Gob.js → DesktopAnalyticsPage-DryJOaMz.js} +1 -1
- package/client/dist/assets/{DocsDialog-CZ1QztVB.js → DocsDialog-BsCG9ksG.js} +1 -1
- package/client/dist/assets/{DocsPage-DHdvuLeh.js → DocsPage-Cc-GeKYl.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-DDYgxV10.js → InteractiveJobComposer-DfLF6N0f.js} +1 -1
- package/client/dist/assets/{JobDetailModal-DSsX48Vu.js → JobDetailModal-DHeur9YK.js} +1 -1
- package/client/dist/assets/{JobDetailPage-DbzshFhr.js → JobDetailPage-CraDKzeG.js} +1 -1
- package/client/dist/assets/{JobsPage-DY4qRt9_.js → JobsPage-CcbuSwj7.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-DTqzBtuf.js → LoopBuilderPage-BKtNp494.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-C-NbAvIZ.js → LoopPreviewModal-DbZd9isy.js} +1 -1
- package/client/dist/assets/{LoopsPage-C-RjiAxo.js → LoopsPage-DEg6sIkG.js} +1 -1
- package/client/dist/assets/{PluginsPage-BUdxGBro.js → PluginsPage-BVqSsPzL.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-e7y_uZIz.js → ProjectSettingsDialog-CAjLgJAZ.js} +1 -1
- package/client/dist/assets/{ReviewPacketPage-Bs_jaLbJ.js → ReviewPacketPage-D-UjTef3.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-DoeJuvcY.js → TemplatePreviewModal-Bqs-9fhn.js} +1 -1
- package/client/dist/assets/{index-Cn-T8bgL.js → index-BfAZg5DG.js} +6 -6
- package/client/dist/index.html +1 -1
- package/docs/ci-cd.md +4 -0
- package/package.json +1 -1
- package/server/dist/agent-chat-manager.js +17 -7
- package/server/dist/agent-chat-router.js +1 -0
- package/server/dist/mobile/mobile-code.js +100 -0
- package/server/dist/mobile/mobile-missions.js +26 -4
- package/server/dist/mobile/mobile-router.js +60 -6
- package/server/dist/mobile/mobile-signal-reconnect.js +17 -3
package/client/dist/index.html
CHANGED
|
@@ -204,7 +204,7 @@
|
|
|
204
204
|
.specrails-splash__mark .pill { opacity: 1; }
|
|
205
205
|
}
|
|
206
206
|
</style>
|
|
207
|
-
<script type="module" crossorigin src="/assets/index-
|
|
207
|
+
<script type="module" crossorigin src="/assets/index-BfAZg5DG.js"></script>
|
|
208
208
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
|
|
209
209
|
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-zu2_FqZY.js">
|
|
210
210
|
<link rel="modulepreload" crossorigin href="/assets/react-dom-DiWzBDP9.js">
|
package/docs/ci-cd.md
CHANGED
|
@@ -41,6 +41,10 @@ The macOS job retains `artifacts/chromium-signing/` as the `chromium-signing-mac
|
|
|
41
41
|
|
|
42
42
|
## Credentials and external requirements
|
|
43
43
|
|
|
44
|
+
### Recover an already published Desktop download channel
|
|
45
|
+
|
|
46
|
+
If native builds and GitHub publication succeed but Hostinger installer uploads fail, run **Recover Desktop download channel** on `main` with the existing stable version (for example `2.54.0`). It requires successful main CI and successful native builds for that exact release commit, then streams all three published installers and verifies their sizes and GitHub SHA-256 digests. It atomically replaces only `latest/manifest.json` over verified FTPS, pointing downloads to the existing version-specific GitHub assets. It refuses version downgrades, verifies the public manifest after publication, and does not rebuild, delete installers, or alter the signed Tauri updater. Normal releases continue to use the regular distribution workflow.
|
|
47
|
+
|
|
44
48
|
- `RELEASE_PAT` must be authorized for release-please's pull requests and releases. It also permits tag pushes to trigger publication workflows; events created with the repository's ordinary `GITHUB_TOKEN` generally do not trigger another workflow.
|
|
45
49
|
- Publication uses one of two credentials. When the `NPM_TOKEN` secret is set, it must authenticate an account with publishing permission for `specrails-desktop` (npm now requires such tokens to be granular with *bypass 2FA*, and they expire within 90 days); the workflow checks authentication and gives an explicit error before publishing. When the secret is empty, the job publishes through npm **trusted publishing**: it installs npm 11, removes the empty token line that `setup-node` writes, and lets npm exchange the job's OIDC identity (`id-token: write`) for a short-lived publish grant. That path only works once the package's *Trusted publisher* on npmjs.com names this repository (`fjpulidop/specrails-desktop`) and workflow file (`npm-publish.yml`, no environment); registry configuration is never changed from here. Provenance does **not** grant publishing permission in either mode. An npm `E404` during publication is npm's answer to an unauthorized publish and does not identify which token or permission failed; an `E403` mentioning two-factor authentication means the stored token lacks *bypass 2FA*. [npm publishing and provenance](https://docs.github.com/en/actions/tutorials/publish-packages/publish-nodejs-packages).
|
|
46
50
|
- Existing Apple signing/notarization and Tauri updater signing secrets remain required. Installer build steps retain their platform-specific checks.
|
package/package.json
CHANGED
|
@@ -194,6 +194,9 @@ class AgentChatManager {
|
|
|
194
194
|
})),
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
|
+
notifyConversationCreated(conversationId) {
|
|
198
|
+
this._broadcast({ type: 'agent_conversation_created', conversationId, timestamp: new Date().toISOString() });
|
|
199
|
+
}
|
|
197
200
|
_startLifecycle(conversationId) {
|
|
198
201
|
const startedAt = new Date().toISOString();
|
|
199
202
|
this._turnStartedAt.set(conversationId, startedAt);
|
|
@@ -400,13 +403,20 @@ class AgentChatManager {
|
|
|
400
403
|
const currentMessageId = options.queueId ? (0, agent_input_store_1.getAgentInput)(this._db, conversationId, options.queueId)?.messageId : null;
|
|
401
404
|
const historyBlock = (0, agent_context_resolver_1.buildAgentHistoryBlock)((0, agent_input_store_1.decorateAgentInputMessages)(this._db, (0, agent_store_1.listAgentMessages)(this._db, conversationId))
|
|
402
405
|
.filter((message) => message.id !== currentMessageId && message.delivery_status !== 'cancelled' && message.delivery_status !== 'interrupted'));
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
406
|
+
const message = options.queueId
|
|
407
|
+
? (0, agent_input_store_1.deliverAgentInput)(this._db, conversationId, options.queueId, 'sent')
|
|
408
|
+
: (0, agent_store_1.addAgentMessage)(this._db, { conversationId, role: 'user', content: userText, attachmentIds, contextRefs: options.contextRefs });
|
|
409
|
+
if (!message)
|
|
410
|
+
return;
|
|
411
|
+
// Queued turns were announced by the drain loop. Direct turns must also
|
|
412
|
+
// reach every open client, including Desktop when Companion sent the input.
|
|
413
|
+
if (!currentMessageId)
|
|
414
|
+
this._broadcast({
|
|
415
|
+
type: 'agent_input_started', conversationId, queueId: options.queueId ?? null,
|
|
416
|
+
messageId: message.id, text: message.content, timestamp: message.created_at,
|
|
417
|
+
contextRefs: options.contextRefs ?? [], attachmentIds: attachmentIds ?? [],
|
|
418
|
+
deliveryReceipt: 'sent',
|
|
419
|
+
});
|
|
410
420
|
this._autoTitle(conversationId, conversation.title);
|
|
411
421
|
// Providers WITHOUT a --system-prompt flag (codex, gemini) drop opts.systemPrompt
|
|
412
422
|
// for chat turns, so the attachment prompt-injection note would never reach them —
|
|
@@ -199,6 +199,7 @@ function createAgentChatRouter(deps) {
|
|
|
199
199
|
// Off-catalog values fall back to null (= no provider-specific override).
|
|
200
200
|
reasoningEffort: validEffort(provider, effectiveModel, body.reasoningEffort) ?? null,
|
|
201
201
|
});
|
|
202
|
+
manager.notifyConversationCreated(conversation.id);
|
|
202
203
|
res.status(201).json({ conversation });
|
|
203
204
|
});
|
|
204
205
|
router.get('/conversations/:id', (req, res) => {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.relativeCodePath = relativeCodePath;
|
|
4
|
+
exports.createMobileCodeRouter = createMobileCodeRouter;
|
|
5
|
+
const express_1 = require("express");
|
|
6
|
+
const mobile_redact_1 = require("./mobile-redact");
|
|
7
|
+
const id = /^[A-Za-z0-9_-]{1,160}$/;
|
|
8
|
+
function relativeCodePath(value) {
|
|
9
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 4096
|
|
10
|
+
&& !/^[\\/]|^[A-Za-z]:|[\x00-\x1f\\]/.test(value)
|
|
11
|
+
&& !value.split('/').some(part => part === '..' || part === '.' || part === '');
|
|
12
|
+
}
|
|
13
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
14
|
+
/** Project ACL/auth run on the parent router. Desktop remains authoritative for
|
|
15
|
+
* repository membership, realpath confinement, deny-list and gitignore rules.
|
|
16
|
+
* Only this explicit source DTO preserves source text verbatim: applying the
|
|
17
|
+
* generic absolute-path scrubber to code would silently corrupt its contents.
|
|
18
|
+
*/
|
|
19
|
+
function createMobileCodeRouter(upstream) {
|
|
20
|
+
const router = (0, express_1.Router)();
|
|
21
|
+
for (const operation of ['tree', 'find', 'file']) {
|
|
22
|
+
router.get(`/projects/:pid/repositories/:rid/code/${operation}`, async (req, res) => {
|
|
23
|
+
const pid = req.params.pid, rid = req.params.rid;
|
|
24
|
+
if (!id.test(pid) || !id.test(rid)) {
|
|
25
|
+
res.status(400).json({ error: 'Invalid parameter' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const query = new URLSearchParams();
|
|
29
|
+
if (operation === 'tree') {
|
|
30
|
+
query.set('filter', 'all');
|
|
31
|
+
query.set('limit', '200');
|
|
32
|
+
if (req.query.cursor !== undefined) {
|
|
33
|
+
if (typeof req.query.cursor !== 'string' || req.query.cursor.length > 4096) {
|
|
34
|
+
res.status(400).json({ error: 'Invalid cursor' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
query.set('cursor', req.query.cursor);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (operation === 'find') {
|
|
41
|
+
if (typeof req.query.q !== 'string' || !req.query.q.trim() || req.query.q.length > 256) {
|
|
42
|
+
res.status(400).json({ error: 'Invalid query' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
query.set('q', req.query.q);
|
|
46
|
+
query.set('limit', '50');
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (!relativeCodePath(req.query.path)) {
|
|
50
|
+
res.status(400).json({ error: 'Invalid relative path' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
query.set('path', req.query.path);
|
|
54
|
+
for (const key of ['startLine', 'startColumn']) {
|
|
55
|
+
const value = req.query[key] ?? '1';
|
|
56
|
+
if (typeof value !== 'string' || !/^[1-9]\d{0,8}$/.test(value)) {
|
|
57
|
+
res.status(400).json({ error: 'Invalid range' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
query.set(key, value);
|
|
61
|
+
}
|
|
62
|
+
query.set('endLine', String(Number(query.get('startLine')) + 199));
|
|
63
|
+
if (req.query.expectedHash !== undefined) {
|
|
64
|
+
if (typeof req.query.expectedHash !== 'string' || !/^[a-f0-9]{64}$/i.test(req.query.expectedHash)) {
|
|
65
|
+
res.status(400).json({ error: 'Invalid hash' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
query.set('expectedHash', req.query.expectedHash);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const response = await upstream('GET', `/api/projects/${encodeURIComponent(pid)}/repositories/${encodeURIComponent(rid)}/code/${operation}?${query}`);
|
|
73
|
+
if (response.status < 200 || response.status >= 300) {
|
|
74
|
+
res.status(response.status).json((0, mobile_redact_1.redact)(response.json));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const data = record(response.json);
|
|
78
|
+
if (operation === 'file') {
|
|
79
|
+
if (!relativeCodePath(data.path) || data.path !== req.query.path || typeof data.content !== 'string') {
|
|
80
|
+
res.status(502).json({ error: 'Invalid source response' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
res.json({ relativePath: data.path, content: data.content, language: data.language,
|
|
84
|
+
fileHash: data.fileHash, startLine: data.startLine, startColumn: data.startColumn,
|
|
85
|
+
endLine: data.endLine, totalLines: data.totalLines, nextLine: data.nextLine, nextColumn: data.nextColumn });
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const rows = data[operation === 'tree' ? 'entries' : 'matches'];
|
|
89
|
+
res.json({ entries: (Array.isArray(rows) ? rows : []).map(record).filter(row => relativeCodePath(row.path)).map(row => ({
|
|
90
|
+
relativePath: row.path, kind: row.kind === 'dir' ? 'dir' : 'file', sizeBytes: row.sizeBytes,
|
|
91
|
+
})), nextCursor: operation === 'tree' ? data.nextCursor : null, truncated: data.truncated === true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
res.status(502).json({ error: 'Code explorer unavailable' });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return router;
|
|
100
|
+
}
|
|
@@ -4,6 +4,7 @@ exports.MISSION_ACCESS_REASON = exports.MOBILE_PROTOCOL_VERSION = void 0;
|
|
|
4
4
|
exports.mobileCapabilities = mobileCapabilities;
|
|
5
5
|
exports.missionSnapshot = missionSnapshot;
|
|
6
6
|
exports.createMobileMissionsRouter = createMobileMissionsRouter;
|
|
7
|
+
const feature_flags_1 = require("../feature-flags");
|
|
7
8
|
const node_string_decoder_1 = require("node:string_decoder");
|
|
8
9
|
const express_1 = require("express");
|
|
9
10
|
const agent_store_1 = require("../agent-store");
|
|
@@ -17,7 +18,7 @@ exports.MOBILE_PROTOCOL_VERSION = 2;
|
|
|
17
18
|
exports.MISSION_ACCESS_REASON = 'Mission agents can access more than their pinned project. Missions require an all-projects device grant; your permitted Specs and Rails remain available.';
|
|
18
19
|
function mobileCapabilities(db, deviceId) {
|
|
19
20
|
const missions = process.env.SPECRAILS_AGENT_CHAT !== 'false' && (0, mobile_devices_1.getAllowedProjects)(db, deviceId) === null;
|
|
20
|
-
return { protocolVersion: exports.MOBILE_PROTOCOL_VERSION, features: { missions, missionControl: missions, missionQueue: missions, missionProcesses: missions, repositories: true },
|
|
21
|
+
return { protocolVersion: exports.MOBILE_PROTOCOL_VERSION, features: { missions, missionControl: missions, missionQueue: missions, missionProcesses: missions, repositories: true, codeExplorer: (0, feature_flags_1.isCodeExplorerEnabled)(), missionRailCards: missions && (0, feature_flags_1.isMissionRailCardsEnabled)() },
|
|
21
22
|
...(!missions ? { missionsUnavailableReason: process.env.SPECRAILS_AGENT_CHAT === 'false' ? 'Missions are disabled on Desktop.' : exports.MISSION_ACCESS_REASON } : {}) };
|
|
22
23
|
}
|
|
23
24
|
function conversationView(value) {
|
|
@@ -56,7 +57,7 @@ function missionSnapshot(value) {
|
|
|
56
57
|
try {
|
|
57
58
|
const parsed = record(JSON.parse(raw));
|
|
58
59
|
if (parsed.kind === 'pr_decision')
|
|
59
|
-
card = { ...pick(parsed, ['kind', 'railIndex', 'ticketIds', 'decision', 'implementationOutcome', 'deliveryOutcome', 'prNumber']), repositoryDeliveries: (Array.isArray(parsed.repositoryDeliveries) ? parsed.repositoryDeliveries : []).slice(0, 30).map(value => pick(value, ['repositoryId', 'repositoryName', 'decision'])) };
|
|
60
|
+
card = { ...pick(parsed, ['kind', 'railIndex', 'ticketIds', 'decision', 'implementationOutcome', 'deliveryOutcome', 'prNumber', 'railName', 'phase', 'statusDetail', 'baseBranch']), runIds: (Array.isArray(parsed.runIds) ? parsed.runIds : []).filter(id => typeof id === 'string' && ID.test(id)).slice(0, 30), runtime: pick(parsed.runtime, ['status', 'currentStep', 'pendingApproval']), repositoryDeliveries: (Array.isArray(parsed.repositoryDeliveries) ? parsed.repositoryDeliveries : []).slice(0, 30).map(value => pick(value, ['repositoryId', 'repositoryName', 'decision'])) };
|
|
60
61
|
}
|
|
61
62
|
catch { /* Non-card system rows do not expose internal provider payloads. */ }
|
|
62
63
|
}
|
|
@@ -65,7 +66,7 @@ function missionSnapshot(value) {
|
|
|
65
66
|
break;
|
|
66
67
|
}
|
|
67
68
|
const content = record(message).role === 'system' ? '' : take(raw, 40_000);
|
|
68
|
-
bounded.unshift({ ...pick(message, ['id', 'role', 'created_at', 'delivery_status', 'delivery_receipt']), content, ...(card ? { card } : {}) });
|
|
69
|
+
bounded.unshift({ ...pick(message, ['id', 'role', 'created_at', 'delivery_status', 'delivery_receipt']), content, intents: (Array.isArray(record(message).intents) ? record(message).intents : []).slice(0, 30).map(value => pick(value, ['kind', 'proposalIndex', 'status', 'railIndex', 'runIds'])), ...(card ? { card } : {}) });
|
|
69
70
|
}
|
|
70
71
|
return { conversation: conversationView(data.conversation), messages: bounded, truncated,
|
|
71
72
|
pendingMessages, pendingTruncated: pending.length > 50,
|
|
@@ -90,7 +91,12 @@ function createMobileMissionsRouter({ db, upstream }) {
|
|
|
90
91
|
res.status(400).json({ error: 'A valid provider is required' });
|
|
91
92
|
return;
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
+
const model = segment(req.query.model);
|
|
95
|
+
if (model.length > 200) {
|
|
96
|
+
res.status(400).json({ error: 'Invalid model' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
void send(res, 'GET', `/api/agent/models?provider=${encodeURIComponent(provider)}${model ? `&model=${encodeURIComponent(model)}` : ''}`);
|
|
94
100
|
});
|
|
95
101
|
router.get('/projects/:pid/repositories', (req, res) => {
|
|
96
102
|
const pid = segment(req.params.pid);
|
|
@@ -175,6 +181,22 @@ function createMobileMissionsRouter({ db, upstream }) {
|
|
|
175
181
|
}
|
|
176
182
|
void send(res, 'POST', `${id.base}/send`, { text: b.text, queueId: b.queueId, deliveryMode: b.deliveryMode ?? 'queue' }, data => pick(data, ['accepted', 'queued', 'duplicate', 'removed', 'deliveryMode']));
|
|
177
183
|
});
|
|
184
|
+
router.patch('/projects/:pid/missions/:cid/messages/:mid/intent', (req, res) => {
|
|
185
|
+
const id = identity(req, res);
|
|
186
|
+
if (!id)
|
|
187
|
+
return;
|
|
188
|
+
const mid = segment(req.params.mid), b = record(req.body);
|
|
189
|
+
if (!ID.test(mid) || b.kind !== 'rail-launch' || !Number.isSafeInteger(b.proposalIndex) || Number(b.proposalIndex) < 0 || Number(b.proposalIndex) > 100) {
|
|
190
|
+
res.status(400).json({ error: 'Invalid proposal' });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const message = db.prepare('SELECT conversation_id FROM agent_messages WHERE id = ?').get(mid);
|
|
194
|
+
if (!message || message.conversation_id !== segment(req.params.cid)) {
|
|
195
|
+
res.status(404).json({ error: 'Message not found in this mission' });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
void send(res, 'PATCH', `${id.base}/messages/${encodeURIComponent(mid)}/intent`, pick(b, ['kind', 'proposalIndex', 'status', 'railIndex', 'runIds', 'prDeliveryId']), data => ({ message: pick(record(data).message, ['id', 'intents']) }));
|
|
199
|
+
});
|
|
178
200
|
router.post('/projects/:pid/missions/:cid/abort', (req, res) => { const id = identity(req, res); if (id)
|
|
179
201
|
void send(res, 'POST', `${id.base}/abort`, {}); });
|
|
180
202
|
for (const action of ['edit', 'steer', 'remove']) {
|
|
@@ -8,6 +8,7 @@ const mobile_redact_1 = require("./mobile-redact");
|
|
|
8
8
|
const mobile_auth_1 = require("./mobile-auth");
|
|
9
9
|
const mobile_devices_1 = require("./mobile-devices");
|
|
10
10
|
const mobile_missions_1 = require("./mobile-missions");
|
|
11
|
+
const mobile_code_1 = require("./mobile-code");
|
|
11
12
|
// The gateway's authenticated REST surface: the `/v1/*` allow-list. Each route
|
|
12
13
|
// forwards, in-process, via a REAL loopback HTTP request to
|
|
13
14
|
// http://127.0.0.1:<desktopPort> with the master token injected server-side as
|
|
@@ -187,6 +188,23 @@ function createMobileRouter(deps) {
|
|
|
187
188
|
return;
|
|
188
189
|
void forward(res, 'DELETE', `/api/projects/${encodeURIComponent(pid)}/tickets/${encodeURIComponent(tid)}`, '');
|
|
189
190
|
});
|
|
191
|
+
v1.post('/projects/:pid/rails', (req, res) => {
|
|
192
|
+
const pid = seg(req.params.pid);
|
|
193
|
+
if (!validate(res, [pid, PID_RE]))
|
|
194
|
+
return;
|
|
195
|
+
const name = req.body?.name;
|
|
196
|
+
if (name != null && (typeof name !== 'string' || name.length > 60)) {
|
|
197
|
+
res.status(400).json({ error: 'Invalid rail name' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
void forward(res, 'POST', `/api/projects/${encodeURIComponent(pid)}/rails`, '', { name: name ?? null });
|
|
201
|
+
});
|
|
202
|
+
v1.get('/projects/:pid/profiles', (req, res) => {
|
|
203
|
+
const pid = seg(req.params.pid), provider = seg(req.query.provider);
|
|
204
|
+
if (!validate(res, [pid, PID_RE], [provider, PID_RE]))
|
|
205
|
+
return;
|
|
206
|
+
void forward(res, 'GET', `/api/projects/${encodeURIComponent(pid)}/profiles`, `?provider=${encodeURIComponent(provider)}`);
|
|
207
|
+
});
|
|
190
208
|
v1.put('/projects/:pid/rails/:i/tickets', (req, res) => {
|
|
191
209
|
const pid = seg(req.params.pid), i = seg(req.params.i);
|
|
192
210
|
if (!validate(res, [pid, PID_RE], [i, NUM_RE]))
|
|
@@ -219,6 +237,33 @@ function createMobileRouter(deps) {
|
|
|
219
237
|
// provider. This gateway only narrows to the cross-provider enum.
|
|
220
238
|
narrowed.reasoning_effort = b.reasoning_effort;
|
|
221
239
|
}
|
|
240
|
+
if (b.targetPrNumber !== undefined) {
|
|
241
|
+
if (!Number.isSafeInteger(b.targetPrNumber) || Number(b.targetPrNumber) < 1) {
|
|
242
|
+
res.status(400).json({ error: 'Invalid PR number' });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
narrowed.targetPrNumber = b.targetPrNumber;
|
|
246
|
+
}
|
|
247
|
+
if (b.baseBranch !== undefined) {
|
|
248
|
+
if (typeof b.baseBranch !== 'string' || !b.baseBranch.trim() || b.baseBranch.length > 200) {
|
|
249
|
+
res.status(400).json({ error: 'Invalid base branch' });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
narrowed.baseBranch = b.baseBranch;
|
|
253
|
+
}
|
|
254
|
+
if (b.originConversationId !== undefined) {
|
|
255
|
+
if (typeof b.originConversationId !== 'string' || !PID_RE.test(b.originConversationId)) {
|
|
256
|
+
res.status(400).json({ error: 'Invalid mission' });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const conversation = deps.db.prepare('SELECT pinned_project_id FROM agent_conversations WHERE id = ?').get(b.originConversationId);
|
|
260
|
+
if (!conversation || conversation.pinned_project_id !== pid || (0, mobile_devices_1.getAllowedProjects)(deps.db, req.mobileDevice.id) !== null) {
|
|
261
|
+
res.status(403).json({ error: 'Mission is not accessible' });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
narrowed.originConversationId = b.originConversationId;
|
|
265
|
+
narrowed.originSurface = 'agent-chat';
|
|
266
|
+
}
|
|
222
267
|
if (typeof b.interactive === 'boolean')
|
|
223
268
|
narrowed.interactive = b.interactive;
|
|
224
269
|
if (b.repositoryIds !== undefined) {
|
|
@@ -404,12 +449,14 @@ function createMobileRouter(deps) {
|
|
|
404
449
|
narrowed.acceptanceCriteria = b.acceptanceCriteria.filter((x) => typeof x === 'string');
|
|
405
450
|
void forward(res, 'POST', `/api/projects/${encodeURIComponent(pid)}/tickets/from-draft`, '', narrowed);
|
|
406
451
|
});
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
452
|
+
const mobileUpstream = async (method, path, body) => {
|
|
453
|
+
const response = await fetch(internalBase + path, { method, signal: AbortSignal.timeout(30_000),
|
|
454
|
+
headers: { 'x-desktop-token': (0, auth_1.loadOrGenerateToken)(), ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
455
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
|
|
456
|
+
return { status: response.status, json: await response.json() };
|
|
457
|
+
};
|
|
458
|
+
v1.use((0, mobile_missions_1.createMobileMissionsRouter)({ db: deps.db, upstream: mobileUpstream }));
|
|
459
|
+
v1.use((0, mobile_code_1.createMobileCodeRouter)(mobileUpstream));
|
|
413
460
|
router.use('/v1', v1);
|
|
414
461
|
// Any unmatched gateway path → 404 JSON (NEVER falls through to a SPA handler;
|
|
415
462
|
// the gateway serves JSON/WS only, no static client).
|
|
@@ -421,8 +468,12 @@ function createMobileRouter(deps) {
|
|
|
421
468
|
/** The allow-list, exported for the CI drift test (asserts each entry resolves
|
|
422
469
|
* against a real internal route, and that no traversal param is accepted). */
|
|
423
470
|
exports.MOBILE_ALLOWLIST = [
|
|
471
|
+
{ method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/tree' },
|
|
472
|
+
{ method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/find' },
|
|
473
|
+
{ method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/file' },
|
|
424
474
|
{ method: 'GET', path: '/v1/capabilities' },
|
|
425
475
|
{ method: 'GET', path: '/v1/mission-models' },
|
|
476
|
+
{ method: 'PATCH', path: '/v1/projects/:pid/missions/:cid/messages/:mid/intent' },
|
|
426
477
|
{ method: 'GET', path: '/v1/projects/:pid/repositories' },
|
|
427
478
|
{ method: 'GET', path: '/v1/projects/:pid/missions' },
|
|
428
479
|
{ method: 'POST', path: '/v1/projects/:pid/missions' },
|
|
@@ -445,6 +496,9 @@ exports.MOBILE_ALLOWLIST = [
|
|
|
445
496
|
{ method: 'GET', path: '/v1/projects/:pid/loop-runs/:id' },
|
|
446
497
|
{ method: 'GET', path: '/v1/projects/:pid/queue' },
|
|
447
498
|
{ method: 'GET', path: '/v1/projects/:pid/rails' },
|
|
499
|
+
{ method: 'POST', path: '/v1/projects/:pid/rails' },
|
|
500
|
+
{ method: 'GET', path: '/v1/projects/:pid/profiles' },
|
|
501
|
+
{ method: 'PUT', path: '/v1/projects/:pid/rails/:i/name' },
|
|
448
502
|
{ method: 'GET', path: '/v1/projects/:pid/activity' },
|
|
449
503
|
{ method: 'GET', path: '/v1/projects/:pid/stats' },
|
|
450
504
|
{ method: 'GET', path: '/v1/projects/:pid/state' },
|
|
@@ -22,6 +22,7 @@ class MobileSignalReconnect {
|
|
|
22
22
|
_deps;
|
|
23
23
|
_timeoutMs;
|
|
24
24
|
_timer = null;
|
|
25
|
+
_attempts = new Map();
|
|
25
26
|
_busy = false;
|
|
26
27
|
_epoch = 0;
|
|
27
28
|
_requests = new Set();
|
|
@@ -34,9 +35,11 @@ class MobileSignalReconnect {
|
|
|
34
35
|
return;
|
|
35
36
|
this._timer = setInterval(() => void this.poll(), intervalMs);
|
|
36
37
|
this._timer.unref?.();
|
|
38
|
+
void this.poll();
|
|
37
39
|
}
|
|
38
40
|
stop() {
|
|
39
41
|
this._epoch++;
|
|
42
|
+
this._attempts.clear();
|
|
40
43
|
for (const controller of this._requests)
|
|
41
44
|
controller.abort();
|
|
42
45
|
this._requests.clear();
|
|
@@ -61,6 +64,14 @@ class MobileSignalReconnect {
|
|
|
61
64
|
if (epoch !== this._epoch)
|
|
62
65
|
return;
|
|
63
66
|
if (req !== null) {
|
|
67
|
+
let requestId = null;
|
|
68
|
+
try {
|
|
69
|
+
const value = JSON.parse(req);
|
|
70
|
+
if (typeof value?.requestId === 'string' && /^[a-zA-Z0-9_-]{8,80}$/.test(value.requestId))
|
|
71
|
+
requestId = value.requestId;
|
|
72
|
+
}
|
|
73
|
+
catch { /* legacy request */ }
|
|
74
|
+
this._attempts.set(room, requestId);
|
|
64
75
|
const offer = await this._deps.makeOffer(room);
|
|
65
76
|
if (epoch !== this._epoch)
|
|
66
77
|
return;
|
|
@@ -68,7 +79,7 @@ class MobileSignalReconnect {
|
|
|
68
79
|
// BUG-AUTH-01: never publish `offer.secret` to the public mailbox.
|
|
69
80
|
// The reconnecting companion authenticates with its existing device
|
|
70
81
|
// token; the secret would only help an eavesdropper.
|
|
71
|
-
await this._post(room, 'offer', JSON.stringify({ sdp: offer.sdp, hub: offer.hubInstanceId, name: offer.hubName }));
|
|
82
|
+
await this._post(room, 'offer', JSON.stringify({ sdp: offer.sdp, hub: offer.hubInstanceId, name: offer.hubName, ...(requestId ? { requestId } : {}) }));
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
// A phone's answer waiting? → complete the connection.
|
|
@@ -80,8 +91,11 @@ class MobileSignalReconnect {
|
|
|
80
91
|
if (ans) {
|
|
81
92
|
try {
|
|
82
93
|
const parsed = JSON.parse(ans);
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
const expected = this._attempts.get(room);
|
|
95
|
+
if (expected && parsed.requestId !== expected)
|
|
96
|
+
continue;
|
|
97
|
+
if (typeof parsed.sdp === 'string' && await this._deps.acceptAnswer(room, parsed.sdp))
|
|
98
|
+
this._attempts.delete(room);
|
|
85
99
|
}
|
|
86
100
|
catch {
|
|
87
101
|
/* malformed answer — ignore */
|