claude-teammate 0.1.307 → 0.1.309
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/package.json +1 -1
- package/src/claude/prompts.js +16 -0
- package/src/config.js +29 -0
- package/src/dashboard/README.md +77 -0
- package/src/dashboard/app/assets/css/main.css +29 -0
- package/src/dashboard/app/composables/useApi.ts +19 -3
- package/src/dashboard/app/layouts/default.vue +18 -0
- package/src/dashboard/auth.js +275 -0
- package/src/dashboard/server.js +40 -4
- package/src/jira.js +19 -1
- package/src/memory.js +1 -0
- package/src/worker/jira-helpers.js +41 -1
- package/src/worker/jira-issue-workflow.js +13 -1
package/package.json
CHANGED
package/src/claude/prompts.js
CHANGED
|
@@ -179,6 +179,7 @@ export function buildJiraClarificationUserPrompt(input) {
|
|
|
179
179
|
referenceRepos: input.referenceRepos,
|
|
180
180
|
repoPaths: input.repoPaths
|
|
181
181
|
});
|
|
182
|
+
const attachmentsSection = formatIssueAttachments(input.issue.attachments);
|
|
182
183
|
|
|
183
184
|
return `Clarify this Jira issue.
|
|
184
185
|
|
|
@@ -189,6 +190,7 @@ Status: ${input.issue.status}
|
|
|
189
190
|
Description:
|
|
190
191
|
${input.issue.descriptionText || "(none)"}
|
|
191
192
|
|
|
193
|
+
${attachmentsSection}
|
|
192
194
|
Memory snapshot:
|
|
193
195
|
${JSON.stringify(input.memory, null, 2)}
|
|
194
196
|
|
|
@@ -202,6 +204,20 @@ ${reopenContext}
|
|
|
202
204
|
Decide whether the requirements are clear enough to plan implementation and whether the task needs code changes. Read code only if it helps.`;
|
|
203
205
|
}
|
|
204
206
|
|
|
207
|
+
// List attachments already present on the Jira issue so the agent reads
|
|
208
|
+
// material the user attached instead of asking for it. These are downloadable
|
|
209
|
+
// with jira_download_attachments by filename.
|
|
210
|
+
function formatIssueAttachments(attachments) {
|
|
211
|
+
const list = (Array.isArray(attachments) ? attachments : []).filter((item) => item && item.filename);
|
|
212
|
+
if (list.length === 0) {
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
const lines = list.map((item) => `- ${item.filename}${item.mimeType ? ` (${item.mimeType})` : ""}`).join("\n");
|
|
216
|
+
return `Issue attachments (download with jira_download_attachments before planning; read every one relevant to the task):
|
|
217
|
+
${lines}
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
220
|
+
|
|
205
221
|
function formatClarificationRepoScope({ targetRepo, referenceRepos, repoPaths }) {
|
|
206
222
|
const repoPathLines = (Array.isArray(repoPaths) ? repoPaths : []).filter(Boolean);
|
|
207
223
|
|
package/src/config.js
CHANGED
|
@@ -51,6 +51,35 @@ export const REQUIRED_FIELDS = [
|
|
|
51
51
|
prompt: "GitLab personal access token",
|
|
52
52
|
secret: true,
|
|
53
53
|
required: false
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: "DASHBOARD_BASE_URL",
|
|
57
|
+
prompt: "Dashboard public URL (for Google OAuth redirect)",
|
|
58
|
+
example: "https://bot.ignify.co",
|
|
59
|
+
required: false
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: "DASHBOARD_ALLOWED_DOMAIN",
|
|
63
|
+
prompt: "Email domain allowed to sign in",
|
|
64
|
+
example: "ignify.co",
|
|
65
|
+
required: false
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: "GOOGLE_CLIENT_ID",
|
|
69
|
+
prompt: "Google OAuth client ID (dashboard login)",
|
|
70
|
+
required: false
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: "GOOGLE_CLIENT_SECRET",
|
|
74
|
+
prompt: "Google OAuth client secret",
|
|
75
|
+
secret: true,
|
|
76
|
+
required: false
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: "DASHBOARD_AUTH_SECRET",
|
|
80
|
+
prompt: "Random secret for signing dashboard sessions",
|
|
81
|
+
secret: true,
|
|
82
|
+
required: false
|
|
54
83
|
}
|
|
55
84
|
];
|
|
56
85
|
|
package/src/dashboard/README.md
CHANGED
|
@@ -103,6 +103,83 @@ location / {
|
|
|
103
103
|
|
|
104
104
|
---
|
|
105
105
|
|
|
106
|
+
## Authentication (Google OAuth)
|
|
107
|
+
|
|
108
|
+
The dashboard can require Google sign-in restricted to one email domain
|
|
109
|
+
(e.g. `@ignify.co`). Auth is **off** until the env vars below are all set, so
|
|
110
|
+
local/dev usage needs no config.
|
|
111
|
+
|
|
112
|
+
### 1. Create a Google OAuth client
|
|
113
|
+
|
|
114
|
+
1. Google Cloud Console → **APIs & Services → Credentials**.
|
|
115
|
+
2. Configure the **OAuth consent screen** (Internal if `ignify.co` is a Google
|
|
116
|
+
Workspace domain; otherwise External). Scopes: `email`, `profile`, `openid`.
|
|
117
|
+
3. **Create Credentials → OAuth client ID → Web application**.
|
|
118
|
+
4. **Authorized redirect URIs**: add `https://bot.ignify.co/auth/callback`
|
|
119
|
+
5. Copy the **Client ID** and **Client secret**.
|
|
120
|
+
|
|
121
|
+
### 2. Add config to the dashboard `.env` (`~/.tm8/.env`)
|
|
122
|
+
|
|
123
|
+
```ini
|
|
124
|
+
DASHBOARD_BASE_URL=https://bot.ignify.co
|
|
125
|
+
DASHBOARD_ALLOWED_DOMAIN=ignify.co
|
|
126
|
+
GOOGLE_CLIENT_ID=<client id>
|
|
127
|
+
GOOGLE_CLIENT_SECRET=<client secret>
|
|
128
|
+
DASHBOARD_AUTH_SECRET=<random 32+ byte hex> # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 3. nginx must terminate TLS and forward the real protocol
|
|
132
|
+
|
|
133
|
+
Google rejects non-HTTPS redirect URIs. Run the dashboard as plain http on
|
|
134
|
+
`127.0.0.1:7881` behind nginx with a Let's Encrypt cert:
|
|
135
|
+
|
|
136
|
+
```nginx
|
|
137
|
+
server {
|
|
138
|
+
listen 443 ssl;
|
|
139
|
+
server_name bot.ignify.co;
|
|
140
|
+
|
|
141
|
+
ssl_certificate /etc/letsencrypt/live/bot.ignify.co/fullchain.pem;
|
|
142
|
+
ssl_certificate_key /etc/letsencrypt/live/bot.ignify.co/privkey.pem;
|
|
143
|
+
|
|
144
|
+
location / {
|
|
145
|
+
proxy_pass http://127.0.0.1:7881;
|
|
146
|
+
proxy_http_version 1.1;
|
|
147
|
+
proxy_set_header Host $host;
|
|
148
|
+
proxy_set_header X-Forwarded-Proto $scheme; # required for OAuth redirect
|
|
149
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
150
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
151
|
+
proxy_set_header Connection keep-alive;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
server { # redirect http → https
|
|
156
|
+
listen 80;
|
|
157
|
+
server_name bot.ignify.co;
|
|
158
|
+
return 301 https://$host$request_uri;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Get the cert with `sudo certbot --nginx -d bot.ignify.co`.
|
|
163
|
+
|
|
164
|
+
### 4. Rebuild SPA + restart
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
cd src/dashboard/app && npm run generate # rebuild SPA (logout button / 401 redirect)
|
|
168
|
+
sudo systemctl restart tm8-dashboard # reload .env (auth config read at startup)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### How it works
|
|
172
|
+
|
|
173
|
+
- Unauthenticated `/api/*` → `401`; unauthenticated pages → redirect to
|
|
174
|
+
`/auth/login` → Google → `/auth/callback`.
|
|
175
|
+
- The callback verifies the Google id_token, checks `email_verified` and that the
|
|
176
|
+
address ends with `@DASHBOARD_ALLOWED_DOMAIN`, then sets a signed
|
|
177
|
+
(HMAC-SHA256), HttpOnly, Secure, SameSite=Lax session cookie (7-day expiry).
|
|
178
|
+
- No server-side session store — stateless cookie, safe across worker restarts.
|
|
179
|
+
- Changing `.env` requires a dashboard restart (config loaded at startup).
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
106
183
|
## API notes
|
|
107
184
|
|
|
108
185
|
- `/api/status` — worker state, polled every 10 s by the UI
|
|
@@ -272,6 +272,35 @@ body::before {
|
|
|
272
272
|
letter-spacing: 0.5px;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
.user-pill {
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
gap: 8px;
|
|
279
|
+
font-size: 0.78rem;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.user-pill .user-email {
|
|
283
|
+
color: var(--ink-3);
|
|
284
|
+
max-width: 180px;
|
|
285
|
+
overflow: hidden;
|
|
286
|
+
text-overflow: ellipsis;
|
|
287
|
+
white-space: nowrap;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.user-pill .logout-link {
|
|
291
|
+
color: var(--ink-2);
|
|
292
|
+
text-decoration: none;
|
|
293
|
+
font-weight: 600;
|
|
294
|
+
padding: 3px 9px;
|
|
295
|
+
border: 1px solid var(--border-color);
|
|
296
|
+
border-radius: 6px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.user-pill .logout-link:hover {
|
|
300
|
+
color: var(--ink-1);
|
|
301
|
+
border-color: var(--ink-3);
|
|
302
|
+
}
|
|
303
|
+
|
|
275
304
|
/* ── Sidebar ── */
|
|
276
305
|
.sidebar {
|
|
277
306
|
background: var(--bg-card);
|
|
@@ -3,9 +3,19 @@
|
|
|
3
3
|
* All calls go to the same origin the Nuxt app is served from.
|
|
4
4
|
*/
|
|
5
5
|
export function useApi() {
|
|
6
|
+
// Session expired / not logged in → bounce to the OAuth login (full reload, not SPA nav).
|
|
7
|
+
function handleUnauthorized(status: number) {
|
|
8
|
+
if (status === 401 && typeof window !== "undefined") {
|
|
9
|
+
window.location.href = "/auth/login";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
async function apiFetch<T = unknown>(url: string): Promise<T> {
|
|
7
14
|
const r = await fetch(url);
|
|
8
|
-
if (!r.ok)
|
|
15
|
+
if (!r.ok) {
|
|
16
|
+
handleUnauthorized(r.status);
|
|
17
|
+
throw new Error("HTTP " + r.status);
|
|
18
|
+
}
|
|
9
19
|
return r.json() as Promise<T>;
|
|
10
20
|
}
|
|
11
21
|
|
|
@@ -15,7 +25,10 @@ export function useApi() {
|
|
|
15
25
|
headers: { "Content-Type": "application/json" },
|
|
16
26
|
body: JSON.stringify(body)
|
|
17
27
|
});
|
|
18
|
-
if (!r.ok)
|
|
28
|
+
if (!r.ok) {
|
|
29
|
+
handleUnauthorized(r.status);
|
|
30
|
+
throw new Error("HTTP " + r.status);
|
|
31
|
+
}
|
|
19
32
|
return r.json() as Promise<T>;
|
|
20
33
|
}
|
|
21
34
|
|
|
@@ -25,7 +38,10 @@ export function useApi() {
|
|
|
25
38
|
headers: body ? { "Content-Type": "application/json" } : {},
|
|
26
39
|
body: body ? JSON.stringify(body) : undefined
|
|
27
40
|
});
|
|
28
|
-
if (!r.ok)
|
|
41
|
+
if (!r.ok) {
|
|
42
|
+
handleUnauthorized(r.status);
|
|
43
|
+
throw new Error("HTTP " + r.status);
|
|
44
|
+
}
|
|
29
45
|
return r.json() as Promise<T>;
|
|
30
46
|
}
|
|
31
47
|
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
<span>{{ workerText }}</span>
|
|
25
25
|
</div>
|
|
26
26
|
<div class="clock" id="clock">{{ clockText }}</div>
|
|
27
|
+
<div v-if="userEmail" class="user-pill" :title="userEmail">
|
|
28
|
+
<span class="user-email">{{ userEmail }}</span>
|
|
29
|
+
<a href="/auth/logout" class="logout-link" title="Sign out">Sign out</a>
|
|
30
|
+
</div>
|
|
27
31
|
</div>
|
|
28
32
|
</header>
|
|
29
33
|
|
|
@@ -168,6 +172,19 @@ const { status, startPolling } = useStatus();
|
|
|
168
172
|
const { skillFixStats, loadSkillFixStats, startPolling: startSkillPolling } = useSkillFixes();
|
|
169
173
|
const sidebarOpen = ref(false);
|
|
170
174
|
const clockText = ref("");
|
|
175
|
+
const userEmail = ref<string | null>(null);
|
|
176
|
+
|
|
177
|
+
async function loadUser() {
|
|
178
|
+
try {
|
|
179
|
+
const r = await fetch("/auth/me");
|
|
180
|
+
if (r.ok) {
|
|
181
|
+
const data = await r.json();
|
|
182
|
+
userEmail.value = data.email || null;
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// auth disabled or offline — leave userEmail null (pill hidden)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
171
188
|
|
|
172
189
|
const _skillsCount = computed(() => skillFixStats.value?.totalEvents24h ?? null);
|
|
173
190
|
|
|
@@ -207,6 +224,7 @@ const _awaitingCount = computed(() => {
|
|
|
207
224
|
});
|
|
208
225
|
|
|
209
226
|
onMounted(() => {
|
|
227
|
+
loadUser();
|
|
210
228
|
startPolling();
|
|
211
229
|
startSkillPolling(15000);
|
|
212
230
|
loadSkillFixStats();
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth gate for the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Stateless: a signed (HMAC-SHA256) cookie holds the authenticated email + expiry.
|
|
5
|
+
* No server-side session store, so it stays idempotent across worker restarts.
|
|
6
|
+
*
|
|
7
|
+
* Auth is OFF unless GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and DASHBOARD_AUTH_SECRET
|
|
8
|
+
* are all set. That keeps localhost/dev usage working with zero config.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
|
|
12
|
+
const SESSION_COOKIE = "tm8_sess";
|
|
13
|
+
const STATE_COOKIE = "tm8_oauth_state";
|
|
14
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
15
|
+
const STATE_TTL_MS = 10 * 60 * 1000; // 10 min
|
|
16
|
+
const DEFAULT_ALLOWED_DOMAIN = "ignify.co";
|
|
17
|
+
|
|
18
|
+
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
19
|
+
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
20
|
+
|
|
21
|
+
export function buildAuthConfig(values) {
|
|
22
|
+
const clientId = String(values.GOOGLE_CLIENT_ID || "").trim();
|
|
23
|
+
const clientSecret = String(values.GOOGLE_CLIENT_SECRET || "").trim();
|
|
24
|
+
const secret = String(values.DASHBOARD_AUTH_SECRET || "").trim();
|
|
25
|
+
const allowedDomain = String(values.DASHBOARD_ALLOWED_DOMAIN || DEFAULT_ALLOWED_DOMAIN)
|
|
26
|
+
.trim()
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/^@/, "");
|
|
29
|
+
const baseUrl = String(values.DASHBOARD_BASE_URL || "")
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/\/+$/, "");
|
|
32
|
+
|
|
33
|
+
const enabled = Boolean(clientId && clientSecret && secret);
|
|
34
|
+
|
|
35
|
+
return { enabled, clientId, clientSecret, secret, allowedDomain, baseUrl };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function redirectUri(cfg, req) {
|
|
39
|
+
if (cfg.baseUrl) return `${cfg.baseUrl}/auth/callback`;
|
|
40
|
+
// Fallback: derive from request (assumes proxy sets X-Forwarded-Proto)
|
|
41
|
+
const proto = (req.headers["x-forwarded-proto"] || "http").split(",")[0].trim();
|
|
42
|
+
return `${proto}://${req.headers.host}/auth/callback`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- cookie helpers -------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function parseCookies(req) {
|
|
48
|
+
const header = req.headers.cookie;
|
|
49
|
+
const out = {};
|
|
50
|
+
if (!header) return out;
|
|
51
|
+
for (const part of header.split(";")) {
|
|
52
|
+
const idx = part.indexOf("=");
|
|
53
|
+
if (idx === -1) continue;
|
|
54
|
+
const k = part.slice(0, idx).trim();
|
|
55
|
+
const v = part.slice(idx + 1).trim();
|
|
56
|
+
if (k) out[k] = decodeURIComponent(v);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function setCookie(res, name, value, { maxAgeMs, httpOnly = true } = {}) {
|
|
62
|
+
const parts = [`${name}=${encodeURIComponent(value)}`, "Path=/", "SameSite=Lax"];
|
|
63
|
+
if (httpOnly) parts.push("HttpOnly");
|
|
64
|
+
parts.push("Secure"); // dashboard is served over https via the reverse proxy
|
|
65
|
+
if (typeof maxAgeMs === "number") parts.push(`Max-Age=${Math.floor(maxAgeMs / 1000)}`);
|
|
66
|
+
appendSetCookie(res, parts.join("; "));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clearCookie(res, name) {
|
|
70
|
+
appendSetCookie(res, `${name}=; Path=/; SameSite=Lax; HttpOnly; Secure; Max-Age=0`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function appendSetCookie(res, cookie) {
|
|
74
|
+
const existing = res.getHeader("Set-Cookie");
|
|
75
|
+
if (!existing) res.setHeader("Set-Cookie", [cookie]);
|
|
76
|
+
else res.setHeader("Set-Cookie", Array.isArray(existing) ? [...existing, cookie] : [existing, cookie]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- signing --------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function sign(value, secret) {
|
|
82
|
+
return crypto.createHmac("sha256", secret).update(value).digest("base64url");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function timingSafeEqual(a, b) {
|
|
86
|
+
const ab = Buffer.from(a);
|
|
87
|
+
const bb = Buffer.from(b);
|
|
88
|
+
if (ab.length !== bb.length) return false;
|
|
89
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createSessionToken(email, secret) {
|
|
93
|
+
const payload = Buffer.from(JSON.stringify({ email, exp: Date.now() + SESSION_TTL_MS })).toString("base64url");
|
|
94
|
+
return `${payload}.${sign(payload, secret)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function verifySessionToken(token, secret) {
|
|
98
|
+
if (!token || typeof token !== "string") return null;
|
|
99
|
+
const dot = token.lastIndexOf(".");
|
|
100
|
+
if (dot === -1) return null;
|
|
101
|
+
const payload = token.slice(0, dot);
|
|
102
|
+
const sig = token.slice(dot + 1);
|
|
103
|
+
if (!timingSafeEqual(sig, sign(payload, secret))) return null;
|
|
104
|
+
let data;
|
|
105
|
+
try {
|
|
106
|
+
data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (!data || typeof data.email !== "string" || typeof data.exp !== "number") return null;
|
|
111
|
+
if (Date.now() > data.exp) return null;
|
|
112
|
+
return data.email;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Returns the authenticated email, or null. */
|
|
116
|
+
export function getAuthedEmail(req, cfg) {
|
|
117
|
+
if (!cfg.enabled) return null;
|
|
118
|
+
const cookies = parseCookies(req);
|
|
119
|
+
return verifySessionToken(cookies[SESSION_COOKIE], cfg.secret);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- route handling -------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function sendHtml(res, status, html) {
|
|
125
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
126
|
+
res.end(html);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function emailAllowed(email, emailVerified, allowedDomain) {
|
|
130
|
+
if (!email || emailVerified === false) return false;
|
|
131
|
+
return email.toLowerCase().endsWith(`@${allowedDomain}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Handles /auth/* routes. Returns true if the request was handled here.
|
|
136
|
+
* Safe to call before the auth gate (these routes must be reachable while unauthenticated).
|
|
137
|
+
*/
|
|
138
|
+
export async function handleAuthRoutes(req, res, url, cfg) {
|
|
139
|
+
const { pathname } = url;
|
|
140
|
+
if (!pathname.startsWith("/auth/")) return false;
|
|
141
|
+
|
|
142
|
+
if (pathname === "/auth/me" && req.method === "GET") {
|
|
143
|
+
const email = getAuthedEmail(req, cfg);
|
|
144
|
+
if (!email) {
|
|
145
|
+
res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
|
|
146
|
+
res.end(JSON.stringify({ error: "Not authenticated" }));
|
|
147
|
+
} else {
|
|
148
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
149
|
+
res.end(JSON.stringify({ email, authEnabled: true }));
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (pathname === "/auth/logout") {
|
|
155
|
+
clearCookie(res, SESSION_COOKIE);
|
|
156
|
+
res.writeHead(302, { Location: "/auth/login" });
|
|
157
|
+
res.end();
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (pathname === "/auth/login" && req.method === "GET") {
|
|
162
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
163
|
+
setCookie(res, STATE_COOKIE, state, { maxAgeMs: STATE_TTL_MS });
|
|
164
|
+
const params = new URLSearchParams({
|
|
165
|
+
client_id: cfg.clientId,
|
|
166
|
+
redirect_uri: redirectUri(cfg, req),
|
|
167
|
+
response_type: "code",
|
|
168
|
+
scope: "openid email profile",
|
|
169
|
+
state,
|
|
170
|
+
access_type: "online",
|
|
171
|
+
prompt: "select_account",
|
|
172
|
+
hd: cfg.allowedDomain // hint only — verified server-side below
|
|
173
|
+
});
|
|
174
|
+
res.writeHead(302, { Location: `${GOOGLE_AUTH_URL}?${params.toString()}` });
|
|
175
|
+
res.end();
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (pathname === "/auth/callback" && req.method === "GET") {
|
|
180
|
+
const code = url.searchParams.get("code");
|
|
181
|
+
const state = url.searchParams.get("state");
|
|
182
|
+
const cookies = parseCookies(req);
|
|
183
|
+
clearCookie(res, STATE_COOKIE);
|
|
184
|
+
|
|
185
|
+
if (!code || !state || !cookies[STATE_COOKIE] || !timingSafeEqual(state, cookies[STATE_COOKIE])) {
|
|
186
|
+
sendHtml(res, 400, authErrorPage("Invalid OAuth state. Please try logging in again."));
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let email;
|
|
191
|
+
let emailVerified;
|
|
192
|
+
try {
|
|
193
|
+
const claims = await exchangeCode(code, cfg, redirectUri(cfg, req));
|
|
194
|
+
email = claims.email;
|
|
195
|
+
emailVerified = claims.email_verified;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
sendHtml(res, 502, authErrorPage(`Could not reach Google: ${escapeHtml(err.message || String(err))}`));
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!emailAllowed(email, emailVerified, cfg.allowedDomain)) {
|
|
202
|
+
clearCookie(res, SESSION_COOKIE);
|
|
203
|
+
sendHtml(
|
|
204
|
+
res,
|
|
205
|
+
403,
|
|
206
|
+
authErrorPage(
|
|
207
|
+
`Access denied for <b>${escapeHtml(email || "unknown")}</b>. Only @${escapeHtml(cfg.allowedDomain)} accounts may sign in.`
|
|
208
|
+
)
|
|
209
|
+
);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
setCookie(res, SESSION_COOKIE, createSessionToken(email, cfg.secret), { maxAgeMs: SESSION_TTL_MS });
|
|
214
|
+
res.writeHead(302, { Location: "/" });
|
|
215
|
+
res.end();
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function exchangeCode(code, cfg, redirect) {
|
|
223
|
+
const body = new URLSearchParams({
|
|
224
|
+
code,
|
|
225
|
+
client_id: cfg.clientId,
|
|
226
|
+
client_secret: cfg.clientSecret,
|
|
227
|
+
redirect_uri: redirect,
|
|
228
|
+
grant_type: "authorization_code"
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const resp = await fetch(GOOGLE_TOKEN_URL, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
234
|
+
body
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!resp.ok) {
|
|
238
|
+
const text = await resp.text().catch(() => "");
|
|
239
|
+
throw new Error(`token endpoint ${resp.status} ${text.slice(0, 200)}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const json = await resp.json();
|
|
243
|
+
const idToken = json.id_token;
|
|
244
|
+
if (!idToken) throw new Error("no id_token in token response");
|
|
245
|
+
// id_token came directly from Google's token endpoint over TLS, so we can
|
|
246
|
+
// trust its payload without re-verifying the signature.
|
|
247
|
+
return decodeJwtPayload(idToken);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function decodeJwtPayload(jwt) {
|
|
251
|
+
const parts = jwt.split(".");
|
|
252
|
+
if (parts.length !== 3) throw new Error("malformed id_token");
|
|
253
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function escapeHtml(s) {
|
|
257
|
+
return String(s).replace(
|
|
258
|
+
/[&<>"']/g,
|
|
259
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function authErrorPage(message) {
|
|
264
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TM8 Dashboard — Sign in</title>
|
|
265
|
+
<style>body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
|
266
|
+
.card{background:#1e293b;padding:2rem 2.5rem;border-radius:12px;max-width:420px;text-align:center;box-shadow:0 10px 40px rgba(0,0,0,.4)}
|
|
267
|
+
h1{font-size:1.15rem;margin:0 0 .75rem}p{color:#94a3b8;line-height:1.5}a{display:inline-block;margin-top:1.25rem;background:#3b82f6;color:#fff;text-decoration:none;padding:.6rem 1.25rem;border-radius:8px;font-weight:600}</style>
|
|
268
|
+
</head><body><div class="card"><h1>TM8 Dashboard</h1><p>${message}</p><a href="/auth/login">Sign in with Google</a></div></body></html>`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Minimal page shown to unauthenticated users instead of redirect loops on the SPA shell. */
|
|
272
|
+
export function loginRedirect(res) {
|
|
273
|
+
res.writeHead(302, { Location: "/auth/login" });
|
|
274
|
+
res.end();
|
|
275
|
+
}
|
package/src/dashboard/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { getRuntimePaths, isProcessRunning, readPid, readState } from "../runtim
|
|
|
12
12
|
import { restoreSkillBackup } from "../skills/fixer.js";
|
|
13
13
|
import { SKILL_FIX_COOLDOWN_WINDOW_MS_DEFAULT, SKILL_IMPROVEMENT_COOLDOWN_MS_DEFAULT } from "../skills/index.js";
|
|
14
14
|
import { replaceJiraOperationalLabels } from "../worker/forge-sync.js";
|
|
15
|
+
import { buildAuthConfig, getAuthedEmail, handleAuthRoutes, loginRedirect } from "./auth.js";
|
|
15
16
|
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const NUXT_DIST_CANDIDATES = [
|
|
@@ -42,9 +43,17 @@ let _usageCache = null; // { data, expiresAt }
|
|
|
42
43
|
export async function startDashboardServer({ projectRoot, port = 7880 }) {
|
|
43
44
|
const runtimePaths = getRuntimePaths(projectRoot);
|
|
44
45
|
|
|
46
|
+
const { values } = await loadProjectEnv(projectRoot);
|
|
47
|
+
const authConfig = buildAuthConfig(values);
|
|
48
|
+
if (authConfig.enabled) {
|
|
49
|
+
process.stdout.write(` Auth : Google OAuth (allowed domain: @${authConfig.allowedDomain})\n`);
|
|
50
|
+
} else {
|
|
51
|
+
process.stdout.write(" Auth : DISABLED (set GOOGLE_CLIENT_ID/SECRET + DASHBOARD_AUTH_SECRET to enable)\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
const server = createServer(async (req, res) => {
|
|
46
55
|
try {
|
|
47
|
-
await handleRequest(req, res, projectRoot, runtimePaths);
|
|
56
|
+
await handleRequest(req, res, projectRoot, runtimePaths, authConfig);
|
|
48
57
|
} catch (error) {
|
|
49
58
|
sendJson(res, 500, { error: error.message || "Internal server error" });
|
|
50
59
|
}
|
|
@@ -59,8 +68,8 @@ export async function startDashboardServer({ projectRoot, port = 7880 }) {
|
|
|
59
68
|
});
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
async function handleRequest(req, res, projectRoot, runtimePaths) {
|
|
63
|
-
setCorsHeaders(res);
|
|
71
|
+
async function handleRequest(req, res, projectRoot, runtimePaths, authConfig) {
|
|
72
|
+
setCorsHeaders(res, authConfig);
|
|
64
73
|
|
|
65
74
|
if (req.method === "OPTIONS") {
|
|
66
75
|
res.writeHead(204);
|
|
@@ -71,6 +80,21 @@ async function handleRequest(req, res, projectRoot, runtimePaths) {
|
|
|
71
80
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
72
81
|
const pathname = url.pathname;
|
|
73
82
|
|
|
83
|
+
// Auth routes (login/callback/logout/me) must be reachable while unauthenticated.
|
|
84
|
+
if (authConfig?.enabled && (await handleAuthRoutes(req, res, url, authConfig))) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Gate everything else when auth is enabled.
|
|
89
|
+
if (authConfig?.enabled && !getAuthedEmail(req, authConfig)) {
|
|
90
|
+
if (pathname.startsWith("/api/")) {
|
|
91
|
+
sendJson(res, 401, { error: "Not authenticated" });
|
|
92
|
+
} else {
|
|
93
|
+
loginRedirect(res);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
74
98
|
if (!pathname.startsWith("/api/")) {
|
|
75
99
|
const served = await tryServeNuxt(res, pathname);
|
|
76
100
|
if (!served) {
|
|
@@ -176,7 +200,19 @@ async function handleRequest(req, res, projectRoot, runtimePaths) {
|
|
|
176
200
|
sendJson(res, 404, { error: "Not found" });
|
|
177
201
|
}
|
|
178
202
|
|
|
179
|
-
function setCorsHeaders(res) {
|
|
203
|
+
function setCorsHeaders(res, authConfig) {
|
|
204
|
+
// When auth is on, the SPA is same-origin behind the proxy — wildcard CORS would
|
|
205
|
+
// let any site drive the API with the user's cookie. Keep it locked down.
|
|
206
|
+
if (authConfig?.enabled) {
|
|
207
|
+
if (authConfig.baseUrl) {
|
|
208
|
+
res.setHeader("Access-Control-Allow-Origin", authConfig.baseUrl);
|
|
209
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
210
|
+
res.setHeader("Vary", "Origin");
|
|
211
|
+
}
|
|
212
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS");
|
|
213
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
180
216
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
181
217
|
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS");
|
|
182
218
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
package/src/jira.js
CHANGED
|
@@ -306,10 +306,28 @@ function mapIssueDetail(issue, baseUrl) {
|
|
|
306
306
|
? issue.fields.comment.comments.map(mapComment).sort(compareCommentsByCreated)
|
|
307
307
|
: [];
|
|
308
308
|
|
|
309
|
+
// The detail fetch uses fields=*all, so existing attachments are already in
|
|
310
|
+
// the payload — surface them so the intake/planning step can download and
|
|
311
|
+
// read documents that users attach to the issue (a frequent "tài liệu đã
|
|
312
|
+
// đính kèm" complaint where the bot ignored material already on the task).
|
|
313
|
+
const attachments = Array.isArray(issue.fields?.attachment) ? issue.fields.attachment.map(mapAttachment) : [];
|
|
314
|
+
|
|
309
315
|
return {
|
|
310
316
|
...mapIssue(issue, baseUrl),
|
|
311
317
|
descriptionText: adfToText(issue.fields?.description).trim(),
|
|
312
|
-
comments
|
|
318
|
+
comments,
|
|
319
|
+
attachments
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function mapAttachment(attachment) {
|
|
324
|
+
return {
|
|
325
|
+
id: attachment?.id ?? null,
|
|
326
|
+
filename: attachment?.filename ?? "",
|
|
327
|
+
mimeType: attachment?.mimeType ?? "",
|
|
328
|
+
size: attachment?.size ?? null,
|
|
329
|
+
url: attachment?.content ?? null,
|
|
330
|
+
created: attachment?.created ?? null
|
|
313
331
|
};
|
|
314
332
|
}
|
|
315
333
|
|
package/src/memory.js
CHANGED
|
@@ -263,6 +263,7 @@ function normalizeIssueMemoryData(data) {
|
|
|
263
263
|
normalized.last_jira_memory_comment_id = String(normalized.last_jira_memory_comment_id ?? "").trim();
|
|
264
264
|
normalized.code_change_verdict = String(normalized.code_change_verdict ?? "").trim();
|
|
265
265
|
normalized.code_change_input_id = String(normalized.code_change_input_id ?? "").trim();
|
|
266
|
+
normalized.no_code_locked = Boolean(normalized.no_code_locked);
|
|
266
267
|
delete normalized.github_issue_url;
|
|
267
268
|
delete normalized.github_issue_number;
|
|
268
269
|
delete normalized.latest_processed_comment_id;
|
|
@@ -185,11 +185,51 @@ export function shouldDisplayIssueInState(issue) {
|
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Matches an explicit "no code" declaration in any of: "no code", "no_code",
|
|
189
|
+
// "no-code", "nocode" (case-insensitive). Users add this to tell the bot a task
|
|
190
|
+
// is documentation/estimate only and must NOT create a repo issue or push code.
|
|
191
|
+
const NO_CODE_MARKER = /\bno[\s_-]?code\b/iu;
|
|
192
|
+
|
|
193
|
+
// Deterministic no-code detection from explicit user signals, so the verdict no
|
|
194
|
+
// longer depends on the LLM guessing (which flipped to "code" and made users
|
|
195
|
+
// repeat "task no code" on every comment). Checks an explicit Jira label, the
|
|
196
|
+
// description, and the latest human comment.
|
|
197
|
+
export function detectNoCodeMarker(detail, botUser, jira) {
|
|
198
|
+
if (!detail) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const labels = Array.isArray(detail.labels) ? detail.labels : [];
|
|
203
|
+
if (labels.some((label) => /^no[\s_-]?code$/iu.test(String(label || "").trim()))) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (NO_CODE_MARKER.test(String(detail.descriptionText || ""))) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const latestHumanComment = [...(Array.isArray(detail.comments) ? detail.comments : [])]
|
|
212
|
+
.filter((comment) => !String(comment?.bodyText || "").startsWith(PROGRESS_COMMENT_PREFIX))
|
|
213
|
+
.sort(compareCommentsNewestFirst)
|
|
214
|
+
.find((comment) => (botUser ? !jira.isBotAuthor(comment.author, botUser) : true));
|
|
215
|
+
|
|
216
|
+
return Boolean(latestHumanComment && NO_CODE_MARKER.test(String(latestHumanComment.bodyText || "")));
|
|
217
|
+
}
|
|
218
|
+
|
|
188
219
|
export function shouldReuseNoCodeDecision({ issueMemory, latestInputId }) {
|
|
220
|
+
const noGithubIssues = !Array.isArray(issueMemory?.github_issues) || issueMemory.github_issues.length === 0;
|
|
221
|
+
|
|
222
|
+
// Once an explicit no-code marker has locked the issue, keep the no-code
|
|
223
|
+
// verdict sticky across subsequent comments (new input ids) so a follow-up
|
|
224
|
+
// that does not repeat "no code" is not re-evaluated back into a code task.
|
|
225
|
+
if (issueMemory?.no_code_locked && noGithubIssues) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
189
229
|
return (
|
|
190
230
|
issueMemory?.code_change_verdict === "no_code" &&
|
|
191
231
|
issueMemory?.code_change_input_id === latestInputId &&
|
|
192
|
-
|
|
232
|
+
noGithubIssues
|
|
193
233
|
);
|
|
194
234
|
}
|
|
195
235
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
cleanupJiraProgressComments,
|
|
23
23
|
deriveRawTaskRepos,
|
|
24
24
|
deriveTaskRepos,
|
|
25
|
+
detectNoCodeMarker,
|
|
25
26
|
ensureJiraComment,
|
|
26
27
|
getLatestBlockedTaskRestartComment,
|
|
27
28
|
getLatestClarificationInput,
|
|
@@ -632,7 +633,18 @@ export async function processJiraIssue({
|
|
|
632
633
|
issueMemory,
|
|
633
634
|
latestInputId: latestInput.id
|
|
634
635
|
});
|
|
635
|
-
if (
|
|
636
|
+
if (detectNoCodeMarker(detail, botUser, jira)) {
|
|
637
|
+
// Explicit user signal ("task no code" / no-code label) overrides the
|
|
638
|
+
// LLM verdict and locks the issue so later comments stay no-code. This
|
|
639
|
+
// stops the bot from re-deciding "code" and creating a repo issue, which
|
|
640
|
+
// forced users to repeat "task no code" on every comment.
|
|
641
|
+
decisionResult = { verdict: "no_code" };
|
|
642
|
+
issueMemory.no_code_locked = true;
|
|
643
|
+
await logger.info("Explicit no-code marker detected; forcing no_code verdict", {
|
|
644
|
+
issue: detail.key,
|
|
645
|
+
input: latestInput.id
|
|
646
|
+
});
|
|
647
|
+
} else if (shouldReusePreservedNoCodeDecision) {
|
|
636
648
|
decisionResult = { verdict: "no_code" };
|
|
637
649
|
await logger.info("Reusing preserved no-code decision", {
|
|
638
650
|
issue: detail.key,
|